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 00000000..00eac9fb Binary files /dev/null and b/docs/modules/ROOT/assets/images/gitlab_settings.png differ 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) + } + }) + } +}