From fb8e0ebd3510aa716ccb12a9f144465bcab95370 Mon Sep 17 00:00:00 2001 From: Simon Beck Date: Fri, 12 Jun 2020 11:14:41 +0200 Subject: [PATCH] Add deletion logic This commit add logic to delete Git and Vault resources. There are two kinds of deletion for each resource: * soft deletion -> the resource can easily be restored if necessary * hard deletion -> the resource is completely deleted Also there's an annotation that is set by default, that prevents the operator from deleting resources. Please see the docs for more info. Resources will be deleted as soon as the k8s resource belonging to it is deleted! --- CHANGELOG.md | 4 + deploy/crds/syn.tools_clusters_crd.yaml | 20 ++ deploy/crds/syn.tools_gitrepos_crd.yaml | 10 + deploy/crds/syn.tools_tenants_crd.yaml | 20 ++ .../ROOT/assets/images/gitlab_settings.png | Bin 0 -> 89920 bytes docs/modules/ROOT/nav.adoc | 2 +- docs/modules/ROOT/pages/configuration.adoc | 50 ++++ docs/modules/ROOT/partials/crds.html | 111 ++++++++ examples/cluster.yaml | 4 + examples/gitrepo-secret.yaml | 2 +- examples/tenant.yaml | 1 + go.sum | 2 + pkg/apis/syn/v1alpha1/cluster_types.go | 6 + pkg/apis/syn/v1alpha1/gitrepo_types.go | 18 +- pkg/apis/syn/v1alpha1/tenant_types.go | 6 + pkg/controller/cluster/cluster_reconcile.go | 185 +++++++++----- .../cluster/cluster_reconcile_test.go | 17 +- pkg/controller/gitrepo/gitrepo_reconcile.go | 80 ++---- .../gitrepo/gitrepo_reconcile_test.go | 16 +- pkg/controller/tenant/tenant_reconcile.go | 77 +++--- pkg/git/gitlab/gitlab.go | 127 +++++++--- pkg/git/gitlab/gitlab_test.go | 6 +- pkg/git/manager/manager.go | 113 ++++++++- pkg/helpers/crd.go | 94 +++++++ pkg/helpers/crd_test.go | 151 ++++++++++- pkg/helpers/values.go | 21 ++ pkg/vault/client.go | 198 +++++++++++++-- pkg/vault/client_test.go | 236 +++++++++++++++++- 28 files changed, 1322 insertions(+), 255 deletions(-) create mode 100644 docs/modules/ROOT/assets/images/gitlab_settings.png create mode 100644 docs/modules/ROOT/pages/configuration.adoc create mode 100644 pkg/helpers/values.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9442f35b..e50ae00a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased +### Added +- The operator can now remove external resources: Vault, Git Repository and Files in a repository + ## v0.1.5 - 2020-06-12 ### Added - Kustomize setup ([#71]) diff --git a/deploy/crds/syn.tools_clusters_crd.yaml b/deploy/crds/syn.tools_clusters_crd.yaml index ee2891ec..2bf2e1c6 100644 --- a/deploy/crds/syn.tools_clusters_crd.yaml +++ b/deploy/crds/syn.tools_clusters_crd.yaml @@ -41,6 +41,16 @@ spec: spec: description: ClusterSpec defines the desired state of Cluster properties: + deletionPolicy: + description: 'DeletionPolicy defines how the external resources should + be treated upon CR deletion. Retain: will not delete any external + resources Delete: will delete the external resources Archive: will + archive the external resources, if it supports that' + enum: + - Delete + - Retain + - Archive + type: string displayName: description: DisplayName of cluster which could be different from metadata.name. Allows cluster renaming should it be needed. @@ -69,6 +79,16 @@ spec: name must be unique. type: string type: object + deletionPolicy: + description: 'DeletionPolicy defines how the external resources + should be treated upon CR deletion. Retain: will not delete any + external resources Delete: will delete the external resources + Archive: will archive the external resources, if it supports that' + enum: + - Delete + - Retain + - Archive + type: string deployKeys: additionalProperties: description: DeployKey defines an SSH key to be used for git operations. diff --git a/deploy/crds/syn.tools_gitrepos_crd.yaml b/deploy/crds/syn.tools_gitrepos_crd.yaml index 69dea9db..8ceea1d8 100644 --- a/deploy/crds/syn.tools_gitrepos_crd.yaml +++ b/deploy/crds/syn.tools_gitrepos_crd.yaml @@ -57,6 +57,16 @@ spec: name must be unique. type: string type: object + deletionPolicy: + description: 'DeletionPolicy defines how the external resources should + be treated upon CR deletion. Retain: will not delete any external + resources Delete: will delete the external resources Archive: will + archive the external resources, if it supports that' + enum: + - Delete + - Retain + - Archive + type: string deployKeys: additionalProperties: description: DeployKey defines an SSH key to be used for git operations. diff --git a/deploy/crds/syn.tools_tenants_crd.yaml b/deploy/crds/syn.tools_tenants_crd.yaml index d8572d53..33935c3a 100644 --- a/deploy/crds/syn.tools_tenants_crd.yaml +++ b/deploy/crds/syn.tools_tenants_crd.yaml @@ -38,6 +38,16 @@ spec: spec: description: TenantSpec defines the desired state of Tenant properties: + deletionPolicy: + description: 'DeletionPolicy defines how the external resources should + be treated upon CR deletion. Retain: will not delete any external + resources Delete: will delete the external resources Archive: will + archive the external resources, if it supports that' + enum: + - Delete + - Retain + - Archive + type: string displayName: description: DisplayName is the display name of the tenant. type: string @@ -58,6 +68,16 @@ spec: name must be unique. type: string type: object + deletionPolicy: + description: 'DeletionPolicy defines how the external resources + should be treated upon CR deletion. Retain: will not delete any + external resources Delete: will delete the external resources + Archive: will archive the external resources, if it supports that' + enum: + - Delete + - Retain + - Archive + type: string deployKeys: additionalProperties: description: DeployKey defines an SSH key to be used for git operations. diff --git a/docs/modules/ROOT/assets/images/gitlab_settings.png b/docs/modules/ROOT/assets/images/gitlab_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..00eac9fbdeb3cc8d963da6fed9c6184e70da8955 GIT binary patch literal 89920 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 K{7%s#^#1^y1O}l1 literal 0 HcmV?d00001 diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 259dda27..bdde2e09 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -10,4 +10,4 @@ include::partial$nav-howtos.adoc[] include::partial$nav-reference.adoc[] .Explanation -include::partial$nav-explanation.adoc[] \ No newline at end of file +include::partial$nav-explanation.adoc[] diff --git a/docs/modules/ROOT/pages/configuration.adoc b/docs/modules/ROOT/pages/configuration.adoc new file mode 100644 index 00000000..73ad1d8a --- /dev/null +++ b/docs/modules/ROOT/pages/configuration.adoc @@ -0,0 +1,50 @@ += Configuration + +== Permissions for other systems + +In order for the operator to work correctly it will need specific permissions in other systems. + +=== Gitlab + +These are the settings needed for the Gitlab API token. + +image::gitlab_settings.png[] + +=== Vault +[source,hcl] +---- +path "kv/data/*" { + capabilities = ["read", "create", "update", "delete"] +} + +path "kv/metadata/*" { + capabilities = ["read", "create", "update", "delete", "list"] +} + +path "kv/delete/*" { + capabilities = ["update"] +} +---- + +== Environment variables + +[cols=",",options="header",] +|=== + +a| Environment Variable + +a| Description + +| VAULT_ADDR | Sets the address to the Vault instance + +| VAULT_TOKEN | Sets the Vault token to be used, ony recommended for testing. in production the K8s authentication should be used. + +| SKIP_VAULT_SETUP | Doesn't create any Vault secrets. Recommended for testing only. + +| DEFAULT_DELETION_POLICY | Sets what deletion policy for external resources (Git, Vault) should be used by default. + +| LIEUTENANT_SYNC_DURATION | Defines with what frequence the CRs will be synced. Default: 5m + +| LIEUTENANT_DELETE_PROTECTION | Defines whether the annotation to protect for accidental deletion should be set by default. Default: true + +|=== diff --git a/docs/modules/ROOT/partials/crds.html b/docs/modules/ROOT/partials/crds.html index 4c7f7a34..d7b6c0bf 100644 --- a/docs/modules/ROOT/partials/crds.html +++ b/docs/modules/ROOT/partials/crds.html @@ -236,6 +236,26 @@

Cluster

+ + +

+deletionPolicy
+ + +DeletionPolicy + + +

+ + +

+

DeletionPolicy defines how the external resources should be treated upon CR deletion. +Retain: will not delete any external resources +Delete: will delete the external resources +Archive: will archive the external resources, if it supports that

+

+ +

@@ -386,6 +406,26 @@

ClusterSpec

+ + +

+deletionPolicy
+ + +DeletionPolicy + + +

+ + +

+

DeletionPolicy defines how the external resources should be treated upon CR deletion. +Retain: will not delete any external resources +Delete: will delete the external resources +Archive: will archive the external resources, if it supports that

+

+ +

ClusterStatus @@ -424,6 +464,17 @@

ClusterStatus +

DeletionPolicy +(string alias)

+

+(Appears on: +ClusterSpec, +GitRepoTemplate, +TenantSpec) +

+

+

DeletionPolicy defines the type deletion policy

+

DeployKey

@@ -882,6 +933,26 @@

GitRepoTemplate

+ + +

+deletionPolicy
+ + +DeletionPolicy + + +

+ + +

+

DeletionPolicy defines how the external resources should be treated upon CR deletion. +Retain: will not delete any external resources +Delete: will delete the external resources +Archive: will archive the external resources, if it supports that

+

+ +

GitType @@ -996,6 +1067,26 @@

Tenant

+ + +

+deletionPolicy
+ + +DeletionPolicy + + +

+ + +

+

DeletionPolicy defines how the external resources should be treated upon CR deletion. +Retain: will not delete any external resources +Delete: will delete the external resources +Archive: will archive the external resources, if it supports that

+

+ +

@@ -1082,6 +1173,26 @@

TenantSpec

+ + +

+deletionPolicy
+ + +DeletionPolicy + + +

+ + +

+

DeletionPolicy defines how the external resources should be treated upon CR deletion. +Retain: will not delete any external resources +Delete: will delete the external resources +Archive: will archive the external resources, if it supports that

+

+ +

TenantStatus diff --git a/examples/cluster.yaml b/examples/cluster.yaml index 95e607cd..65ac1af2 100644 --- a/examples/cluster.yaml +++ b/examples/cluster.yaml @@ -2,11 +2,15 @@ apiVersion: syn.tools/v1alpha1 kind: Cluster metadata: name: c-ae3oso + annotations: + syn.tools/protected-delete: "false" spec: displayName: Big Corp. Production Cluster + deletionPolicy: Delete gitRepoTemplate: path: cluster repoName: cluster1 + deletionPolicy: Delete apiSecretRef: name: example-secret # namespace: syn-lieutenant diff --git a/examples/gitrepo-secret.yaml b/examples/gitrepo-secret.yaml index cf2b6139..47cef88e 100644 --- a/examples/gitrepo-secret.yaml +++ b/examples/gitrepo-secret.yaml @@ -1,7 +1,7 @@ apiVersion: v1 stringData: endpoint: http://192.168.5.42:8080 - token: zbxUWoPykEh5ZjG-mFsa + token: vY3gHvPs82NvYK8dKAGw kind: Secret metadata: name: example-secret diff --git a/examples/tenant.yaml b/examples/tenant.yaml index 15160d8d..c1562cdb 100644 --- a/examples/tenant.yaml +++ b/examples/tenant.yaml @@ -7,6 +7,7 @@ spec: gitRepoTemplate: path: tenant repoName: tenant1 + deletionPolicy: Delete apiSecretRef: name: example-secret # namespace: syn-lieutenant diff --git a/go.sum b/go.sum index 9e16a427..fa4315da 100644 --- a/go.sum +++ b/go.sum @@ -943,6 +943,7 @@ go.elastic.co/apm/module/apmot v1.5.0/go.mod h1:d2KYwhJParTpyw2WnTNy8geNlHKKFX+4 go.elastic.co/fastjson v1.0.0/go.mod h1:PmeUOMMtLHQr9ZS9J9owrAVg0FkaZDRZJEFTTGHtchs= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738 h1:VcrIfasaLFkyjk6KNlXQSzO+B0fZcnECiDrKJsfxka0= go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.mongodb.org/mongo-driver v1.1.0/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= @@ -1403,4 +1404,5 @@ sigs.k8s.io/structured-merge-diff v1.0.1-0.20191108220359-b1b620dd3f06/go.mod h1 sigs.k8s.io/structured-merge-diff v1.0.2/go.mod h1:IIgPezJWb76P0hotTxzDbWsMYB8APh18qZnxkomBpxA= sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +vbom.ml/util v0.0.0-20160121211510-db5cfe13f5cc h1:MksmcCZQWAQJCTA5T0jgI/0sJ51AVm4Z41MrmfczEoc= vbom.ml/util v0.0.0-20160121211510-db5cfe13f5cc/go.mod h1:so/NYdZXCz+E3ZpW0uAoCj6uzU2+8OWDFv/HxUSs7kI= diff --git a/pkg/apis/syn/v1alpha1/cluster_types.go b/pkg/apis/syn/v1alpha1/cluster_types.go index 0d9b1bcb..b26f2ca2 100644 --- a/pkg/apis/syn/v1alpha1/cluster_types.go +++ b/pkg/apis/syn/v1alpha1/cluster_types.go @@ -24,6 +24,12 @@ type ClusterSpec struct { TokenLifeTime string `json:"tokenLifeTime,omitempty"` // Facts are key/value pairs for statically configured facts Facts *Facts `json:"facts,omitempty"` + // DeletionPolicy defines how the external resources should be treated upon CR deletion. + // Retain: will not delete any external resources + // Delete: will delete the external resources + // Archive: will archive the external resources, if it supports that + // +kubebuilder:validation:Enum=Delete;Retain;Archive + DeletionPolicy DeletionPolicy `json:"deletionPolicy,omitempty"` } // BootstrapToken this key is used only once for Steward to register. diff --git a/pkg/apis/syn/v1alpha1/gitrepo_types.go b/pkg/apis/syn/v1alpha1/gitrepo_types.go index 11ce6894..e8a83919 100644 --- a/pkg/apis/syn/v1alpha1/gitrepo_types.go +++ b/pkg/apis/syn/v1alpha1/gitrepo_types.go @@ -24,9 +24,12 @@ const ( // GitPhase enum values const ( - Created = GitPhase("created") - Failed = GitPhase("failed") - PhaseUnknown = GitPhase("") + Created GitPhase = "created" + Failed GitPhase = "failed" + PhaseUnknown GitPhase = "" + ArchivePolicy DeletionPolicy = "Archive" + DeletePolicy DeletionPolicy = "Delete" + RetainPolicy DeletionPolicy = "Retain" ) // GitPhase is the enum for the git phase status @@ -38,6 +41,9 @@ type GitType string // RepoType specifies the type of the repo type RepoType string +// DeletionPolicy defines the type deletion policy +type DeletionPolicy string + // GitRepoSpec defines the desired state of GitRepo type GitRepoSpec struct { GitRepoTemplate `json:",inline"` @@ -64,6 +70,12 @@ type GitRepoTemplate struct { // TemplateFiles is a list of files that should be pushed to the repository // after its creation. TemplateFiles map[string]string `json:"templateFiles,omitempty"` + // DeletionPolicy defines how the external resources should be treated upon CR deletion. + // Retain: will not delete any external resources + // Delete: will delete the external resources + // Archive: will archive the external resources, if it supports that + // +kubebuilder:validation:Enum=Delete;Retain;Archive + DeletionPolicy DeletionPolicy `json:"deletionPolicy,omitempty"` } // DeployKey defines an SSH key to be used for git operations. diff --git a/pkg/apis/syn/v1alpha1/tenant_types.go b/pkg/apis/syn/v1alpha1/tenant_types.go index b3a2f9ba..3d051a19 100644 --- a/pkg/apis/syn/v1alpha1/tenant_types.go +++ b/pkg/apis/syn/v1alpha1/tenant_types.go @@ -12,6 +12,12 @@ type TenantSpec struct { GitRepoURL string `json:"gitRepoURL,omitempty"` // GitRepoTemplate Template for managing the GitRepo object. If not set, no GitRepo object will be created. GitRepoTemplate *GitRepoTemplate `json:"gitRepoTemplate,omitempty"` + // DeletionPolicy defines how the external resources should be treated upon CR deletion. + // Retain: will not delete any external resources + // Delete: will delete the external resources + // Archive: will archive the external resources, if it supports that + // +kubebuilder:validation:Enum=Delete;Retain;Archive + DeletionPolicy DeletionPolicy `json:"deletionPolicy,omitempty"` } // TenantStatus defines the observed state of Tenant diff --git a/pkg/controller/cluster/cluster_reconcile.go b/pkg/controller/cluster/cluster_reconcile.go index 9afcf9ea..8e085d47 100644 --- a/pkg/controller/cluster/cluster_reconcile.go +++ b/pkg/controller/cluster/cluster_reconcile.go @@ -11,8 +11,10 @@ import ( "strings" "time" + "github.com/go-logr/logr" synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" synTenant "github.com/projectsyn/lieutenant-operator/pkg/controller/tenant" + "github.com/projectsyn/lieutenant-operator/pkg/git/manager" "github.com/projectsyn/lieutenant-operator/pkg/helpers" "github.com/projectsyn/lieutenant-operator/pkg/vault" corev1 "k8s.io/api/core/v1" @@ -21,6 +23,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/reconcile" ) @@ -28,6 +31,7 @@ const ( clusterClassContent = `classes: - %s.%s ` + finalizerName = "cluster.lieutenant.syn.tools" ) // Reconcile reads that state of the cluster for a Cluster object and makes changes based on the state read @@ -38,82 +42,119 @@ func (r *ReconcileCluster) Reconcile(request reconcile.Request) (reconcile.Resul reqLogger := log.WithValues("Request.Namespace", request.Namespace, "Request.Name", request.Name) reqLogger.Info("Reconciling Cluster") - instance := &synv1alpha1.Cluster{} + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - err := r.client.Get(context.TODO(), request.NamespacedName, instance) - if err != nil { - if errors.IsNotFound(err) { - return reconcile.Result{}, nil - } - return reconcile.Result{}, err - } - - if err := r.createClusterRBAC(*instance); err != nil { - return reconcile.Result{}, err - } + instance := &synv1alpha1.Cluster{} - if instance.Status.BootstrapToken == nil { - reqLogger.Info("Adding status to Cluster object") - err := r.newStatus(instance) + err := r.client.Get(context.TODO(), request.NamespacedName, instance) if err != nil { - return reconcile.Result{}, err + if errors.IsNotFound(err) { + return nil + } + return err } - } - if time.Now().After(instance.Status.BootstrapToken.ValidUntil.Time) { - instance.Status.BootstrapToken.TokenValid = false - } + if err := r.createClusterRBAC(*instance); err != nil { + return err + } - gvk := schema.GroupVersionKind{ - Version: instance.APIVersion, - Kind: instance.Kind, - } + if instance.Status.BootstrapToken == nil { + reqLogger.Info("Adding status to Cluster object") + err := r.newStatus(instance) + if err != nil { + return err + } + } - if len(instance.Spec.GitRepoTemplate.DisplayName) == 0 { - instance.Spec.GitRepoTemplate.DisplayName = instance.Spec.DisplayName - } + if time.Now().After(instance.Status.BootstrapToken.ValidUntil.Time) { + instance.Status.BootstrapToken.TokenValid = false + } - err = helpers.CreateOrUpdateGitRepo(instance, gvk, instance.Spec.GitRepoTemplate, r.client, instance.Spec.TenantRef) - if err != nil { - reqLogger.Error(err, "Cannot create or update git repo object") - return reconcile.Result{}, err - } + gvk := schema.GroupVersionKind{ + Version: instance.APIVersion, + Kind: instance.Kind, + } - repoName := request.NamespacedName - repoName.Name = instance.Spec.TenantRef.Name + if len(instance.Spec.GitRepoTemplate.DisplayName) == 0 { + instance.Spec.GitRepoTemplate.DisplayName = instance.Spec.DisplayName + } + + instance.Spec.GitRepoTemplate.DeletionPolicy = instance.Spec.DeletionPolicy - if strings.ToLower(os.Getenv("SKIP_VAULT_SETUP")) != "true" { - client, err := vault.NewClient() + err = helpers.CreateOrUpdateGitRepo(instance, gvk, instance.Spec.GitRepoTemplate, r.client, instance.Spec.TenantRef) if err != nil { - return reconcile.Result{}, err + reqLogger.Error(err, "Cannot create or update git repo object") + return err } - token, err := r.getServiceAccountToken(instance) + repoName := request.NamespacedName + repoName.Name = instance.Spec.TenantRef.Name + + var vaultClient vault.VaultClient = nil + secretPath := path.Join(instance.Spec.TenantRef.Name, instance.Name, "steward") + + deletionPolicy := instance.Spec.DeletionPolicy + if deletionPolicy == "" { + deletionPolicy = helpers.GetDeletionPolicy() + } + + vaultClient, err = vault.NewClient(deletionPolicy, reqLogger) if err != nil { - return reconcile.Result{}, err + return err + } + + if strings.ToLower(os.Getenv("SKIP_VAULT_SETUP")) != "true" { + + token, err := r.getServiceAccountToken(instance) + if err != nil { + return err + } + + err = vaultClient.AddSecrets([]vault.VaultSecret{{Path: secretPath, Value: token}}) + if err != nil { + return err + } + + } + + deleted := helpers.HandleDeletion(instance, finalizerName, r.client) + if deleted.FinalizerRemoved { + if vaultClient != nil { + err := vaultClient.RemoveSecrets([]vault.VaultSecret{{Path: path.Dir(secretPath), Value: ""}}) + if err != nil { + return err + } + } + err = r.removeClusterFileFromTenant(instance.GetName(), repoName, reqLogger) + if err != nil { + return err + } + } + if deleted.Deleted { + return r.client.Update(context.TODO(), instance) } - err = client.SetToken(path.Join(instance.Spec.TenantRef.Name, instance.Name, "steward"), token, reqLogger) + err = r.updateTenantGitRepo(repoName, instance.GetName()) if err != nil { - return reconcile.Result{}, err + return err } - } - err = r.updateTenantGitRepo(repoName, instance.GetName()) - if err != nil { - return reconcile.Result{}, err - } + helpers.AddTenantLabel(&instance.ObjectMeta, instance.Spec.TenantRef.Name) + helpers.AddDeletionProtection(instance) + controllerutil.AddFinalizer(instance, finalizerName) - helpers.AddTenantLabel(&instance.ObjectMeta, instance.Spec.TenantRef.Name) - instance.Spec.GitRepoURL, instance.Spec.GitHostKeys, err = helpers.GetGitRepoURLAndHostKeys(instance, r.client) - if err != nil { - return reconcile.Result{}, err - } - err = r.client.Status().Update(context.TODO(), instance) - if err != nil { - return reconcile.Result{}, err - } - return reconcile.Result{}, r.client.Update(context.TODO(), instance) + instance.Spec.GitRepoURL, instance.Spec.GitHostKeys, err = helpers.GetGitRepoURLAndHostKeys(instance, r.client) + if err != nil { + return err + } + err = r.client.Status().Update(context.TODO(), instance) + if err != nil { + return err + } + return r.client.Update(context.TODO(), instance) + }) + + return reconcile.Result{}, err } func (r *ReconcileCluster) generateToken() (string, error) { @@ -153,11 +194,15 @@ func (r *ReconcileCluster) newStatus(cluster *synv1alpha1.Cluster) error { return nil } +func (r *ReconcileCluster) getTenantCR(tenant types.NamespacedName) (*synv1alpha1.Tenant, error) { + tenantCR := &synv1alpha1.Tenant{} + return tenantCR, r.client.Get(context.TODO(), tenant, tenantCR) +} + func (r *ReconcileCluster) updateTenantGitRepo(tenant types.NamespacedName, clusterName string) error { return retry.RetryOnConflict(retry.DefaultBackoff, func() error { - tenantCR := &synv1alpha1.Tenant{} - err := r.client.Get(context.TODO(), tenant, tenantCR) + tenantCR, err := r.getTenantCR(tenant) if err != nil { return err } @@ -205,3 +250,27 @@ func (r *ReconcileCluster) getServiceAccountToken(instance metav1.Object) (strin return "", fmt.Errorf("no matching secrets found") } + +func (r *ReconcileCluster) removeClusterFileFromTenant(clusterName string, tenantInfo types.NamespacedName, reqLogger logr.Logger) error { + + tenantCR, err := r.getTenantCR(tenantInfo) + if err != nil { + return err + } + + fileName := clusterName + ".yml" + + if tenantCR.Spec.GitRepoTemplate.TemplateFiles == nil { + return nil + } + + if _, ok := tenantCR.Spec.GitRepoTemplate.TemplateFiles[fileName]; ok { + tenantCR.Spec.GitRepoTemplate.TemplateFiles[fileName] = manager.DeletionMagicString + err := r.client.Update(context.TODO(), tenantCR) + if err != nil { + return err + } + } + + return nil +} diff --git a/pkg/controller/cluster/cluster_reconcile_test.go b/pkg/controller/cluster/cluster_reconcile_test.go index 61234cad..58cca2c4 100644 --- a/pkg/controller/cluster/cluster_reconcile_test.go +++ b/pkg/controller/cluster/cluster_reconcile_test.go @@ -4,12 +4,12 @@ import ( "context" "fmt" "os" + "path" "reflect" "strconv" "testing" "time" - "github.com/go-logr/logr" "github.com/projectsyn/lieutenant-operator/pkg/apis" synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" "github.com/projectsyn/lieutenant-operator/pkg/controller/tenant" @@ -176,11 +176,12 @@ func TestReconcileCluster_Reconcile(t *testing.T) { assert.NoError(t, err) if tt.skipVault { - assert.Empty(t, testMockClient.token) + assert.Empty(t, testMockClient.secrets) } else { saToken, err := r.getServiceAccountToken(newCluster) + saSecrets := []vault.VaultSecret{{Value: saToken, Path: path.Join(tt.fields.tenantName, tt.fields.objName, "steward")}} assert.NoError(t, err) - assert.Equal(t, testMockClient.token, saToken) + assert.Equal(t, testMockClient.secrets, saSecrets) } role := &rbacv1.Role{} err = cl.Get(context.TODO(), req.NamespacedName, role) @@ -204,11 +205,15 @@ func TestReconcileCluster_Reconcile(t *testing.T) { } type TestMockClient struct { - token string + secrets []vault.VaultSecret } -func (m *TestMockClient) SetToken(secretPath, token string, log logr.Logger) error { - m.token = token +func (m *TestMockClient) AddSecrets(secrets []vault.VaultSecret) error { + m.secrets = secrets + return nil +} + +func (m *TestMockClient) RemoveSecrets(secrets []vault.VaultSecret) error { return nil } diff --git a/pkg/controller/gitrepo/gitrepo_reconcile.go b/pkg/controller/gitrepo/gitrepo_reconcile.go index ea908284..363f390b 100644 --- a/pkg/controller/gitrepo/gitrepo_reconcile.go +++ b/pkg/controller/gitrepo/gitrepo_reconcile.go @@ -3,26 +3,19 @@ package gitrepo import ( "context" "fmt" - "net/url" "github.com/projectsyn/lieutenant-operator/pkg/git/manager" "github.com/projectsyn/lieutenant-operator/pkg/helpers" synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" - corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/reconcile" ) const ( - // SecretTokenName is the name of the secret entry containing the token - SecretTokenName = "token" - // SecretHostKeysName is the name of the secret entry containing the SSH host keys - SecretHostKeysName = "hostKeys" - // SecretEndpointName is the name of the secret entry containing the api endpoint - SecretEndpointName = "endpoint" + finalizerName = "gitrepo.lieutenant.syn.tools" ) // Reconcile will create or delete a git repository based on the event. @@ -46,64 +39,15 @@ func (r *ReconcileGitRepo) Reconcile(request reconcile.Request) (reconcile.Resul return err } - secret := &corev1.Secret{} - namespacedName := types.NamespacedName{ - Name: instance.Spec.APISecretRef.Name, - Namespace: instance.Namespace, - } - - if len(instance.Spec.APISecretRef.Namespace) > 0 { - namespacedName.Namespace = instance.Spec.APISecretRef.Namespace - } - - err = r.client.Get(context.TODO(), namespacedName, secret) - if err != nil { - return fmt.Errorf("error getting git secret: %v", err) - } - - if hostKeys, ok := secret.Data[SecretHostKeysName]; ok { - instance.Status.HostKeys = string(hostKeys) - } - - if _, ok := secret.Data[SecretEndpointName]; !ok { - return fmt.Errorf("secret %s does not contain endpoint data", secret.GetName()) - } - - if _, ok := secret.Data[SecretTokenName]; !ok { - return fmt.Errorf("secret %s does not contain token", secret.GetName()) - } - - repoURL, err := url.Parse(string(secret.Data[SecretEndpointName]) + "/" + instance.Spec.Path + "/" + instance.Spec.RepoName) - + repo, hostKeys, err := manager.GetGitClient(&instance.Spec.GitRepoTemplate, instance.GetNamespace(), reqLogger, r.client) if err != nil { return err } - repoOptions := manager.RepoOptions{ - Credentials: manager.Credentials{ - Token: string(secret.Data[SecretTokenName]), - }, - DeployKeys: instance.Spec.DeployKeys, - Logger: reqLogger, - Path: instance.Spec.Path, - RepoName: instance.Spec.RepoName, - DisplayName: instance.Spec.DisplayName, - URL: repoURL, - TemplateFiles: instance.Spec.TemplateFiles, - } - - repo, err := manager.NewRepo(repoOptions) - if err != nil { - return err - } - - err = repo.Connect() - if err != nil { - return err - } + instance.Status.HostKeys = hostKeys if !r.repoExists(repo) { - reqLogger.Info("creating git repo", SecretEndpointName, repoOptions.URL) + reqLogger.Info("creating git repo", manager.SecretEndpointName, repo.FullURL()) err := repo.Create() if err != nil { return r.handleRepoError(err, instance, repo) @@ -111,6 +55,17 @@ func (r *ReconcileGitRepo) Reconcile(request reconcile.Request) (reconcile.Resul reqLogger.Info("successfully created the repository") } + deleted := helpers.HandleDeletion(instance, finalizerName, r.client) + if deleted.FinalizerRemoved { + err := repo.Remove() + if err != nil { + return err + } + } + if deleted.Deleted { + return r.client.Update(context.TODO(), instance) + } + err = repo.CommitTemplateFiles() if err != nil { return r.handleRepoError(err, instance, repo) @@ -126,6 +81,9 @@ func (r *ReconcileGitRepo) Reconcile(request reconcile.Request) (reconcile.Resul } helpers.AddTenantLabel(&instance.ObjectMeta, instance.Spec.TenantRef.Name) + helpers.AddDeletionProtection(instance) + + controllerutil.AddFinalizer(instance, finalizerName) err = r.client.Update(context.TODO(), instance) if err != nil { diff --git a/pkg/controller/gitrepo/gitrepo_reconcile_test.go b/pkg/controller/gitrepo/gitrepo_reconcile_test.go index 1f6fd88b..293dedfd 100644 --- a/pkg/controller/gitrepo/gitrepo_reconcile_test.go +++ b/pkg/controller/gitrepo/gitrepo_reconcile_test.go @@ -102,9 +102,9 @@ func TestReconcileGitRepo_Reconcile(t *testing.T) { Namespace: tt.fields.namespace, }, Data: map[string][]byte{ - SecretEndpointName: []byte(tt.fields.URL), - SecretTokenName: []byte("secret"), - SecretHostKeysName: []byte("somekey"), + manager.SecretEndpointName: []byte(tt.fields.URL), + manager.SecretTokenName: []byte("secret"), + manager.SecretHostKeysName: []byte("somekey"), }, } @@ -134,7 +134,7 @@ func TestReconcileGitRepo_Reconcile(t *testing.T) { err = cl.Get(context.TODO(), req.NamespacedName, gitRepo) assert.NoError(t, err) if !tt.wantErr { - assert.Equal(t, string(secret.Data[SecretHostKeysName]), gitRepo.Status.HostKeys) + assert.Equal(t, string(secret.Data[manager.SecretHostKeysName]), gitRepo.Status.HostKeys) assert.Equal(t, synv1alpha1.DefaultRepoType, gitRepo.Spec.RepoType) assert.Equal(t, tt.fields.displayName, gitRepo.Spec.DisplayName) assert.Equal(t, tt.fields.displayName, savedGitRepoOpt.DisplayName) @@ -161,6 +161,14 @@ func (t testRepoImplementation) New(options manager.RepoOptions) (manager.Repo, return t, nil } +func (t testRepoImplementation) RemoveFile(path string) error { + return nil +} + +func (t testRepoImplementation) Remove() error { + return nil +} + func (t testRepoImplementation) Type() string { return "test" } diff --git a/pkg/controller/tenant/tenant_reconcile.go b/pkg/controller/tenant/tenant_reconcile.go index b742a448..9d169968 100644 --- a/pkg/controller/tenant/tenant_reconcile.go +++ b/pkg/controller/tenant/tenant_reconcile.go @@ -24,47 +24,52 @@ func (r *ReconcileTenant) Reconcile(request reconcile.Request) (reconcile.Result reqLogger := log.WithValues("Request.Namespace", request.Namespace, "Request.Name", request.Name) reqLogger.Info("Reconciling Tenant") - // Fetch the Tenant instance - instance := &synv1alpha1.Tenant{} - err := r.client.Get(context.TODO(), request.NamespacedName, instance) - if err != nil { - if errors.IsNotFound(err) { - // Request object not found, could have been deleted after reconcile request. - // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. - // Return and don't requeue - return reconcile.Result{}, nil + err := retry.OnError(retry.DefaultBackoff, errors.IsNotFound, func() error { + // Fetch the Tenant instance + instance := &synv1alpha1.Tenant{} + err := r.client.Get(context.TODO(), request.NamespacedName, instance) + if err != nil { + if errors.IsNotFound(err) { + // Request object not found, could have been deleted after reconcile request. + // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. + // Return and don't requeue + return nil + } + // Error reading the object - requeue the request. + return err } - // Error reading the object - requeue the request. - return reconcile.Result{}, err - } - gvk := schema.GroupVersionKind{ - Version: instance.APIVersion, - Kind: instance.Kind, - } + gvk := schema.GroupVersionKind{ + Version: instance.APIVersion, + Kind: instance.Kind, + } + + if len(instance.Spec.GitRepoTemplate.DisplayName) == 0 { + instance.Spec.GitRepoTemplate.DisplayName = instance.Spec.DisplayName + } - if len(instance.Spec.GitRepoTemplate.DisplayName) == 0 { - instance.Spec.GitRepoTemplate.DisplayName = instance.Spec.DisplayName - } + commonClassFile := CommonClassName + ".yml" + if instance.Spec.GitRepoTemplate.TemplateFiles == nil { + instance.Spec.GitRepoTemplate.TemplateFiles = map[string]string{} + } + if _, ok := instance.Spec.GitRepoTemplate.TemplateFiles[commonClassFile]; !ok { + instance.Spec.GitRepoTemplate.TemplateFiles[commonClassFile] = "" + } - commonClassFile := CommonClassName + ".yml" - if instance.Spec.GitRepoTemplate.TemplateFiles == nil { - instance.Spec.GitRepoTemplate.TemplateFiles = map[string]string{} - } - if _, ok := instance.Spec.GitRepoTemplate.TemplateFiles[commonClassFile]; !ok { - instance.Spec.GitRepoTemplate.TemplateFiles[commonClassFile] = "" - } + instance.Spec.GitRepoTemplate.DeletionPolicy = instance.Spec.DeletionPolicy + + err = helpers.CreateOrUpdateGitRepo(instance, gvk, instance.Spec.GitRepoTemplate, r.client, corev1.LocalObjectReference{Name: instance.GetName()}) + if err != nil { + return err + } - err = helpers.CreateOrUpdateGitRepo(instance, gvk, instance.Spec.GitRepoTemplate, r.client, corev1.LocalObjectReference{Name: instance.GetName()}) - if err != nil { - return reconcile.Result{}, err - } - err = retry.OnError(retry.DefaultBackoff, errors.IsNotFound, func() error { instance.Spec.GitRepoURL, _, err = helpers.GetGitRepoURLAndHostKeys(instance, r.client) - return err + if err != nil { + return err + } + + helpers.AddDeletionProtection(instance) + return r.client.Update(context.TODO(), instance) }) - if err != nil { - return reconcile.Result{}, err - } - return reconcile.Result{}, r.client.Update(context.TODO(), instance) + return reconcile.Result{}, err } diff --git a/pkg/git/gitlab/gitlab.go b/pkg/git/gitlab/gitlab.go index 9c1b89ee..4b81e5f6 100644 --- a/pkg/git/gitlab/gitlab.go +++ b/pkg/git/gitlab/gitlab.go @@ -21,7 +21,8 @@ func init() { manager.Register(&Gitlab{}) } -// Gitlab holds the necessary information to communincate with a Gitlab server +// Gitlab holds the necessary information to communincate with a Gitlab server. +// Each Gitlab instance will handle exactly one project. type Gitlab struct { client *gitlab.Client credentials manager.Credentials @@ -141,8 +142,42 @@ func (g *Gitlab) removeDeployKeys(deleteKeys map[string]synv1alpha1.DeployKey) e return err } -// Delete deletes the project handled by the gitlab instance -func (g *Gitlab) Delete() error { +// Remove removes the project according to the recycle policy. +// Delete -> project gets deleted +// Archive -> project gets archived +// Retain -> nothing happens +func (g *Gitlab) Remove() error { + switch g.ops.DeletionPolicy { + case synv1alpha1.DeletePolicy: + g.log.Info("deleting", "project", g.project.Name) + return g.delete() + case synv1alpha1.ArchivePolicy: + g.log.Info("archiving", "project", g.project.Name) + return g.archive() + default: + g.log.Info("retaining", "project", g.project.Name) + return nil + } +} + +// archive archives the project handled by this gitlab instance +func (g *Gitlab) archive() error { + err := g.getProject() + if err != nil { + return err + } + + if g.project == nil { + return fmt.Errorf("no project %v found, can't archive", g.ops.Path) + } + + _, _, err = g.client.Projects.ArchiveProject(g.project.ID) + + return err +} + +// delete deletes the project handled by the gitlab instance +func (g *Gitlab) delete() error { // make sure to have the latest version of the project err := g.getProject() if err != nil { @@ -316,34 +351,35 @@ func (g *Gitlab) CommitTemplateFiles() error { return nil } - filesToApply, err := g.compareFiles() + filesToCommit, err := g.compareFiles() if err != nil { return err } - if len(filesToApply) == 0 { + if len(filesToCommit) == 0 { // we're done here return nil } g.log.Info("populating repository with template files") - co := &gitlab.CreateCommitOptions{ - AuthorEmail: builtinx.NewString("lieutenant-operator@syn.local"), - AuthorName: builtinx.NewString("Lieutenant Operator"), - Branch: builtinx.NewString("master"), - CommitMessage: builtinx.NewString("Provision templates"), - } + co := g.getCommitOptions() - co.Actions = []*gitlab.CommitAction{} + for _, file := range filesToCommit { + action := &gitlab.CommitAction{ + FilePath: file.FileName, + Content: file.Content, + } - for name, content := range filesToApply { + if file.Delete { + g.log.Info("deleting file from repository", "file", action.FilePath, "repository", g.project.Name) + action.Action = gitlab.FileDelete + } else { + g.log.Info("writing file to repository", "file", action.FilePath, "repository", g.project.Name) + action.Action = gitlab.FileCreate + } - co.Actions = append(co.Actions, &gitlab.CommitAction{ - Action: gitlab.FileCreate, - FilePath: name, - Content: content, - }) + co.Actions = append(co.Actions, action) } _, _, err = g.client.Commits.CreateCommit(g.project.ID, co, nil) @@ -354,29 +390,62 @@ func (g *Gitlab) CommitTemplateFiles() error { // compareFiles will compare the files of the repositories root with the // files that should be created. If there are existing files they will be // dropped. -func (g *Gitlab) compareFiles() (map[string]string, error) { +func (g *Gitlab) compareFiles() ([]manager.CommitFile, error) { - newmap := map[string]string{} + files := []manager.CommitFile{} trees, _, err := g.client.Repositories.ListTree(g.project.ID, nil, nil) if err != nil { // if the tree is not found it's probably just because there are no files at all currently... + // So we have to apply all pending ones. if strings.Contains(err.Error(), "Tree Not Found") { - return g.ops.TemplateFiles, nil + + for name, content := range g.ops.TemplateFiles { + files = append(files, manager.CommitFile{ + FileName: name, + Content: content, + }) + } + + return files, nil } - return newmap, fmt.Errorf("cannot list files in repository: %s", err) + return files, fmt.Errorf("cannot list files in repository: %s", err) } - treeMap := map[string]bool{} + compareMap := map[string]bool{} for _, tree := range trees { - treeMap[tree.Path] = true + compareMap[tree.Path] = true + } + + for name, content := range g.ops.TemplateFiles { + if _, ok := compareMap[name]; ok && content == manager.DeletionMagicString { + files = append(files, manager.CommitFile{ + FileName: name, + Content: content, + Delete: true, + }) + } else if !ok && content != manager.DeletionMagicString { + files = append(files, manager.CommitFile{ + FileName: name, + Content: content, + }) + } + } - for k, v := range g.ops.TemplateFiles { - if _, ok := treeMap[k]; !ok { - newmap[k] = v - } + return files, nil +} + +func (g *Gitlab) getCommitOptions() *gitlab.CreateCommitOptions { + + co := &gitlab.CreateCommitOptions{ + AuthorEmail: builtinx.NewString("lieutenant-operator@syn.local"), + AuthorName: builtinx.NewString("Lieutenant Operator"), + Branch: builtinx.NewString("master"), + CommitMessage: builtinx.NewString("Update cluster files"), } - return newmap, nil + co.Actions = []*gitlab.CommitAction{} + + return co } diff --git a/pkg/git/gitlab/gitlab_test.go b/pkg/git/gitlab/gitlab_test.go index b137ff27..d0f1721b 100644 --- a/pkg/git/gitlab/gitlab_test.go +++ b/pkg/git/gitlab/gitlab_test.go @@ -114,7 +114,7 @@ func testGetCreateServer() *httptest.Server { mux.HandleFunc("/api/v4/projects", func(res http.ResponseWriter, req *http.Request) { createProjectOptions := gitlab.CreateProjectOptions{} buf := new(bytes.Buffer) - buf.ReadFrom(req.Body) + _, _ = buf.ReadFrom(req.Body) err := json.Unmarshal(buf.Bytes(), &createProjectOptions) response := http.StatusOK if err != nil { @@ -244,7 +244,7 @@ func TestGitlab_Delete(t *testing.T) { _ = g.Connect() - if err := g.Delete(); (err != nil) != tt.wantErr { + if err := g.delete(); (err != nil) != tt.wantErr { t.Errorf("Delete() error = %v, wantErr %v", err, tt.wantErr) } }) @@ -274,7 +274,7 @@ func testGetUpdateServer(fail bool) *httptest.Server { mux.HandleFunc("/api/v4/projects/3", func(res http.ResponseWriter, req *http.Request) { editProjectOptions := gitlab.EditProjectOptions{} buf := new(bytes.Buffer) - buf.ReadFrom(req.Body) + _, _ = buf.ReadFrom(req.Body) err := json.Unmarshal(buf.Bytes(), &editProjectOptions) response := http.StatusOK if err != nil { diff --git a/pkg/git/manager/manager.go b/pkg/git/manager/manager.go index 8e89d1e3..871230bd 100644 --- a/pkg/git/manager/manager.go +++ b/pkg/git/manager/manager.go @@ -1,14 +1,31 @@ package manager import ( + "context" "fmt" "net/url" synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + corev1 "k8s.io/api/core/v1" "github.com/go-logr/logr" ) +const ( + // SecretTokenName is the name of the secret entry containing the token + SecretTokenName = "token" + // SecretHostKeysName is the name of the secret entry containing the SSH host keys + SecretHostKeysName = "hostKeys" + // SecretEndpointName is the name of the secret entry containing the api endpoint + SecretEndpointName = "endpoint" + // DeletionMagicString defines when a file should be deleted from the repository + //TODO it will be replaced with somethin better in the futur TODO + DeletionMagicString = "{delete}" +) + var ( // implementations holds each a copy of the registered Git implementation implementations []Implementation @@ -41,15 +58,17 @@ func NewRepo(opts RepoOptions) (Repo, error) { // RepoOptions hold the options for creating a repository. The credentials are required to work. The deploykeys are // optional but desired. +// If not provided DeletionPolicy will default to archive. type RepoOptions struct { - Credentials Credentials - DeployKeys map[string]synv1alpha1.DeployKey - Logger logr.Logger - URL *url.URL - Path string - RepoName string - DisplayName string - TemplateFiles map[string]string + Credentials Credentials + DeployKeys map[string]synv1alpha1.DeployKey + Logger logr.Logger + URL *url.URL + Path string + RepoName string + DisplayName string + TemplateFiles map[string]string + DeletionPolicy synv1alpha1.DeletionPolicy } // Credentials holds the authentication information for the API. Most of the times this @@ -71,9 +90,12 @@ type Repo interface { // Read will read the repository and populate it with the deployed keys. It will throw an // error if the repo is not found on the server. Read() error - Delete() error + // Remove will remove the git project according to the recycle policy + Remove() error Connect() error - // CommitTemplateFiles uploads given files to the repository + // CommitTemplateFiles uploads given files to the repository. + // files that contain exactly the deletion magic string should be removed + // when calling this function. TODO: will be replaced with something better in the future. CommitTemplateFiles() error } @@ -85,3 +107,74 @@ type Implementation interface { // New returns a clean new Repo implementation with the given URL New(options RepoOptions) (Repo, error) } + +// CommitFile contains all information about a file that should be committed to git +// TODO migrate to the CRDs in the future. +type CommitFile struct { + FileName string + Content string + Delete bool +} + +// GetGitClient will return a git client from a provided template. This does a lot more +// plumbing than the simple NewClient() call. If you're needing a git client from a +// reconcile function, this is the way to go. +func GetGitClient(instance *synv1alpha1.GitRepoTemplate, namespace string, reqLogger logr.Logger, client client.Client) (Repo, string, error) { + secret := &corev1.Secret{} + namespacedName := types.NamespacedName{ + Name: instance.APISecretRef.Name, + Namespace: namespace, + } + + if len(instance.APISecretRef.Namespace) > 0 { + namespacedName.Namespace = instance.APISecretRef.Namespace + } + + err := client.Get(context.TODO(), namespacedName, secret) + if err != nil { + return nil, "", fmt.Errorf("error getting git secret: %v", err) + } + + hostKeysString := "" + if hostKeys, ok := secret.Data[SecretHostKeysName]; ok { + hostKeysString = string(hostKeys) + } + + if _, ok := secret.Data[SecretEndpointName]; !ok { + return nil, "", fmt.Errorf("secret %s does not contain endpoint data", secret.GetName()) + } + + if _, ok := secret.Data[SecretTokenName]; !ok { + return nil, "", fmt.Errorf("secret %s does not contain token", secret.GetName()) + } + + repoURL, err := url.Parse(string(secret.Data[SecretEndpointName]) + "/" + instance.Path + "/" + instance.RepoName) + + if err != nil { + return nil, "", err + } + + repoOptions := RepoOptions{ + Credentials: Credentials{ + Token: string(secret.Data[SecretTokenName]), + }, + DeployKeys: instance.DeployKeys, + Logger: reqLogger, + Path: instance.Path, + RepoName: instance.RepoName, + DisplayName: instance.DisplayName, + URL: repoURL, + TemplateFiles: instance.TemplateFiles, + DeletionPolicy: instance.DeletionPolicy, + } + + repo, err := NewRepo(repoOptions) + if err != nil { + return nil, "", err + } + + err = repo.Connect() + + return repo, hostKeysString, err + +} diff --git a/pkg/helpers/crd.go b/pkg/helpers/crd.go index 3698d1e3..d68cb66c 100644 --- a/pkg/helpers/crd.go +++ b/pkg/helpers/crd.go @@ -3,18 +3,31 @@ package helpers import ( "context" "fmt" + "os" + "strconv" corev1 "k8s.io/api/core/v1" "github.com/projectsyn/lieutenant-operator/pkg/apis" synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" + "github.com/projectsyn/lieutenant-operator/pkg/git/manager" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) +const ( + protectionSettingEnvVar = "LIEUTENANT_DELETE_PROTECTION" +) + +type DeletionState struct { + FinalizerRemoved bool + Deleted bool +} + // CreateOrUpdateGitRepo will create the gitRepo object if it doesn't already exist. If the owner object itself is a tenant tenantRef can be set nil. func CreateOrUpdateGitRepo(obj metav1.Object, gvk schema.GroupVersionKind, template *synv1alpha1.GitRepoTemplate, client client.Client, tenantRef corev1.LocalObjectReference) error { @@ -26,6 +39,10 @@ func CreateOrUpdateGitRepo(obj metav1.Object, gvk schema.GroupVersionKind, templ return fmt.Errorf("the tenant name is empty") } + if template.DeletionPolicy == "" { + template.DeletionPolicy = GetDeletionPolicy() + } + if template.RepoType == synv1alpha1.DefaultRepoType { template.RepoType = synv1alpha1.AutoRepoType } @@ -44,6 +61,8 @@ func CreateOrUpdateGitRepo(obj metav1.Object, gvk schema.GroupVersionKind, templ }, } + AddDeletionProtection(repo) + err := client.Create(context.TODO(), repo) if err != nil && errors.IsAlreadyExists(err) { existingRepo := &synv1alpha1.GitRepo{} @@ -62,6 +81,13 @@ func CreateOrUpdateGitRepo(obj metav1.Object, gvk schema.GroupVersionKind, templ err = client.Update(context.TODO(), existingRepo) } + + for file, content := range template.TemplateFiles { + if content == manager.DeletionMagicString { + delete(template.TemplateFiles, file) + } + } + return err } @@ -102,3 +128,71 @@ func (s SecretSortList) Less(i, j int) bool { return s.Items[i].CreationTimestamp.Before(&s.Items[j].CreationTimestamp) } + +// Checks if the slice of strings contains a specific string +func SliceContainsString(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +// HandleDeletion will handle the finalizers if the object was deleted. +// It will return true, if the finalizer was removed. If the object was +// removed the reconcile can be returned. +func HandleDeletion(instance metav1.Object, finalizerName string, client client.Client) DeletionState { + if instance.GetDeletionTimestamp().IsZero() { + return DeletionState{FinalizerRemoved: false, Deleted: false} + } + + annotationValue, exists := instance.GetAnnotations()[DeleteProtectionAnnotation] + + var protected bool + var err error + if exists { + protected, err = strconv.ParseBool(annotationValue) + // Assume true if it can't be parsed + if err != nil { + protected = true + // We need to reset the error again, so we don't trigger any unwanted side effects... + err = nil + } + } else { + protected = false + } + + if SliceContainsString(instance.GetFinalizers(), finalizerName) && !protected { + + controllerutil.RemoveFinalizer(instance, finalizerName) + + return DeletionState{Deleted: true, FinalizerRemoved: true} + } + + return DeletionState{Deleted: true, FinalizerRemoved: false} +} + +func AddDeletionProtection(instance metav1.Object) { + config := os.Getenv(protectionSettingEnvVar) + + protected, err := strconv.ParseBool(config) + if err != nil { + protected = true + } + + if protected { + annotations := instance.GetAnnotations() + + if annotations == nil { + annotations = make(map[string]string) + } + + if _, ok := annotations[DeleteProtectionAnnotation]; !ok { + annotations[DeleteProtectionAnnotation] = "true" + } + + instance.SetAnnotations(annotations) + } + +} diff --git a/pkg/helpers/crd_test.go b/pkg/helpers/crd_test.go index 09ac356c..fb78db95 100644 --- a/pkg/helpers/crd_test.go +++ b/pkg/helpers/crd_test.go @@ -2,22 +2,21 @@ package helpers import ( "context" + "os" "testing" + "time" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/kubernetes/scheme" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - + "github.com/projectsyn/lieutenant-operator/pkg/apis" synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" "github.com/stretchr/testify/assert" v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/projectsyn/lieutenant-operator/pkg/apis" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" ) func TestAddTenantLabel(t *testing.T) { @@ -128,3 +127,137 @@ func testSetupClient(objs []runtime.Object) client.Client { s.AddKnownTypes(synv1alpha1.SchemeGroupVersion, objs...) return fake.NewFakeClient(objs...) } + +func TestHandleDeletion(t *testing.T) { + type args struct { + instance metav1.Object + finalizerName string + } + tests := []struct { + name string + args args + want DeletionState + }{ + { + name: "Normal deletion", + want: DeletionState{Deleted: true, FinalizerRemoved: true}, + args: args{ + instance: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{ + "test", + }, + }, + }, + finalizerName: "test", + }, + }, + { + name: "Deletion protection set", + want: DeletionState{Deleted: true, FinalizerRemoved: false}, + args: args{ + instance: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + DeleteProtectionAnnotation: "true", + }, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{ + "test", + }, + }, + }, + finalizerName: "test", + }, + }, + { + name: "Nonsense annotation value", + want: DeletionState{Deleted: true, FinalizerRemoved: false}, + args: args{ + instance: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + DeleteProtectionAnnotation: "trugadse", + }, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{ + "test", + }, + }, + }, + finalizerName: "test", + }, + }, + { + name: "Object not deleted", + want: DeletionState{Deleted: false, FinalizerRemoved: false}, + args: args{ + instance: &synv1alpha1.Cluster{}, + finalizerName: "test", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + client := testSetupClient([]runtime.Object{&synv1alpha1.Cluster{}}) + + got := HandleDeletion(tt.args.instance, tt.args.finalizerName, client) + if got != tt.want { + t.Errorf("HandleDeletion() = %v, want %v", got, tt.want) + } + + }) + } +} + +func TestAddDeletionProtection(t *testing.T) { + type args struct { + instance metav1.Object + enable string + result string + } + tests := []struct { + name string + args args + }{ + { + name: "Add deletion protection", + args: args{ + instance: &synv1alpha1.Cluster{}, + enable: "true", + result: "true", + }, + }, + { + name: "Don't add deletion protection", + args: args{ + instance: &synv1alpha1.Cluster{}, + enable: "false", + result: "", + }, + }, + { + name: "Invalid setting", + args: args{ + instance: &synv1alpha1.Cluster{}, + enable: "gaga", + result: "true", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + os.Setenv(protectionSettingEnvVar, tt.args.enable) + + AddDeletionProtection(tt.args.instance) + + result := tt.args.instance.GetAnnotations()[DeleteProtectionAnnotation] + if result != tt.args.result { + t.Errorf("AddDeletionProtection() value = %v, wantValue %v", result, tt.args.result) + } + }) + } +} diff --git a/pkg/helpers/values.go b/pkg/helpers/values.go new file mode 100644 index 00000000..c1c64173 --- /dev/null +++ b/pkg/helpers/values.go @@ -0,0 +1,21 @@ +package helpers + +import ( + "os" + + synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" +) + +const ( + DeleteProtectionAnnotation = "syn.tools/protected-delete" +) + +func GetDeletionPolicy() synv1alpha1.DeletionPolicy { + policy := synv1alpha1.DeletionPolicy(os.Getenv("DEFAULT_DELETION_POLICY")) + switch policy { + case synv1alpha1.ArchivePolicy, synv1alpha1.DeletePolicy, synv1alpha1.RetainPolicy: + return policy + default: + return synv1alpha1.ArchivePolicy + } +} diff --git a/pkg/vault/client.go b/pkg/vault/client.go index 31f7524e..63734e8d 100644 --- a/pkg/vault/client.go +++ b/pkg/vault/client.go @@ -1,12 +1,16 @@ package vault import ( + "fmt" "os" "path" + "sort" + "strconv" "github.com/banzaicloud/bank-vaults/pkg/sdk/vault" "github.com/go-logr/logr" "github.com/hashicorp/vault/api" + synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" ) const ( @@ -19,30 +23,49 @@ var ( instanceClient VaultClient ) -type VaultClient interface { - SetToken(secretPath string, token string, log logr.Logger) error +// TODO: similar map like the template files +type VaultSecret struct { + Path string + Value string } -type BankVaultClient struct { - client *vault.Client - secretEngine string +type VaultClient interface { + AddSecrets(secrets []VaultSecret) error + // remove specific secret + RemoveSecrets(secret []VaultSecret) error } -// SetCustomClient is used if a custom client needs to be used. Currently only -// used for testing. -func SetCustomClient(c VaultClient) { - instanceClient = c +type BankVaultClient struct { + client *vault.Client + secretEngine string + deletionPolicy synv1alpha1.DeletionPolicy + log logr.Logger } // NewClient returns the default VaultClient implementation, ready to be used. // It automatically detects, if there was a Vault token provided or if it's // running withing kubernetes. -func NewClient() (VaultClient, error) { +func NewClient(deletionPolicy synv1alpha1.DeletionPolicy, log logr.Logger) (VaultClient, error) { if instanceClient != nil { return instanceClient, nil } + var err error + instanceClient, err = newBankVaultClient(deletionPolicy, log) + + return instanceClient, err + +} + +// SetCustomClient is used if a custom client needs to be used. Currently only +// used for testing. +func SetCustomClient(c VaultClient) { + instanceClient = c +} + +func newBankVaultClient(deletionPolicy synv1alpha1.DeletionPolicy, log logr.Logger) (*BankVaultClient, error) { + client, err := vault.NewClientFromConfig(&api.Config{ Address: os.Getenv(api.EnvVaultAddress), }, vault.ClientRole("lieutenant-operator")) @@ -55,18 +78,29 @@ func NewClient() (VaultClient, error) { client.RawClient().SetToken(os.Getenv(api.EnvVaultToken)) } - instanceClient = &BankVaultClient{ - client: client, - secretEngine: "kv", - } + return &BankVaultClient{ + client: client, + secretEngine: "kv", + deletionPolicy: deletionPolicy, + log: log, + }, nil - return instanceClient, nil } -// SetToken saves the token in Vault, the path should have the form +func (b *BankVaultClient) AddSecrets(secrets []VaultSecret) error { + for _, secret := range secrets { + err := b.addSecret(secret.Path, secret.Value) + if err != nil { + return err + } + } + return nil +} + +// addSecret saves the token in Vault, the path should have the form // tenant/cluster to work properly. It will check if the token exists and // re-apply it if not. -func (b *BankVaultClient) SetToken(secretPath, token string, log logr.Logger) error { +func (b *BankVaultClient) addSecret(secretPath, token string) error { queryPath := path.Join(b.secretEngine, "data", secretPath) @@ -76,7 +110,7 @@ func (b *BankVaultClient) SetToken(secretPath, token string, log logr.Logger) er } if secret == nil { - log.WithName("vault").Info("does not yet exist, creating", "name", secretPath) + b.log.WithName("vault").Info("does not yet exist, creating", "name", secretPath) secret = &api.Secret{} secret.Data = vault.NewData(0, map[string]interface{}{ tokenName: token, @@ -85,16 +119,138 @@ func (b *BankVaultClient) SetToken(secretPath, token string, log logr.Logger) er return err } - secretData := secret.Data["data"].(map[string]interface{}) + secretData, ok := secret.Data["data"].(map[string]interface{}) - if secretData[tokenName] != token { + if !ok { + secretData = make(map[string]interface{}) + } + + if !ok || secretData[tokenName] != token { - log.WithName("vault").Info("secrets don't match, re-applying") + b.log.WithName("vault").Info("secrets don't match, re-applying") secretData[tokenName] = token + secret.Data["data"] = secretData + _, err = b.client.RawClient().Logical().Write(queryPath, secret.Data) } return err } + +// RemoveSecrets will remove all the keys bellow the given paths. It will list +// all secrets of in the path and delete them according to the deletion policy. +func (b *BankVaultClient) RemoveSecrets(secrets []VaultSecret) error { + for _, secret := range secrets { + err := b.removeSecret(secret) + if err != nil { + return err + } + } + return nil +} + +// removeSecret will remove the token according to the DeletetionPolicy +func (b *BankVaultClient) removeSecret(removeSecret VaultSecret) error { + + secrets, err := b.listSecrets(removeSecret.Path) + if err != nil { + return err + } + + for _, secret := range secrets { + + sPath := path.Join(b.secretEngine, "metadata", removeSecret.Path, secret) + + s, err := b.client.RawClient().Logical().Read(sPath) + if err != nil { + return err + } + + versions, err := b.getVersionList(s.Data) + if err != nil { + return err + } + + switch b.deletionPolicy { + case synv1alpha1.ArchivePolicy: + b.log.Info("soft deleting secret", "secret", removeSecret.Path) + err := b.deleteToken(path.Join(b.secretEngine, "delete", removeSecret.Path, secret), versions) + if err != nil { + return err + } + case synv1alpha1.DeletePolicy: + b.log.Info("destroying secret", "secret", removeSecret.Path) + err := b.destroyToken(path.Join(b.secretEngine, "metadata", removeSecret.Path, secret), versions) + if err != nil { + return err + } + default: + return fmt.Errorf("unknown DeletionPolicy, skipping") + } + } + + return nil +} + +func (b *BankVaultClient) getVersionList(data map[string]interface{}) (map[string]interface{}, error) { + + versionlist := make([]int, 0) + + if versions, ok := data["versions"]; ok { + + version, ok := versions.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("can't parse versions from secret") + } + + for k := range version { + if v, err := strconv.Atoi(k); err == nil { + versionlist = append(versionlist, v) + } else { + return nil, err + } + + } + + } + + sort.Ints(versionlist) + + return map[string]interface{}{"versions": versionlist}, nil +} + +func (b *BankVaultClient) destroyToken(secretPath string, versions map[string]interface{}) error { + _, err := b.client.RawClient().Logical().Delete(secretPath) + return err +} + +func (b *BankVaultClient) deleteToken(secretPath string, versions map[string]interface{}) error { + _, err := b.client.RawClient().Logical().Write(secretPath, versions) + return err +} + +func (b *BankVaultClient) listSecrets(secretPath string) ([]string, error) { + + secrets, err := b.client.RawClient().Logical().List(path.Join(b.secretEngine, "metadata", secretPath)) + if err != nil { + return nil, err + } + data, ok := secrets.Data["keys"].([]interface{}) + if !ok { + return nil, fmt.Errorf("list of secrets can't be decoded") + } + + result := []string{} + for _, secret := range data { + s, ok := secret.(string) + if !ok { + return nil, fmt.Errorf("list of secrets can't be decoded") + } + result = append(result, s) + } + + return result, nil + +} diff --git a/pkg/vault/client_test.go b/pkg/vault/client_test.go index c5286dd0..4e931cb8 100644 --- a/pkg/vault/client_test.go +++ b/pkg/vault/client_test.go @@ -1,14 +1,18 @@ package vault import ( + "io" "net/http" "net/http/httptest" "os" + "reflect" "testing" "github.com/go-logr/logr" "github.com/hashicorp/vault/api" "github.com/operator-framework/operator-sdk/pkg/log/zap" + synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" + "github.com/stretchr/testify/assert" ) func testGetHTTPServer(statusCode int, body []byte) *httptest.Server { @@ -63,7 +67,7 @@ func TestNewClient(t *testing.T) { defer server.Close() - _, err := NewClient() + _, err := NewClient(synv1alpha1.RetainPolicy, zap.Logger()) if err != nil { t.Errorf("NewClient() error = %v, wantErr %v", err, tt.wantErr) return @@ -73,11 +77,11 @@ func TestNewClient(t *testing.T) { } } -func TestBankVaultClient_SetToken(t *testing.T) { +func TestBankVaultClient_AddSecrets(t *testing.T) { type args struct { - secretPath string - token string - log logr.Logger + secrets []VaultSecret + token string + log logr.Logger } tests := []struct { name string @@ -89,9 +93,9 @@ func TestBankVaultClient_SetToken(t *testing.T) { { name: "test SetToken", args: args{ - secretPath: "1234/6789", - token: "test", - log: zap.Logger(), + secrets: []VaultSecret{{Path: "1234/6789", Value: ""}}, + token: "test", + log: zap.Logger(), }, body: `{ "data": { @@ -114,9 +118,9 @@ func TestBankVaultClient_SetToken(t *testing.T) { statusCode: 404, body: "{}", args: args{ - secretPath: "1234/6789", - token: "test", - log: zap.Logger(), + secrets: []VaultSecret{{Path: "1234/6789", Value: ""}}, + token: "test", + log: zap.Logger(), }, }, } @@ -130,10 +134,216 @@ func TestBankVaultClient_SetToken(t *testing.T) { defer server.Close() - b, _ := NewClient() - if err := b.SetToken(tt.args.secretPath, tt.args.token, tt.args.log); (err != nil) != tt.wantErr { + b, _ := NewClient(synv1alpha1.ArchivePolicy, tt.args.log) + if err := b.AddSecrets(tt.args.secrets); (err != nil) != tt.wantErr { t.Errorf("BankVaultClient.SetToken() error = %v, wantErr %v", err, tt.wantErr) } }) } } + +func TestBankVaultClient_RemoveSecrets(t *testing.T) { + type args struct { + secrets []VaultSecret + policy synv1alpha1.DeletionPolicy + log logr.Logger + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "deleting", + wantErr: false, + args: args{ + secrets: []VaultSecret{{Path: "kv2/test", Value: ""}}, + policy: synv1alpha1.DeletePolicy, + log: zap.Logger(), + }, + }, + { + name: "archiving", + wantErr: false, + args: args{ + secrets: []VaultSecret{{Path: "kv2/test", Value: ""}}, + policy: synv1alpha1.ArchivePolicy, + log: zap.Logger(), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + instanceClient = nil + server := getVersionHTTPServer() + + os.Setenv(api.EnvVaultToken, "myroot") + os.Setenv(api.EnvVaultAddress, server.URL) + + defer server.Close() + + b, _ := NewClient(tt.args.policy, tt.args.log) + if err := b.RemoveSecrets(tt.args.secrets); (err != nil) != tt.wantErr { + t.Errorf("BankVaultClient.SetToken() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func getVersionHTTPServer() *httptest.Server { + mux := http.NewServeMux() + mux.HandleFunc("/v1/kv/delete/kv2/test/foo", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + mux.HandleFunc("/v1/kv/metadata/kv2/test/foo", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + versionBody := ` + { + "data": { + "created_time": "2018-03-22T02:24:06.945319214Z", + "current_version": 3, + "max_versions": 0, + "oldest_version": 0, + "updated_time": "2018-03-22T02:36:43.986212308Z", + "versions": { + "1": { + "created_time": "2018-03-22T02:24:06.945319214Z", + "deletion_time": "", + "destroyed": false + }, + "2": { + "created_time": "2018-03-22T02:36:33.954880664Z", + "deletion_time": "", + "destroyed": false + }, + "3": { + "created_time": "2018-03-22T02:36:43.986212308Z", + "deletion_time": "", + "destroyed": false + } + } + } + }` + _, _ = io.WriteString(w, versionBody) + }) + + mux.HandleFunc("/v1/kv/metadata/kv2/test", func(w http.ResponseWriter, r *http.Request) { + + w.WriteHeader(http.StatusOK) + + if r.URL.Query().Get("list") == "true" { + _, _ = io.WriteString(w, `{ + "data": { + "keys": ["foo", "foo/"] + } + }`) + return + } + + }) + + return httptest.NewServer(mux) +} + +func TestBankVaultClient_getVersionList(t *testing.T) { + type args struct { + data map[string]interface{} + } + tests := []struct { + name string + args args + want map[string]interface{} + wantErr bool + }{ + { + name: "test parsing", + args: args{ + data: map[string]interface{}{ + "versions": map[string]interface{}{ + "1": struct{}{}, + "2": struct{}{}, + }, + }, + }, + wantErr: false, + want: map[string]interface{}{ + "versions": []int{1, 2}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := &BankVaultClient{} + got, err := b.getVersionList(tt.args.data) + if (err != nil) != tt.wantErr { + t.Errorf("BankVaultClient.getVersionList() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("BankVaultClient.getVersionList() = %v, want %v", got, tt.want) + } + }) + } +} + +func getListHTTPServer() *httptest.Server { + mux := http.NewServeMux() + mux.HandleFunc("/v1/kv/metadata/test", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{ + "data": { + "keys": ["foo", "foo/"] + } + }`) + }) + + return httptest.NewServer(mux) +} + +func TestBankVaultClient_listSecrets(t *testing.T) { + type args struct { + secretPath string + policy synv1alpha1.DeletionPolicy + } + tests := []struct { + name string + args args + want []string + wantErr bool + }{ + { + name: "parse test", + wantErr: false, + want: []string{"foo", "foo/"}, + args: args{ + secretPath: "test", + policy: synv1alpha1.DeletePolicy, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + instanceClient = nil + server := getListHTTPServer() + + os.Setenv(api.EnvVaultToken, "myroot") + os.Setenv(api.EnvVaultAddress, server.URL) + + defer server.Close() + + b, err := newBankVaultClient(tt.args.policy, zap.Logger()) + assert.NoError(t, err) + + got, err := b.listSecrets(tt.args.secretPath) + if (err != nil) != tt.wantErr { + t.Errorf("BankVaultClient.listSecrets() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("BankVaultClient.listSecrets() = %v, want %v", got, tt.want) + } + }) + } +}