diff --git a/.gitignore b/.gitignore index 32dff3ce..3ec04c0e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ lieutenant-operator cmd/manager/__debug_bin tempgopath + +.cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff1226b..75c85a1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ 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] - 2020-10-22 +### Changed +- Redesigned reconcile handling ([#120]) +- GitRepo objects now fetch their information instead of them receiving from other controllers ([#120]) +### Fixed +- Lot's of race conditions and smaller bugs ([#120]) + ## [v0.4.0] - 2020-10-20 ### Added - Configurable revisions for git repositories ([#116]) @@ -113,3 +121,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#104]: https://github.com/projectsyn/lieutenant-operator/pull/104 [#110]: https://github.com/projectsyn/lieutenant-operator/pull/110 [#116]: https://github.com/projectsyn/lieutenant-operator/pull/116 +[#120]: https://github.com/projectsyn/lieutenant-operator/pull/120 diff --git a/examples/gitrepo-secret.yaml b/examples/gitrepo-secret.yaml index 47cef88e..f36780fb 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: vY3gHvPs82NvYK8dKAGw + endpoint: http://192.168.5.195:8080 + token: oqepRZZtm2QymfTXErHX kind: Secret metadata: name: example-secret diff --git a/go.sum b/go.sum index 21087114..41711da1 100644 --- a/go.sum +++ b/go.sum @@ -167,6 +167,7 @@ github.com/coreos/etcd v3.3.17+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -1275,6 +1276,7 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20190905181640-827449938966/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= helm.sh/helm/v3 v3.1.0/go.mod h1:WYsFJuMASa/4XUqLyv54s0U/f3mlAaRErGmyy4z921g= helm.sh/helm/v3 v3.1.2 h1:VpNzaNv2DX4aRnOCcV7v5Of+XT2SZrJ8iOQ25AGKOos= diff --git a/pkg/apis/syn/v1alpha1/cluster_types.go b/pkg/apis/syn/v1alpha1/cluster_types.go index 435b0c2f..aca9486a 100644 --- a/pkg/apis/syn/v1alpha1/cluster_types.go +++ b/pkg/apis/syn/v1alpha1/cluster_types.go @@ -81,3 +81,29 @@ type ClusterList struct { func init() { SchemeBuilder.Register(&Cluster{}, &ClusterList{}) } + +// GetGitTemplate returns the git repository template +func (c *Cluster) GetGitTemplate() *GitRepoTemplate { + return c.Spec.GitRepoTemplate +} + +// GetTenantRef returns the tenant of this CR +func (c *Cluster) GetTenantRef() corev1.LocalObjectReference { + return c.Spec.TenantRef +} + +// GetDeletionPolicy returns the object's deletion policy +func (c *Cluster) GetDeletionPolicy() DeletionPolicy { + return c.Spec.DeletionPolicy +} + +// GetDisplayName returns the display name of the object +func (c *Cluster) GetDisplayName() string { + return c.Spec.DisplayName +} + +// SetGitRepoURLAndHostKeys +func (c *Cluster) SetGitRepoURLAndHostKeys(URL, hostKeys string) { + c.Spec.GitRepoURL = URL + c.Spec.GitHostKeys = hostKeys +} diff --git a/pkg/apis/syn/v1alpha1/gitrepo_types.go b/pkg/apis/syn/v1alpha1/gitrepo_types.go index 66996782..58ec2f54 100644 --- a/pkg/apis/syn/v1alpha1/gitrepo_types.go +++ b/pkg/apis/syn/v1alpha1/gitrepo_types.go @@ -130,3 +130,28 @@ type GitRepoList struct { func init() { SchemeBuilder.Register(&GitRepo{}, &GitRepoList{}) } + +// GetGitTemplate returns the git repository template +func (g *GitRepo) GetGitTemplate() *GitRepoTemplate { + return &g.Spec.GitRepoTemplate +} + +// GetTenantRef returns the tenant of this CR +func (g *GitRepo) GetTenantRef() corev1.LocalObjectReference { + return g.Spec.TenantRef +} + +// GetDeletionPolicy returns the object's deletion policy +func (g *GitRepo) GetDeletionPolicy() DeletionPolicy { + return g.Spec.DeletionPolicy +} + +// GetDisplayName returns the display name of the object +func (g *GitRepo) GetDisplayName() string { + return g.Spec.DisplayName +} + +// SetGitRepoURLAndHostKeys is currenlty a noop for gitrepo +func (g *GitRepo) SetGitRepoURLAndHostKeys(URL, hostKeys string) { + //NOOP +} diff --git a/pkg/apis/syn/v1alpha1/tenant_types.go b/pkg/apis/syn/v1alpha1/tenant_types.go index 97ca5623..77bd1792 100644 --- a/pkg/apis/syn/v1alpha1/tenant_types.go +++ b/pkg/apis/syn/v1alpha1/tenant_types.go @@ -1,6 +1,7 @@ package v1alpha1 import ( + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -62,3 +63,31 @@ type TenantList struct { func init() { SchemeBuilder.Register(&Tenant{}, &TenantList{}) } + +// GetGitTemplate returns the git repository template +func (t *Tenant) GetGitTemplate() *GitRepoTemplate { + if t.Spec.GitRepoTemplate == nil { + t.Spec.GitRepoTemplate = &GitRepoTemplate{} + } + return t.Spec.GitRepoTemplate +} + +// GetTenantRef returns the tenant of this CR +func (t *Tenant) GetTenantRef() corev1.LocalObjectReference { + return corev1.LocalObjectReference{Name: t.GetName()} +} + +// GetDeletionPolicy returns the object's deletion policy +func (t *Tenant) GetDeletionPolicy() DeletionPolicy { + return t.Spec.DeletionPolicy +} + +// GetDisplayName returns the display name of the object +func (t *Tenant) GetDisplayName() string { + return t.Spec.DisplayName +} + +// SetGitRepoURLAndHostKeys will only set the URL for the tenant +func (t *Tenant) SetGitRepoURLAndHostKeys(URL, hostKeys string) { + t.Spec.GitRepoURL = URL +} diff --git a/pkg/collection/values.go b/pkg/collection/values.go new file mode 100644 index 00000000..737aa34a --- /dev/null +++ b/pkg/collection/values.go @@ -0,0 +1,24 @@ +package collection + +import ( + corev1 "k8s.io/api/core/v1" +) + +const ( + // DeleteProtectionAnnotation defines the delete protection annotation name + DeleteProtectionAnnotation = "syn.tools/protected-delete" +) + +type SecretSortList corev1.SecretList + +func (s SecretSortList) Len() int { return len(s.Items) } +func (s SecretSortList) Swap(i, j int) { s.Items[i], s.Items[j] = s.Items[j], s.Items[i] } + +func (s SecretSortList) Less(i, j int) bool { + + if s.Items[i].CreationTimestamp.Equal(&s.Items[j].CreationTimestamp) { + return s.Items[i].Name < s.Items[j].Name + } + + return s.Items[i].CreationTimestamp.Before(&s.Items[j].CreationTimestamp) +} diff --git a/pkg/controller/cluster/cluster_controller.go b/pkg/controller/cluster/cluster_controller.go index 78a08578..04b0cbbd 100644 --- a/pkg/controller/cluster/cluster_controller.go +++ b/pkg/controller/cluster/cluster_controller.go @@ -38,10 +38,9 @@ func add(mgr manager.Manager, r reconcile.Reconciler) error { if err != nil { return err } - return c.Watch(&source.Kind{Type: &synv1alpha1.GitRepo{}}, &handler.EnqueueRequestForOwner{ IsController: true, - OwnerType: &synv1alpha1.Cluster{}, + OwnerType: &synv1alpha1.Tenant{}, }) } diff --git a/pkg/controller/cluster/cluster_reconcile.go b/pkg/controller/cluster/cluster_reconcile.go index 3fd16a59..e5870073 100644 --- a/pkg/controller/cluster/cluster_reconcile.go +++ b/pkg/controller/cluster/cluster_reconcile.go @@ -2,28 +2,10 @@ package cluster import ( "context" - "crypto/rand" - "encoding/base64" - "fmt" - "os" - "path" - "sort" - "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" - "k8s.io/apimachinery/pkg/api/equality" + "github.com/projectsyn/lieutenant-operator/pkg/pipeline" "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "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" ) @@ -42,261 +24,22 @@ func (r *ReconcileCluster) Reconcile(request reconcile.Request) (reconcile.Resul reqLogger := log.WithValues("Request.Namespace", request.Namespace, "Request.Name", request.Name) reqLogger.Info("Reconciling Cluster") - err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + instance := &synv1alpha1.Cluster{} - instance := &synv1alpha1.Cluster{} - - err := r.client.Get(context.TODO(), request.NamespacedName, instance) - if err != nil { - if errors.IsNotFound(err) { - return nil - } - return err - } - instanceCopy := instance.DeepCopy() - - nsName := request.NamespacedName - nsName.Name = instance.Spec.TenantRef.Name - - tenant := &synv1alpha1.Tenant{} - - if err := r.client.Get(context.TODO(), nsName, tenant); err != nil { - return fmt.Errorf("Couldn't find tenant: %w", err) - } - - if err := applyClusterTemplate(instance, tenant); err != nil { - return err - } - - if err := r.createClusterRBAC(*instance); err != nil { - return err - } - - if instance.Status.BootstrapToken == nil { - reqLogger.Info("Adding status to Cluster object") - err := r.newStatus(instance) - if err != nil { - return err - } - } - - if time.Now().After(instance.Status.BootstrapToken.ValidUntil.Time) { - instance.Status.BootstrapToken.TokenValid = false - } - - if instance.Spec.GitRepoTemplate != nil { - if len(instance.Spec.GitRepoTemplate.DisplayName) == 0 { - instance.Spec.GitRepoTemplate.DisplayName = instance.Spec.DisplayName - } - - instance.Spec.GitRepoTemplate.DeletionPolicy = instance.Spec.DeletionPolicy - - result, err := helpers.CreateOrUpdateGitRepo(instance, r.scheme, instance.Spec.GitRepoTemplate, r.client, instance.Spec.TenantRef) - if err != nil { - reqLogger.Error(err, "Cannot create or update git repo object") - return err - } - - if result != controllerutil.OperationResultCreated { - instance.Spec.GitRepoURL, instance.Spec.GitHostKeys, err = helpers.GetGitRepoURLAndHostKeys(instance, r.client) - if err != nil { - return err - } - } - } - - 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() - } - - if strings.ToLower(os.Getenv("SKIP_VAULT_SETUP")) != "true" { - - vaultClient, err = vault.NewClient(deletionPolicy, reqLogger) - if err != nil { - return err - } - - 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 - } - } - // TODO: Move logic to tenant reconcile to avoid conflicts https://github.com/projectsyn/lieutenant-operator/issues/80 - err = r.removeClusterFileFromTenant(instance.GetName(), repoName, reqLogger) - if err != nil { - return err - } - } - if deleted.Deleted { - return r.client.Update(context.TODO(), instance) - } - - // TODO: Move logic to tenant reconcile to avoid conflicts https://github.com/projectsyn/lieutenant-operator/issues/80 - err = r.updateTenantGitRepo(repoName, instance.GetName()) - if err != nil { - return err - } - - helpers.AddTenantLabel(&instance.ObjectMeta, instance.Spec.TenantRef.Name) - helpers.AddDeletionProtection(instance) - controllerutil.AddFinalizer(instance, finalizerName) - - if !equality.Semantic.DeepEqual(instanceCopy.Status, instance.Status) { - if err := r.client.Status().Update(context.TODO(), instance); err != nil { - return err - } - } - if !equality.Semantic.DeepEqual(instanceCopy, instance) { - if err := r.client.Update(context.TODO(), instance); err != nil { - return err - } - } - return nil - }) - - return reconcile.Result{}, err -} - -func (r *ReconcileCluster) generateToken() (string, error) { - b := make([]byte, 16) - _, err := rand.Read(b) + err := r.client.Get(context.TODO(), request.NamespacedName, instance) if err != nil { - return "", err - } - return base64.URLEncoding.EncodeToString(b), err -} - -//newStatus will create a default lifetime of 30 minutes if it wasn't set in the object. -func (r *ReconcileCluster) newStatus(cluster *synv1alpha1.Cluster) error { - - parseTime := "30m" - if cluster.Spec.TokenLifeTime != "" { - parseTime = cluster.Spec.TokenLifeTime - } - - duration, err := time.ParseDuration(parseTime) - if err != nil { - return err - } - - validUntil := time.Now().Add(duration) - - token, err := r.generateToken() - if err != nil { - return err - } - - cluster.Status.BootstrapToken = &synv1alpha1.BootstrapToken{ - Token: token, - ValidUntil: metav1.NewTime(validUntil), - TokenValid: true, - } - 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, err := r.getTenantCR(tenant) - if err != nil { - return err - } - - if tenantCR.Spec.GitRepoTemplate == nil { - return nil - } - - if tenantCR.Spec.GitRepoTemplate.TemplateFiles == nil { - tenantCR.Spec.GitRepoTemplate.TemplateFiles = map[string]string{} - } - - clusterClassFile := clusterName + ".yml" - if _, ok := tenantCR.Spec.GitRepoTemplate.TemplateFiles[clusterClassFile]; !ok { - fileContent := fmt.Sprintf(clusterClassContent, tenant.Name, synTenant.CommonClassName) - tenantCR.Spec.GitRepoTemplate.TemplateFiles[clusterClassFile] = fileContent - return r.client.Update(context.TODO(), tenantCR) + if errors.IsNotFound(err) { + return reconcile.Result{}, nil } - return nil - }) -} - -func (r *ReconcileCluster) getServiceAccountToken(instance metav1.Object) (string, error) { - secrets := &corev1.SecretList{} - - err := r.client.List(context.TODO(), secrets) - if err != nil { - return "", err + return reconcile.Result{}, err } - sortSecrets := helpers.SecretSortList(*secrets) - - sort.Sort(sort.Reverse(sortSecrets)) - - for _, secret := range sortSecrets.Items { - - if secret.Type != corev1.SecretTypeServiceAccountToken { - continue - } - - if secret.Annotations[corev1.ServiceAccountNameKey] == instance.GetName() { - if string(secret.Data["token"]) == "" { - // We'll skip the secrets if the token is not yet populated. - continue - } - return string(secret.Data["token"]), nil - } + data := &pipeline.ExecutionContext{ + Client: r.client, + Log: reqLogger, + FinalizerName: finalizerName, } - 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 == nil || 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 reconcile.Result{}, pipeline.ReconcileCluster(instance, data) - return nil } diff --git a/pkg/controller/cluster/cluster_reconcile_test.go b/pkg/controller/cluster/cluster_reconcile_test.go index d007fa81..a106e907 100644 --- a/pkg/controller/cluster/cluster_reconcile_test.go +++ b/pkg/controller/cluster/cluster_reconcile_test.go @@ -2,7 +2,6 @@ package cluster import ( "context" - "fmt" "os" "path" "reflect" @@ -12,7 +11,7 @@ import ( "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" + "github.com/projectsyn/lieutenant-operator/pkg/pipeline" "github.com/projectsyn/lieutenant-operator/pkg/vault" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" @@ -30,7 +29,7 @@ import ( func testSetupClient(objs []runtime.Object) (client.Client, *runtime.Scheme) { s := scheme.Scheme s.AddKnownTypes(synv1alpha1.SchemeGroupVersion, objs...) - return fake.NewFakeClient(objs...), s + return fake.NewFakeClientWithScheme(s, objs...), s } func TestReconcileCluster_NoCluster(t *testing.T) { @@ -76,7 +75,7 @@ func TestReconcileCluster_NoTenant(t *testing.T) { _, err := r.Reconcile(req) assert.Error(t, err) - assert.Contains(t, err.Error(), "find tenant") + assert.Contains(t, err.Error(), "no matching secrets found") } func TestReconcileCluster_NoGitRepoTemplate(t *testing.T) { @@ -99,6 +98,7 @@ func TestReconcileCluster_NoGitRepoTemplate(t *testing.T) { objs := []runtime.Object{ tenant, cluster, + &synv1alpha1.GitRepo{}, } cl, s := testSetupClient(objs) @@ -242,6 +242,10 @@ func TestReconcileCluster_Reconcile(t *testing.T) { t.Errorf("Reconcile() got = %v, want %v", got, tt.want) } + // BootstrapToken is now only populated after the second reconcile. + _, err = r.Reconcile(req) + assert.NoError(t, err) + gitRepoNamespacedName := types.NamespacedName{ Name: tt.fields.objName, Namespace: tt.fields.objNamespace, @@ -258,6 +262,7 @@ func TestReconcileCluster_Reconcile(t *testing.T) { assert.Equal(t, tt.fields.tenantName, newCluster.Labels[apis.LabelNameTenant]) + assert.NotNil(t, newCluster.Status.BootstrapToken) assert.NotEmpty(t, newCluster.Status.BootstrapToken.Token) sa := &corev1.ServiceAccount{} @@ -267,7 +272,7 @@ func TestReconcileCluster_Reconcile(t *testing.T) { if tt.skipVault { assert.Empty(t, testMockClient.secrets) } else { - saToken, err := r.getServiceAccountToken(newCluster) + saToken, err := pipeline.GetServiceAccountToken(newCluster, &pipeline.ExecutionContext{Client: cl}) saSecrets := []vault.VaultSecret{{Value: saToken, Path: path.Join(tt.fields.tenantName, tt.fields.objName, "steward")}} assert.NoError(t, err) assert.Equal(t, testMockClient.secrets, saSecrets) @@ -283,12 +288,6 @@ func TestReconcileCluster_Reconcile(t *testing.T) { assert.Equal(t, roleBinding.RoleRef.Name, role.Name) assert.Equal(t, roleBinding.Subjects[0].Name, sa.Name) - testTenant := &synv1alpha1.Tenant{} - err = cl.Get(context.TODO(), types.NamespacedName{Name: tt.fields.tenantName, Namespace: tt.fields.objNamespace}, testTenant) - assert.NoError(t, err) - fileContent, found := testTenant.Spec.GitRepoTemplate.TemplateFiles[tt.fields.objName+".yml"] - assert.True(t, found) - assert.Equal(t, fileContent, fmt.Sprintf(clusterClassContent, tt.fields.tenantName, tenant.CommonClassName)) }) } } @@ -306,6 +305,8 @@ func (m *TestMockClient) RemoveSecrets(secrets []vault.VaultSecret) error { return nil } +func (m *TestMockClient) SetDeletionPolicy(deletionPolicy synv1alpha1.DeletionPolicy) {} + func TestReconcileCluster_getServiceAccountToken(t *testing.T) { type args struct { instance metav1.Object @@ -435,13 +436,9 @@ func TestReconcileCluster_getServiceAccountToken(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cl, s := testSetupClient(tt.args.objs) + cl, _ := testSetupClient(tt.args.objs) - r := &ReconcileCluster{ - client: cl, - scheme: s, - } - got, err := r.getServiceAccountToken(tt.args.instance) + got, err := pipeline.GetServiceAccountToken(tt.args.instance, &pipeline.ExecutionContext{Client: cl}) if (err != nil) != tt.wantErr { t.Errorf("ReconcileCluster.getServiceAccountToken() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/pkg/controller/gitrepo/gitrepo_controller.go b/pkg/controller/gitrepo/gitrepo_controller.go index c4f21ca0..d504ae74 100644 --- a/pkg/controller/gitrepo/gitrepo_controller.go +++ b/pkg/controller/gitrepo/gitrepo_controller.go @@ -3,6 +3,7 @@ package gitrepo import ( synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -39,7 +40,31 @@ func add(mgr manager.Manager, r reconcile.Reconciler) error { return err } - return nil + err = c.Watch(&source.Kind{Type: &synv1alpha1.Tenant{}}, &handler.EnqueueRequestsFromMapFunc{ + ToRequests: handler.ToRequestsFunc(func(a handler.MapObject) []reconcile.Request { + return []reconcile.Request{ + {NamespacedName: types.NamespacedName{ + Name: a.Meta.GetName(), + Namespace: a.Meta.GetNamespace(), + }}, + } + }), + }) + if err != nil { + return err + } + + return c.Watch(&source.Kind{Type: &synv1alpha1.Cluster{}}, &handler.EnqueueRequestsFromMapFunc{ + ToRequests: handler.ToRequestsFunc(func(a handler.MapObject) []reconcile.Request { + return []reconcile.Request{ + {NamespacedName: types.NamespacedName{ + Name: a.Meta.GetName(), + Namespace: a.Meta.GetNamespace(), + }}, + } + }), + }) + } // blank assignment to verify that ReconcileGitRepo implements reconcile.Reconciler diff --git a/pkg/controller/gitrepo/gitrepo_reconcile.go b/pkg/controller/gitrepo/gitrepo_reconcile.go index 2dd94810..ba931cb9 100644 --- a/pkg/controller/gitrepo/gitrepo_reconcile.go +++ b/pkg/controller/gitrepo/gitrepo_reconcile.go @@ -2,16 +2,11 @@ package gitrepo import ( "context" - "fmt" - "github.com/projectsyn/lieutenant-operator/pkg/git/manager" - "github.com/projectsyn/lieutenant-operator/pkg/helpers" + "github.com/projectsyn/lieutenant-operator/pkg/pipeline" synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" - "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/client-go/util/retry" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/reconcile" ) @@ -28,108 +23,23 @@ func (r *ReconcileGitRepo) Reconcile(request reconcile.Request) (reconcile.Resul reqLogger := log.WithValues("Request.Namespace", request.Namespace, "Request.Name", request.Name) reqLogger.Info("Reconciling GitRepo") - err := retry.RetryOnConflict(retry.DefaultRetry, func() error { - // Fetch the GitRepo instance - instance := &synv1alpha1.GitRepo{} + // Fetch the GitRepo instance + instance := &synv1alpha1.GitRepo{} - err := r.client.Get(context.TODO(), request.NamespacedName, instance) - if err != nil { - if errors.IsNotFound(err) { - return nil - } - return err + err := r.client.Get(context.TODO(), request.NamespacedName, instance) + if err != nil { + if errors.IsNotFound(err) { + return reconcile.Result{}, nil } - instanceCopy := instance.DeepCopy() - - if instance.Spec.RepoType == synv1alpha1.UnmanagedRepoType { - reqLogger.Info("Skipping GitRepo because it is unmanaged") - return nil - } - - repo, hostKeys, err := manager.GetGitClient(&instance.Spec.GitRepoTemplate, instance.GetNamespace(), reqLogger, r.client) - if err != nil { - return err - } - - instance.Status.HostKeys = hostKeys - - if !r.repoExists(repo) { - reqLogger.Info("creating git repo", manager.SecretEndpointName, repo.FullURL()) - err := repo.Create() - if err != nil { - return r.handleRepoError(err, instance, repo) - } - 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) - } - - changed, err := repo.Update() - if err != nil { - return err - } - - if changed { - reqLogger.Info("keys differed from CRD, keys re-applied to repository") - } - - helpers.AddTenantLabel(&instance.ObjectMeta, instance.Spec.TenantRef.Name) - helpers.AddDeletionProtection(instance) - - controllerutil.AddFinalizer(instance, finalizerName) - - if !equality.Semantic.DeepEqual(instanceCopy, instance) { - err = r.client.Update(context.TODO(), instance) - } - if err != nil { - return err - } - phase := synv1alpha1.Created - instance.Status.Phase = &phase - instance.Status.URL = repo.FullURL().String() - instance.Status.Type = synv1alpha1.GitType(repo.Type()) - if !equality.Semantic.DeepEqual(instanceCopy.Status, instance.Status) { - if err := r.client.Status().Update(context.TODO(), instance); err != nil { - return err - } - } - return nil - }) - - return reconcile.Result{}, err -} + return reconcile.Result{}, err + } -func (r *ReconcileGitRepo) repoExists(repo manager.Repo) bool { - if err := repo.Read(); err == nil { - return true + data := &pipeline.ExecutionContext{ + Client: r.client, + Log: reqLogger, + FinalizerName: finalizerName, } - return false -} + return reconcile.Result{}, pipeline.ReconcileGitRep(instance, data) -func (r *ReconcileGitRepo) handleRepoError(repoErr error, instance *synv1alpha1.GitRepo, repo manager.Repo) error { - instanceCopy := instance.DeepCopy() - phase := synv1alpha1.Failed - instance.Status.Phase = &phase - instance.Status.URL = repo.FullURL().String() - if !equality.Semantic.DeepEqual(instanceCopy.Status, instance.Status) { - if err := r.client.Status().Update(context.TODO(), instance); err != nil { - return fmt.Errorf("could not set status while handling error: %w: %s", err, repoErr) - } - } - return repoErr } diff --git a/pkg/controller/gitrepo/gitrepo_reconcile_test.go b/pkg/controller/gitrepo/gitrepo_reconcile_test.go index 293dedfd..bb814ada 100644 --- a/pkg/controller/gitrepo/gitrepo_reconcile_test.go +++ b/pkg/controller/gitrepo/gitrepo_reconcile_test.go @@ -29,7 +29,7 @@ var savedGitRepoOpt manager.RepoOptions func testSetupClient(objs []runtime.Object) (client.Client, *runtime.Scheme) { s := scheme.Scheme s.AddKnownTypes(synv1alpha1.SchemeGroupVersion, objs...) - return fake.NewFakeClient(objs...), s + return fake.NewFakeClientWithScheme(s, objs...), s } func TestReconcileGitRepo_Reconcile(t *testing.T) { @@ -110,6 +110,8 @@ func TestReconcileGitRepo_Reconcile(t *testing.T) { objs := []runtime.Object{ repo, + &synv1alpha1.Tenant{}, + &synv1alpha1.Cluster{}, } cl, s := testSetupClient(objs) diff --git a/pkg/controller/tenant/tenant_controller.go b/pkg/controller/tenant/tenant_controller.go index 9facefbb..116e89b5 100644 --- a/pkg/controller/tenant/tenant_controller.go +++ b/pkg/controller/tenant/tenant_controller.go @@ -39,7 +39,15 @@ func add(mgr manager.Manager, r reconcile.Reconciler) error { return err } - return c.Watch(&source.Kind{Type: &synv1alpha1.GitRepo{}}, &handler.EnqueueRequestForOwner{ + err = c.Watch(&source.Kind{Type: &synv1alpha1.GitRepo{}}, &handler.EnqueueRequestForOwner{ + IsController: true, + OwnerType: &synv1alpha1.Tenant{}, + }) + if err != nil { + return err + } + + return c.Watch(&source.Kind{Type: &synv1alpha1.Cluster{}}, &handler.EnqueueRequestForOwner{ IsController: true, OwnerType: &synv1alpha1.Tenant{}, }) diff --git a/pkg/controller/tenant/tenant_reconcile.go b/pkg/controller/tenant/tenant_reconcile.go index 9c439995..6ee605f6 100644 --- a/pkg/controller/tenant/tenant_reconcile.go +++ b/pkg/controller/tenant/tenant_reconcile.go @@ -2,22 +2,12 @@ package tenant import ( "context" - "os" - corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/reconcile" synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" - "github.com/projectsyn/lieutenant-operator/pkg/helpers" - "k8s.io/apimachinery/pkg/api/equality" + "github.com/projectsyn/lieutenant-operator/pkg/pipeline" "k8s.io/apimachinery/pkg/api/errors" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/reconcile" -) - -const ( - // CommonClassName is the name of the tenant's common class - CommonClassName = "common" ) // Reconcile The Controller will requeue the Request to be processed again if the returned error is non-nil or @@ -26,63 +16,21 @@ func (r *ReconcileTenant) Reconcile(request reconcile.Request) (reconcile.Result reqLogger := log.WithValues("Request.Namespace", request.Namespace, "Request.Name", request.Name) reqLogger.Info("Reconciling Tenant") - err := retry.RetryOnConflict(retry.DefaultBackoff, 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 - } - instanceCopy := instance.DeepCopy() - - if instance.Spec.GitRepoTemplate != nil { - 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] = "" - } - - instance.Spec.GitRepoTemplate.DeletionPolicy = instance.Spec.DeletionPolicy - - result, err := helpers.CreateOrUpdateGitRepo(instance, r.scheme, instance.Spec.GitRepoTemplate, r.client, corev1.LocalObjectReference{Name: instance.GetName()}) - if err != nil { - return err - } - - if result != controllerutil.OperationResultCreated { - instance.Spec.GitRepoURL, _, err = helpers.GetGitRepoURLAndHostKeys(instance, r.client) - if err != nil { - return err - } - } + // Fetch the Tenant instance + instance := &synv1alpha1.Tenant{} + err := r.client.Get(context.TODO(), request.NamespacedName, instance) + if err != nil { + if errors.IsNotFound(err) { + return reconcile.Result{}, nil } + return reconcile.Result{}, err + } - // Set URL of global git repo from default - defaultGlobalGitRepoURL := os.Getenv("DEFAULT_GLOBAL_GIT_REPO_URL") - if len(instance.Spec.GlobalGitRepoURL) == 0 && len(defaultGlobalGitRepoURL) > 0 { - instance.Spec.GlobalGitRepoURL = defaultGlobalGitRepoURL - } + data := &pipeline.ExecutionContext{ + Client: r.client, + Log: reqLogger, + FinalizerName: "", + } - helpers.AddDeletionProtection(instance) - if !equality.Semantic.DeepEqual(instanceCopy, instance) { - if err := r.client.Update(context.TODO(), instance); err != nil { - return err - } - } - return nil - }) - return reconcile.Result{}, err + return reconcile.Result{}, pipeline.ReconcileTenant(instance, data) } diff --git a/pkg/controller/tenant/tenant_reconcile_test.go b/pkg/controller/tenant/tenant_reconcile_test.go index 37effdd9..42f65668 100644 --- a/pkg/controller/tenant/tenant_reconcile_test.go +++ b/pkg/controller/tenant/tenant_reconcile_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" + "github.com/projectsyn/lieutenant-operator/pkg/pipeline" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -20,7 +21,7 @@ import ( func testSetupClient(objs []runtime.Object) (client.Client, *runtime.Scheme) { s := scheme.Scheme s.AddKnownTypes(synv1alpha1.SchemeGroupVersion, objs...) - return fake.NewFakeClient(objs...), s + return fake.NewFakeClientWithScheme(s, objs...), s } func TestHandleNilGitRepoTemplate(t *testing.T) { @@ -33,7 +34,11 @@ func TestHandleNilGitRepoTemplate(t *testing.T) { }, } - cl, s := testSetupClient([]runtime.Object{tenant}) + cl, s := testSetupClient([]runtime.Object{ + tenant, + &synv1alpha1.ClusterList{}, + &synv1alpha1.GitRepo{}, + }) r := &ReconcileTenant{client: cl, scheme: s} @@ -48,8 +53,7 @@ func TestHandleNilGitRepoTemplate(t *testing.T) { updatedTenant := &synv1alpha1.Tenant{} err = cl.Get(context.TODO(), types.NamespacedName{Name: tenant.Name}, updatedTenant) assert.NoError(t, err) - assert.Nil(t, tenant.Spec.GitRepoTemplate) - assert.Nil(t, updatedTenant.Spec.GitRepoTemplate) + assert.Contains(t, updatedTenant.Spec.GitRepoTemplate.TemplateFiles, "common.yml") } func TestCreateGitRepo(t *testing.T) { @@ -118,7 +122,7 @@ func TestCreateGitRepo(t *testing.T) { err = cl.Get(context.TODO(), gitRepoNamespacedName, gitRepo) assert.NoError(t, err) assert.Equal(t, tenant.Spec.DisplayName, gitRepo.Spec.GitRepoTemplate.DisplayName) - fileContent, found := gitRepo.Spec.GitRepoTemplate.TemplateFiles[CommonClassName+".yml"] + fileContent, found := gitRepo.Spec.GitRepoTemplate.TemplateFiles[pipeline.CommonClassName+".yml"] assert.True(t, found) assert.Equal(t, "", fileContent) }) diff --git a/pkg/helpers/crd.go b/pkg/helpers/crd.go deleted file mode 100644 index 965161e9..00000000 --- a/pkg/helpers/crd.go +++ /dev/null @@ -1,179 +0,0 @@ -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" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "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" -) - -// DeletionState of the instance -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, scheme *runtime.Scheme, template *synv1alpha1.GitRepoTemplate, client client.Client, tenantRef corev1.LocalObjectReference) (controllerutil.OperationResult, error) { - - if template == nil { - return controllerutil.OperationResultNone, fmt.Errorf("gitRepo template is empty") - } - - if tenantRef.Name == "" { - return controllerutil.OperationResultNone, fmt.Errorf("the tenant name is empty") - } - - if template.DeletionPolicy == "" { - template.DeletionPolicy = GetDeletionPolicy() - } - - if template.RepoType == synv1alpha1.DefaultRepoType { - template.RepoType = synv1alpha1.AutoRepoType - } - - repo := &synv1alpha1.GitRepo{ - ObjectMeta: metav1.ObjectMeta{ - Name: obj.GetName(), - Namespace: obj.GetNamespace(), - }, - } - - result, err := controllerutil.CreateOrUpdate(context.TODO(), client, repo, func() error { - repo.Spec.GitRepoTemplate = *template - repo.Spec.TenantRef = tenantRef - AddDeletionProtection(repo) - return controllerutil.SetControllerReference(obj, repo, scheme) - }) - - for file, content := range template.TemplateFiles { - if content == manager.DeletionMagicString { - delete(template.TemplateFiles, file) - } - } - - return result, err -} - -// AddTenantLabel adds the tenant label to an object. -func AddTenantLabel(meta *metav1.ObjectMeta, tenant string) { - if meta.Labels == nil { - meta.Labels = make(map[string]string) - } - if meta.Labels[apis.LabelNameTenant] != tenant { - meta.Labels[apis.LabelNameTenant] = tenant - } -} - -// GetGitRepoURLAndHostKeys for an instance -func GetGitRepoURLAndHostKeys(obj metav1.Object, client client.Client) (string, string, error) { - gitRepo := &synv1alpha1.GitRepo{} - repoNamespacedName := types.NamespacedName{ - Namespace: obj.GetNamespace(), - Name: obj.GetName(), - } - err := client.Get(context.TODO(), repoNamespacedName, gitRepo) - if err != nil { - return "", "", err - } - - return gitRepo.Status.URL, gitRepo.Status.HostKeys, nil -} - -// SecretSortList to sort secrets -type SecretSortList corev1.SecretList - -func (s SecretSortList) Len() int { return len(s.Items) } -func (s SecretSortList) Swap(i, j int) { s.Items[i], s.Items[j] = s.Items[j], s.Items[i] } - -func (s SecretSortList) Less(i, j int) bool { - - if s.Items[i].CreationTimestamp.Equal(&s.Items[j].CreationTimestamp) { - return s.Items[i].Name < s.Items[j].Name - } - - return s.Items[i].CreationTimestamp.Before(&s.Items[j].CreationTimestamp) -} - -// SliceContainsString 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} -} - -// AddDeletionProtection annotations to the instance -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 deleted file mode 100644 index 48d4dcc3..00000000 --- a/pkg/helpers/crd_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package helpers - -import ( - "context" - "os" - "testing" - "time" - - "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" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "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" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" -) - -func TestAddTenantLabel(t *testing.T) { - type args struct { - meta *metav1.ObjectMeta - tenant string - } - tests := []struct { - name string - args args - }{ - { - name: "add labels", - args: args{ - meta: &metav1.ObjectMeta{}, - tenant: "test", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - AddTenantLabel(tt.args.meta, tt.args.tenant) - - if tt.args.meta.Labels[apis.LabelNameTenant] != tt.args.tenant { - t.Error("labels do not match") - } - - }) - } -} - -func TestCreateOrUpdateGitRepo(t *testing.T) { - type args struct { - obj metav1.Object - template *synv1alpha1.GitRepoTemplate - tenantRef v1.LocalObjectReference - } - tests := []struct { - name string - args args - wantErr bool - }{ - { - name: "create and update git repo", - args: args{ - obj: &synv1alpha1.GitRepo{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "test", - }, - }, - template: &synv1alpha1.GitRepoTemplate{ - APISecretRef: v1.SecretReference{Name: "testSecret"}, - DeployKeys: nil, - Path: "testPath", - RepoName: "testRepo", - }, - tenantRef: v1.LocalObjectReference{ - Name: "testTenant", - }, - }, - }, - } - for _, tt := range tests { - - objs := []runtime.Object{ - &synv1alpha1.GitRepo{}, - } - - cl := testSetupClient(objs) - - t.Run(tt.name, func(t *testing.T) { - if res, err := CreateOrUpdateGitRepo(tt.args.obj, scheme.Scheme, tt.args.template, cl, tt.args.tenantRef); (err != nil) != tt.wantErr { - t.Errorf("CreateOrUpdateGitRepo() error = %v, wantErr %v", err, tt.wantErr) - } else { - assert.Equal(t, controllerutil.OperationResultCreated, res) - } - }) - - namespacedName := types.NamespacedName{ - Name: tt.args.obj.GetName(), - Namespace: tt.args.obj.GetNamespace(), - } - - checkRepo := &synv1alpha1.GitRepo{} - assert.NoError(t, cl.Get(context.TODO(), namespacedName, checkRepo)) - assert.Equal(t, tt.args.template, &checkRepo.Spec.GitRepoTemplate) - tt.args.template.RepoName = "changedName" - res, err := CreateOrUpdateGitRepo(tt.args.obj, scheme.Scheme, tt.args.template, cl, tt.args.tenantRef) - assert.NoError(t, err) - assert.Equal(t, controllerutil.OperationResultUpdated, res) - assert.NoError(t, cl.Get(context.TODO(), namespacedName, checkRepo)) - assert.Equal(t, tt.args.template, &checkRepo.Spec.GitRepoTemplate) - - checkRepo.Spec.RepoType = synv1alpha1.AutoRepoType - assert.NoError(t, cl.Update(context.TODO(), checkRepo)) - res, err = CreateOrUpdateGitRepo(tt.args.obj, scheme.Scheme, tt.args.template, cl, tt.args.tenantRef) - assert.NoError(t, err) - assert.Equal(t, controllerutil.OperationResultNone, res) - finalRepo := &synv1alpha1.GitRepo{} - assert.NoError(t, cl.Get(context.TODO(), namespacedName, finalRepo)) - assert.Equal(t, checkRepo.Spec.GitRepoTemplate, finalRepo.Spec.GitRepoTemplate) - } -} - -// testSetupClient returns a client containing all objects in objs -func testSetupClient(objs []runtime.Object) client.Client { - s := scheme.Scheme - 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 deleted file mode 100644 index a683fb01..00000000 --- a/pkg/helpers/values.go +++ /dev/null @@ -1,39 +0,0 @@ -package helpers - -import ( - "bytes" - "fmt" - "os" - "text/template" - - synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" -) - -const ( - // DeleteProtectionAnnotation defines the delete protection annotation name - DeleteProtectionAnnotation = "syn.tools/protected-delete" -) - -// GetDeletionPolicy gets the configured default deletion policy -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 - } -} - -// RenderTemplate renders a given template with the given data -func RenderTemplate(tmpl string, data interface{}) (string, error) { - tmp, err := template.New("template").Parse(tmpl) - if err != nil { - return "", fmt.Errorf("Could not parse template: %w", err) - } - buf := new(bytes.Buffer) - if err := tmp.Execute(buf, data); err != nil { - return "", fmt.Errorf("Could not render template: %w", err) - } - return buf.String(), nil -} diff --git a/pkg/helpers/values_test.go b/pkg/helpers/values_test.go deleted file mode 100644 index 8a8046c3..00000000 --- a/pkg/helpers/values_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package helpers - -import ( - "os" - "testing" - - synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" - "github.com/stretchr/testify/assert" -) - -func TestGetDeletionPolicyDefault(t *testing.T) { - policy := GetDeletionPolicy() - assert.Equal(t, synv1alpha1.ArchivePolicy, policy) -} - -func TestGetDeletionPolicyNonDefault(t *testing.T) { - os.Setenv("DEFAULT_DELETION_POLICY", "Retain") - policy := GetDeletionPolicy() - assert.Equal(t, synv1alpha1.RetainPolicy, policy) -} - -func TestRenderTemplateRawString(t *testing.T) { - str, err := RenderTemplate("raw string", nil) - assert.NoError(t, err) - assert.Equal(t, "raw string", str) -} - -func TestRenderTemplateData(t *testing.T) { - str, err := RenderTemplate("{{ .Some }}/{{ .Data }}", struct { - Some string - Data string - }{"some", "data"}) - assert.NoError(t, err) - assert.Equal(t, "some/data", str) -} - -func TestRenderTemplateSyntaxError(t *testing.T) { - _, err := RenderTemplate("{{ }", nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), "parse") -} - -func TestRenderTemplateDataError(t *testing.T) { - _, err := RenderTemplate("{{ .none }}", "data") - assert.Error(t, err) - assert.Contains(t, err.Error(), "render") -} diff --git a/pkg/pipeline/cluster.go b/pkg/pipeline/cluster.go new file mode 100644 index 00000000..c274ee8a --- /dev/null +++ b/pkg/pipeline/cluster.go @@ -0,0 +1,23 @@ +package pipeline + +const ( + clusterClassContent = `classes: +- %s.%s +` +) + +func clusterSpecificSteps(obj PipelineObject, data *ExecutionContext) ExecutionResult { + steps := []Step{ + {Name: "create cluster RBAC", F: createClusterRBAC}, + {Name: "deletion check", F: checkIfDeleted}, + {Name: "set bootstrap token", F: setBootstrapToken}, + {Name: "create or update vault", F: createOrUpdateVault}, + {Name: "delete vault entries", F: handleVaultDeletion}, + {Name: "set tenant owner", F: setTenantOwner}, + {Name: "apply tenant template", F: applyTenantTemplate}, + } + + err := RunPipeline(obj, data, steps) + + return ExecutionResult{Err: err} +} diff --git a/pkg/controller/cluster/cluster_template.go b/pkg/pipeline/cluster/cluster_template.go similarity index 64% rename from pkg/controller/cluster/cluster_template.go rename to pkg/pipeline/cluster/cluster_template.go index 545d1375..01dba343 100644 --- a/pkg/controller/cluster/cluster_template.go +++ b/pkg/pipeline/cluster/cluster_template.go @@ -1,11 +1,12 @@ package cluster import ( + "bytes" "fmt" + "text/template" "github.com/imdario/mergo" synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" - "github.com/projectsyn/lieutenant-operator/pkg/helpers" "github.com/ryankurte/go-structparse" ) @@ -18,7 +19,7 @@ func (r *templateParser) ParseString(in string) interface{} { if r.err != nil || len(in) == 0 { return in } - str, err := helpers.RenderTemplate(in, r.cluster) + str, err := RenderTemplate(in, r.cluster) if err != nil { r.err = err return in @@ -26,7 +27,7 @@ func (r *templateParser) ParseString(in string) interface{} { return str } -func applyClusterTemplate(cluster *synv1alpha1.Cluster, tenant *synv1alpha1.Tenant) error { +func ApplyClusterTemplate(cluster *synv1alpha1.Cluster, tenant *synv1alpha1.Tenant) error { if tenant.Spec.ClusterTemplate == nil { return nil } @@ -50,3 +51,16 @@ func applyClusterTemplate(cluster *synv1alpha1.Cluster, tenant *synv1alpha1.Tena return nil } + +// RenderTemplate renders a given template with the given data +func RenderTemplate(tmpl string, data interface{}) (string, error) { + tmp, err := template.New("template").Parse(tmpl) + if err != nil { + return "", fmt.Errorf("Could not parse template: %w", err) + } + buf := new(bytes.Buffer) + if err := tmp.Execute(buf, data); err != nil { + return "", fmt.Errorf("Could not render template: %w", err) + } + return buf.String(), nil +} diff --git a/pkg/controller/cluster/cluster_template_test.go b/pkg/pipeline/cluster/cluster_template_test.go similarity index 75% rename from pkg/controller/cluster/cluster_template_test.go rename to pkg/pipeline/cluster/cluster_template_test.go index d598e75e..c81f50b9 100644 --- a/pkg/controller/cluster/cluster_template_test.go +++ b/pkg/pipeline/cluster/cluster_template_test.go @@ -29,7 +29,7 @@ func TestApplyClusterTemplateRaw(t *testing.T) { }, } - err := applyClusterTemplate(cluster, tenant) + err := ApplyClusterTemplate(cluster, tenant) assert.NoError(t, err) assert.Equal(t, "test", cluster.Spec.DisplayName) assert.Equal(t, "repo", cluster.Spec.GitRepoTemplate.RepoName) @@ -67,7 +67,7 @@ func TestApplyClusterTemplate(t *testing.T) { }, } - err := applyClusterTemplate(cluster, tenant) + err := ApplyClusterTemplate(cluster, tenant) assert.NoError(t, err) assert.Equal(t, "test", cluster.Spec.DisplayName) assert.Equal(t, "c-some-test", cluster.Spec.GitRepoTemplate.RepoName) @@ -90,13 +90,40 @@ func TestApplyClusterTemplateFail(t *testing.T) { } cluster := &synv1alpha1.Cluster{} - err := applyClusterTemplate(cluster, tenant) + err := ApplyClusterTemplate(cluster, tenant) assert.Error(t, err) assert.Contains(t, err.Error(), "parse") // Non existent data in template tenant.Spec.ClusterTemplate.GitRepoTemplate.RepoName = "{{ .nonexistent }}" - err = applyClusterTemplate(cluster, tenant) + err = ApplyClusterTemplate(cluster, tenant) + assert.Error(t, err) + assert.Contains(t, err.Error(), "render") +} + +func TestRenderTemplateRawString(t *testing.T) { + str, err := RenderTemplate("raw string", nil) + assert.NoError(t, err) + assert.Equal(t, "raw string", str) +} + +func TestRenderTemplateData(t *testing.T) { + str, err := RenderTemplate("{{ .Some }}/{{ .Data }}", struct { + Some string + Data string + }{"some", "data"}) + assert.NoError(t, err) + assert.Equal(t, "some/data", str) +} + +func TestRenderTemplateSyntaxError(t *testing.T) { + _, err := RenderTemplate("{{ }", nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "parse") +} + +func TestRenderTemplateDataError(t *testing.T) { + _, err := RenderTemplate("{{ .none }}", "data") assert.Error(t, err) assert.Contains(t, err.Error(), "render") } diff --git a/pkg/pipeline/cluster_steps.go b/pkg/pipeline/cluster_steps.go new file mode 100644 index 00000000..3d8c8060 --- /dev/null +++ b/pkg/pipeline/cluster_steps.go @@ -0,0 +1,146 @@ +package pipeline + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" + "github.com/projectsyn/lieutenant-operator/pkg/pipeline/cluster" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +func createClusterRBAC(obj PipelineObject, data *ExecutionContext) ExecutionResult { + objMeta := metav1.ObjectMeta{ + Name: obj.GetObjectMeta().GetName(), + Namespace: obj.GetObjectMeta().GetNamespace(), + OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(obj.GetObjectMeta(), synv1alpha1.SchemeBuilder.GroupVersion.WithKind("Cluster"))}, + } + serviceAccount := &corev1.ServiceAccount{ObjectMeta: objMeta} + role := &rbacv1.Role{ + ObjectMeta: objMeta, + Rules: []rbacv1.PolicyRule{{ + APIGroups: []string{synv1alpha1.SchemeGroupVersion.Group}, + Resources: []string{"clusters"}, + Verbs: []string{"get", "update"}, + ResourceNames: []string{obj.GetObjectMeta().GetName()}, + }}, + } + roleBinding := &rbacv1.RoleBinding{ + ObjectMeta: objMeta, + RoleRef: rbacv1.RoleRef{ + Kind: "Role", + Name: role.Name, + }, + Subjects: []rbacv1.Subject{{ + Kind: "ServiceAccount", + Name: serviceAccount.Name, + Namespace: serviceAccount.Namespace, + }}, + } + for _, item := range []runtime.Object{serviceAccount, role, roleBinding} { + if err := data.Client.Create(context.TODO(), item); err != nil && !errors.IsAlreadyExists(err) { + return ExecutionResult{Err: err} + } + } + return ExecutionResult{} +} + +func setBootstrapToken(obj PipelineObject, data *ExecutionContext) ExecutionResult { + instance, ok := obj.(*synv1alpha1.Cluster) + if !ok { + return ExecutionResult{Err: fmt.Errorf("%s is not a cluster object", obj.GetObjectMeta().GetName())} + } + + if instance.Status.BootstrapToken == nil { + data.Log.Info("Adding status to Cluster object") + err := newClusterStatus(instance) + if err != nil { + return ExecutionResult{Err: err} + } + } + + if time.Now().After(instance.Status.BootstrapToken.ValidUntil.Time) { + instance.Status.BootstrapToken.TokenValid = false + } + + return ExecutionResult{} +} + +//newClusterStatus will create a default lifetime of 30 minutes if it wasn't set in the object. +func newClusterStatus(cluster *synv1alpha1.Cluster) error { + parseTime := "30m" + if cluster.Spec.TokenLifeTime != "" { + parseTime = cluster.Spec.TokenLifeTime + } + + duration, err := time.ParseDuration(parseTime) + if err != nil { + return err + } + + validUntil := time.Now().Add(duration) + + token, err := generateToken() + if err != nil { + return err + } + + cluster.Status.BootstrapToken = &synv1alpha1.BootstrapToken{ + Token: token, + ValidUntil: metav1.NewTime(validUntil), + TokenValid: true, + } + return nil +} + +func generateToken() (string, error) { + b := make([]byte, 16) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), err +} + +func setTenantOwner(obj PipelineObject, data *ExecutionContext) ExecutionResult { + tenant := &synv1alpha1.Tenant{} + tenantName := types.NamespacedName{Name: obj.GetTenantRef().Name, Namespace: obj.GetObjectMeta().GetNamespace()} + + err := data.Client.Get(context.TODO(), tenantName, tenant) + if err != nil { + return ExecutionResult{Err: err} + } + + obj.GetObjectMeta().SetOwnerReferences([]metav1.OwnerReference{ + *metav1.NewControllerRef(tenant.GetObjectMeta(), tenant.GroupVersionKind()), + }) + + return ExecutionResult{} +} + +func applyTenantTemplate(obj PipelineObject, data *ExecutionContext) ExecutionResult { + nsName := types.NamespacedName{Name: obj.GetTenantRef().Name, Namespace: obj.GetObjectMeta().GetNamespace()} + + tenant := &synv1alpha1.Tenant{} + if err := data.Client.Get(context.TODO(), nsName, tenant); err != nil { + return ExecutionResult{Err: fmt.Errorf("Couldn't find tenant: %w", err)} + } + + instance, ok := obj.(*synv1alpha1.Cluster) + if !ok { + return ExecutionResult{Err: fmt.Errorf("object is not a cluster")} + } + + if err := cluster.ApplyClusterTemplate(instance, tenant); err != nil { + return ExecutionResult{Err: err} + } + return ExecutionResult{} +} diff --git a/pkg/pipeline/cluster_steps_test.go b/pkg/pipeline/cluster_steps_test.go new file mode 100644 index 00000000..5f94268a --- /dev/null +++ b/pkg/pipeline/cluster_steps_test.go @@ -0,0 +1,217 @@ +package pipeline + +import ( + "context" + "testing" + + synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +var rbacCases = genericCases{ + "create cluster RBAC": { + wantErr: false, + args: args{ + cluster: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "testnamespace", + }, + }, + }, + }, +} + +func Test_createClusterRBAC(t *testing.T) { + for name, tt := range rbacCases { + client, _ := testSetupClient([]runtime.Object{ + tt.args.cluster, + }) + + tt.args.data = &ExecutionContext{Client: client} + + t.Run(name, func(t *testing.T) { + if got := createClusterRBAC(tt.args.cluster, tt.args.data); got.Err != nil != tt.wantErr { + t.Errorf("createClusterRBAC() = had error: %v", got.Err) + } + + roleBinding := &rbacv1.RoleBinding{} + serviceAccount := &corev1.ServiceAccount{} + + namespacedName := types.NamespacedName{Name: tt.args.cluster.Name, Namespace: tt.args.cluster.Namespace} + + assert.NoError(t, client.Get(context.TODO(), namespacedName, roleBinding)) + assert.NoError(t, client.Get(context.TODO(), namespacedName, serviceAccount)) + + assert.Equal(t, serviceAccount.Name, roleBinding.Subjects[len(roleBinding.Subjects)-1].Name) + assert.Equal(t, serviceAccount.Namespace, roleBinding.Subjects[len(roleBinding.Subjects)-1].Namespace) + + }) + } +} + +var setBootstrapTokenCases = genericCases{ + "Set bootstrap token": { + args: args{ + cluster: &synv1alpha1.Cluster{}, + data: &ExecutionContext{ + Log: zap.New(), + }, + }, + }, +} + +func Test_setBootstrapToken(t *testing.T) { + for name, tt := range setBootstrapTokenCases { + t.Run(name, func(t *testing.T) { + if got := setBootstrapToken(tt.args.cluster, tt.args.data); got.Err != nil != tt.wantErr { + t.Errorf("setBootstrapToken() = had error: %v", got.Err) + } + + assert.NotNil(t, tt.args.cluster.Status.BootstrapToken) + }) + } +} + +var clusterStatusCases = genericCases{ + "new cluster status": { + args: args{ + cluster: &synv1alpha1.Cluster{}, + }, + }, +} + +func Test_newClusterStatus(t *testing.T) { + for name, tt := range clusterStatusCases { + t.Run(name, func(t *testing.T) { + if err := newClusterStatus(tt.args.cluster); (err != nil) != tt.wantErr { + t.Errorf("newClusterStatus() error = %v, wantErr %v", err, tt.wantErr) + } + + assert.NotNil(t, tt.args.cluster.Status.BootstrapToken) + + }) + } +} + +var setTenantOwnerCases = genericCases{ + "set tenant owner": { + args: args{ + cluster: &synv1alpha1.Cluster{ + Spec: synv1alpha1.ClusterSpec{ + TenantRef: corev1.LocalObjectReference{Name: "testTenant"}, + }, + }, + tenant: &synv1alpha1.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testTenant", + }, + }, + data: &ExecutionContext{}, + }, + }, + "tenant does not exist": { + wantErr: true, + args: args{ + cluster: &synv1alpha1.Cluster{ + Spec: synv1alpha1.ClusterSpec{ + TenantRef: corev1.LocalObjectReference{Name: "wrongTenant"}, + }, + }, + tenant: &synv1alpha1.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testTenant", + }, + }, + data: &ExecutionContext{}, + }, + }, +} + +func Test_setTenantOwner(t *testing.T) { + for name, tt := range setTenantOwnerCases { + tt.args.data.Client, _ = testSetupClient([]runtime.Object{ + tt.args.cluster, + tt.args.tenant, + }) + + t.Run(name, func(t *testing.T) { + if got := setTenantOwner(tt.args.cluster, tt.args.data); (got.Err != nil) != tt.wantErr { + t.Errorf("setTenantOwner() = had error: %v", got.Err) + } + + if !tt.wantErr { + assert.NotEmpty(t, tt.args.cluster.GetOwnerReferences()) + } + }) + } +} + +var applyTenantTemplateCases = genericCases{ + "apply tenant template": { + args: args{ + data: &ExecutionContext{}, + cluster: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "c-some-test", + }, + }, + tenant: &synv1alpha1.Tenant{ + Spec: synv1alpha1.TenantSpec{ + ClusterTemplate: &synv1alpha1.ClusterSpec{ + DisplayName: "test", + GitRepoTemplate: &synv1alpha1.GitRepoTemplate{ + RepoName: "{{ .Name }}", + }, + }, + }, + }, + }, + }, + "wrong syntax": { + wantErr: true, + args: args{ + data: &ExecutionContext{}, + cluster: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "c-some-test", + }, + }, + tenant: &synv1alpha1.Tenant{ + Spec: synv1alpha1.TenantSpec{ + ClusterTemplate: &synv1alpha1.ClusterSpec{ + DisplayName: "test", + GitRepoTemplate: &synv1alpha1.GitRepoTemplate{ + RepoName: "{{ .Name }", + }, + }, + }, + }, + }, + }, +} + +func Test_applyTenantTemplate(t *testing.T) { + for name, tt := range applyTenantTemplateCases { + tt.args.data.Client, _ = testSetupClient([]runtime.Object{ + tt.args.cluster, + tt.args.tenant, + }) + + t.Run(name, func(t *testing.T) { + if got := applyTenantTemplate(tt.args.cluster, tt.args.data); (got.Err != nil) != tt.wantErr { + t.Errorf("applyTenantTemplate() = had error: %v", got.Err) + } + + if !tt.wantErr { + assert.Equal(t, "c-some-test", tt.args.cluster.Spec.GitRepoTemplate.RepoName) + } + }) + } +} diff --git a/pkg/pipeline/common.go b/pkg/pipeline/common.go new file mode 100644 index 00000000..ccded261 --- /dev/null +++ b/pkg/pipeline/common.go @@ -0,0 +1,22 @@ +// pipeline contains pipelines that define all the steps that a CRD has to go +// through in order to be considered reconciled. + +package pipeline + +const ( + protectionSettingEnvVar = "LIEUTENANT_DELETE_PROTECTION" + DeleteProtectionAnnotation = "syn.tools/protected-delete" +) + +func common(obj PipelineObject, data *ExecutionContext) ExecutionResult { + steps := []Step{ + {Name: "deletion", F: handleDeletion}, + {Name: "add deletion protection", F: addDeletionProtection}, + {Name: "handle finalizer", F: handleFinalizer}, + {Name: "update object", F: updateObject}, + } + + err := RunPipeline(obj, data, steps) + + return ExecutionResult{Err: err} +} diff --git a/pkg/pipeline/common_steps.go b/pkg/pipeline/common_steps.go new file mode 100644 index 00000000..d8fdc006 --- /dev/null +++ b/pkg/pipeline/common_steps.go @@ -0,0 +1,153 @@ +package pipeline + +import ( + "context" + "fmt" + "os" + "strconv" + + "github.com/projectsyn/lieutenant-operator/pkg/apis" + synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +func getDefaultDeletionPolicy() 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 + } +} + +func addDeletionProtection(instance PipelineObject, data *ExecutionContext) ExecutionResult { + if data.Deleted { + return ExecutionResult{} + } + + config := os.Getenv(protectionSettingEnvVar) + + protected, err := strconv.ParseBool(config) + if err != nil { + protected = true + } + + if protected { + annotations := instance.GetObjectMeta().GetAnnotations() + + if annotations == nil { + annotations = make(map[string]string) + } + + if _, ok := annotations[DeleteProtectionAnnotation]; !ok { + annotations[DeleteProtectionAnnotation] = "true" + } + + instance.GetObjectMeta().SetAnnotations(annotations) + } + + return ExecutionResult{} + +} + +// addTenantLabel adds the tenant label to an object. +func addTenantLabel(obj PipelineObject, data *ExecutionContext) ExecutionResult { + labels := obj.GetObjectMeta().GetLabels() + if labels == nil { + labels = make(map[string]string) + } + if labels[apis.LabelNameTenant] != obj.GetTenantRef().Name { + labels[apis.LabelNameTenant] = obj.GetTenantRef().Name + } + + obj.GetObjectMeta().SetLabels(labels) + + return ExecutionResult{} +} + +func updateObject(obj PipelineObject, data *ExecutionContext) ExecutionResult { + + resourceVersion := obj.GetObjectMeta().GetResourceVersion() + + rtObj, ok := obj.(runtime.Object) + if !ok { + return ExecutionResult{ + Abort: true, + Err: fmt.Errorf("object ist not a valid runtime.object: %v", obj.GetObjectMeta().GetName()), + } + } + + err := data.Client.Update(context.TODO(), rtObj) + if err != nil { + return ExecutionResult{Err: err} + } + + // Updating the status if either there were changes or the object is deleted will + // lead to some race conditions. By checking first we can avoid them. + if resourceVersion == obj.GetObjectMeta().GetResourceVersion() && !data.Deleted { + err = data.Client.Status().Update(context.TODO(), rtObj) + } + return ExecutionResult{Abort: true, Err: err} +} + +// handleDeletion will handle the finalizers if the object was deleted. +// It will only trigger if data.Deleted is true. +func handleDeletion(obj PipelineObject, data *ExecutionContext) ExecutionResult { + if !data.Deleted { + return ExecutionResult{} + } + + instance := obj.GetObjectMeta() + + 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 + } + } else { + protected = false + } + + if sliceContainsString(instance.GetFinalizers(), data.FinalizerName) && !protected { + + data.Deleted = true + return ExecutionResult{} + } + + return ExecutionResult{Err: fmt.Errorf("finalzier was not removed")} +} + +// 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 +} + +func checkIfDeleted(obj PipelineObject, data *ExecutionContext) ExecutionResult { + if !obj.GetObjectMeta().GetDeletionTimestamp().IsZero() { + data.Deleted = true + + } + return ExecutionResult{} + +} + +func handleFinalizer(obj PipelineObject, data *ExecutionContext) ExecutionResult { + if data.FinalizerName != "" && !data.Deleted { + controllerutil.AddFinalizer(obj.GetObjectMeta(), data.FinalizerName) + } else { + controllerutil.RemoveFinalizer(obj.GetObjectMeta(), data.FinalizerName) + } + return ExecutionResult{} +} diff --git a/pkg/pipeline/common_steps_test.go b/pkg/pipeline/common_steps_test.go new file mode 100644 index 00000000..d989d66b --- /dev/null +++ b/pkg/pipeline/common_steps_test.go @@ -0,0 +1,299 @@ +package pipeline + +import ( + "os" + "testing" + "time" + + "github.com/projectsyn/lieutenant-operator/pkg/apis" + synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// testSetupClient returns a client containing all objects in objs +func testSetupClient(objs []runtime.Object) (client.Client, *runtime.Scheme) { + s := scheme.Scheme + s.AddKnownTypes(synv1alpha1.SchemeGroupVersion, objs...) + return fake.NewFakeClientWithScheme(s, objs...), s +} + +func TestGetDeletionPolicyDefault(t *testing.T) { + policy := getDefaultDeletionPolicy() + assert.Equal(t, synv1alpha1.ArchivePolicy, policy) +} + +func TestGetDeletionPolicyNonDefault(t *testing.T) { + os.Setenv("DEFAULT_DELETION_POLICY", "Retain") + policy := getDefaultDeletionPolicy() + assert.Equal(t, synv1alpha1.RetainPolicy, policy) +} + +var addTenantLabelCases = genericCases{ + "add labels": { + args: args{ + cluster: &synv1alpha1.Cluster{ + Spec: synv1alpha1.ClusterSpec{ + TenantRef: corev1.LocalObjectReference{Name: "test"}, + }, + }, + }, + }, +} + +func TestAddTenantLabel(t *testing.T) { + for name, tt := range addTenantLabelCases { + t.Run(name, func(t *testing.T) { + addTenantLabel(tt.args.cluster, &ExecutionContext{}) + + if tt.args.cluster.GetLabels()[apis.LabelNameTenant] != tt.args.cluster.Spec.TenantRef.Name { + t.Error("labels do not match") + } + }) + } +} + +var handleDeletionCases = genericCases{ + "Normal deletion": { + args: args{ + cluster: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{ + "test", + }, + }, + }, + finalizerName: "test", + }, + }, + "Deletion protection set": { + wantErr: true, + args: args{ + cluster: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + DeleteProtectionAnnotation: "true", + }, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{ + "test", + }, + }, + }, + finalizerName: "test", + }, + }, + "Nonsense annotation value": { + wantErr: true, + args: args{ + cluster: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + DeleteProtectionAnnotation: "trugadse", + }, + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + Finalizers: []string{ + "test", + }, + }, + }, + finalizerName: "test", + }, + }, +} + +func TestHandleDeletion(t *testing.T) { + for name, tt := range handleDeletionCases { + t.Run(name, func(t *testing.T) { + client, _ := testSetupClient([]runtime.Object{&synv1alpha1.Cluster{}}) + + data := &ExecutionContext{ + Client: client, + Deleted: true, + FinalizerName: tt.args.finalizerName, + } + + got := handleDeletion(tt.args.cluster, data) + if got.Err != nil != tt.wantErr { + t.Errorf("HandleDeletion() = had error: %v", got.Err) + } + + want := []string{tt.args.finalizerName} + + assert.Equal(t, want, tt.args.cluster.GetFinalizers()) + }) + } +} + +type addDeletionProtectionArgs struct { + instance *synv1alpha1.Cluster + enable string + result string +} + +var addDeletionProtectionCases = map[string]struct { + args addDeletionProtectionArgs + wantErr bool +}{ + "Add deletion protection": { + args: addDeletionProtectionArgs{ + instance: &synv1alpha1.Cluster{}, + enable: "true", + result: "true", + }, + }, + "Don't add deletion protection": { + args: addDeletionProtectionArgs{ + instance: &synv1alpha1.Cluster{}, + enable: "false", + result: "", + }, + }, + "Invalid setting": { + args: addDeletionProtectionArgs{ + instance: &synv1alpha1.Cluster{}, + enable: "gaga", + result: "true", + }, + }, +} + +func TestAddDeletionProtection(t *testing.T) { + for name, tt := range addDeletionProtectionCases { + t.Run(name, func(t *testing.T) { + os.Setenv(protectionSettingEnvVar, tt.args.enable) + + addDeletionProtection(tt.args.instance, &ExecutionContext{}) + + result := tt.args.instance.GetAnnotations()[DeleteProtectionAnnotation] + if result != tt.args.result { + t.Errorf("AddDeletionProtection() value = %v, wantValue %v", result, tt.args.result) + } + }) + } +} + +var checkIfDeletedCases = map[string]struct { + args args + wantErr bool + want bool +}{ + "object deleted": { + want: true, + args: args{ + data: &ExecutionContext{}, + tenant: &synv1alpha1.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + }, + }, + }, + }, + "object not deleted": { + want: false, + args: args{ + data: &ExecutionContext{}, + tenant: &synv1alpha1.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + }, + }, +} + +func Test_checkIfDeleted(t *testing.T) { + for name, tt := range checkIfDeletedCases { + t.Run(name, func(t *testing.T) { + if got := checkIfDeleted(tt.args.tenant, tt.args.data); (got.Err != nil) != tt.wantErr { + t.Errorf("checkIfDeleted() = had error %v", got.Err) + } + + assert.Equal(t, tt.want, tt.args.data.Deleted) + }) + } +} + +var handleFinalizerCases = genericCases{ + "add finalizers": { + args: args{ + data: &ExecutionContext{ + FinalizerName: "test", + }, + cluster: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + }, + }, + "remove finalizers": { + args: args{ + data: &ExecutionContext{ + Deleted: true, + FinalizerName: "test", + }, + cluster: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + }, + }, +} + +func Test_handleFinalizer(t *testing.T) { + for name, tt := range handleFinalizerCases { + t.Run(name, func(t *testing.T) { + if got := handleFinalizer(tt.args.cluster, tt.args.data); (got.Err != nil) != tt.wantErr { + t.Errorf("handleFinalizer() = had error: %v", got.Err) + } + + if tt.args.data.Deleted { + assert.Empty(t, tt.args.cluster.GetFinalizers()) + } else { + assert.NotEmpty(t, tt.args.cluster.GetFinalizers()) + } + }) + } +} + +var updateObjectCases = genericCases{ + "update objects": { + args: args{ + data: &ExecutionContext{}, + tenant: &synv1alpha1.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + }, + }, + "update fail": { + args: args{ + data: &ExecutionContext{}, + tenant: &synv1alpha1.Tenant{}, + }, + }, +} + +func Test_updateObject(t *testing.T) { + for name, tt := range updateObjectCases { + t.Run(name, func(t *testing.T) { + tt.args.data.Client, _ = testSetupClient([]runtime.Object{ + tt.args.tenant, + }) + + if got := updateObject(tt.args.tenant, tt.args.data); (got.Err != nil) != tt.wantErr { + t.Errorf("updateObject() = had error: %v", got.Err) + } + }) + } +} diff --git a/pkg/pipeline/git.go b/pkg/pipeline/git.go new file mode 100644 index 00000000..fe8b14cd --- /dev/null +++ b/pkg/pipeline/git.go @@ -0,0 +1,202 @@ +package pipeline + +import ( + "context" + "fmt" + + 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/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func gitRepoSpecificSteps(obj PipelineObject, data *ExecutionContext) ExecutionResult { + instance, ok := obj.(*synv1alpha1.GitRepo) + if !ok { + return ExecutionResult{Err: fmt.Errorf("object is not a GitRepository")} + } + + err := fetchGitRepoTemplate(instance, data) + if err != nil { + return ExecutionResult{Err: err} + } + + if instance.Spec.RepoType == synv1alpha1.UnmanagedRepoType { + data.Log.Info("Skipping GitRepo because it is unmanaged") + return ExecutionResult{} + } + + repo, hostKeys, err := manager.GetGitClient(&instance.Spec.GitRepoTemplate, instance.GetNamespace(), data.Log, data.Client) + if err != nil { + return ExecutionResult{Err: err} + } + + instance.Status.HostKeys = hostKeys + + if !repoExists(repo) { + data.Log.Info("creating git repo", manager.SecretEndpointName, repo.FullURL()) + err := repo.Create() + if err != nil { + return ExecutionResult{Err: handleRepoError(err, instance, repo, data.Client)} + + } + data.Log.Info("successfully created the repository") + } + + if data.Deleted { + err := repo.Remove() + if err != nil { + return ExecutionResult{Err: err} + } + return ExecutionResult{} + } + + err = repo.CommitTemplateFiles() + if err != nil { + return ExecutionResult{Err: handleRepoError(err, instance, repo, data.Client)} + } + + changed, err := repo.Update() + if err != nil { + return ExecutionResult{Err: err} + } + + if changed { + data.Log.Info("keys differed from CRD, keys re-applied to repository") + } + + phase := synv1alpha1.Created + instance.Status.Phase = &phase + instance.Status.URL = repo.FullURL().String() + instance.Status.Type = synv1alpha1.GitType(repo.Type()) + + return ExecutionResult{} +} + +// createGitRepo will create the gitRepo object if it doesn't already exist. +func createGitRepo(obj PipelineObject, data *ExecutionContext) ExecutionResult { + template := obj.GetGitTemplate() + + if template == nil { + return ExecutionResult{} + } + + if template.DisplayName == "" { + template.DisplayName = obj.GetDisplayName() + } + + if obj.GetTenantRef().Name == "" { + return ExecutionResult{ + Abort: true, + Err: fmt.Errorf("the tenant name is empty"), + } + } + + if template.DeletionPolicy == "" { + if obj.GetDeletionPolicy() == "" { + template.DeletionPolicy = getDefaultDeletionPolicy() + } else { + template.DeletionPolicy = obj.GetDeletionPolicy() + } + } + + if template.RepoType == synv1alpha1.DefaultRepoType { + template.RepoType = synv1alpha1.AutoRepoType + } + + repo := &synv1alpha1.GitRepo{ + ObjectMeta: metav1.ObjectMeta{ + Name: obj.GetObjectMeta().GetName(), + Namespace: obj.GetObjectMeta().GetNamespace(), + OwnerReferences: []metav1.OwnerReference{ + *metav1.NewControllerRef(obj.GetObjectMeta(), obj.GroupVersionKind()), + }, + }, + Spec: synv1alpha1.GitRepoSpec{ + GitRepoTemplate: *template, + TenantRef: obj.GetTenantRef(), + }, + } + + err := data.Client.Create(context.TODO(), repo) + if err != nil { + if !errors.IsAlreadyExists(err) { + return ExecutionResult{} + } + } + + return ExecutionResult{} +} + +func setGitRepoURLAndHostKeys(obj PipelineObject, data *ExecutionContext) ExecutionResult { + gitRepo := &synv1alpha1.GitRepo{} + repoNamespacedName := types.NamespacedName{ + Namespace: obj.GetObjectMeta().GetNamespace(), + Name: obj.GetObjectMeta().GetName(), + } + err := data.Client.Get(context.TODO(), repoNamespacedName, gitRepo) + if err != nil { + if errors.IsNotFound(err) { + return ExecutionResult{} + } + return ExecutionResult{Abort: true, Err: err} + } + + obj.SetGitRepoURLAndHostKeys(gitRepo.Status.URL, gitRepo.Status.HostKeys) + + return ExecutionResult{} +} + +func repoExists(repo manager.Repo) bool { + if err := repo.Read(); err == nil { + return true + } + + return false +} + +func handleRepoError(err error, instance *synv1alpha1.GitRepo, repo manager.Repo, client client.Client) error { + phase := synv1alpha1.Failed + instance.Status.Phase = &phase + instance.Status.URL = repo.FullURL().String() + if updateErr := client.Status().Update(context.TODO(), instance); updateErr != nil { + return fmt.Errorf("could not set status while handling error: %s: %s", updateErr, err) + } + return err +} + +func fetchGitRepoTemplate(obj *synv1alpha1.GitRepo, data *ExecutionContext) error { + tenant := &synv1alpha1.Tenant{} + + tenantName := types.NamespacedName{Name: obj.GetObjectMeta().GetName(), Namespace: obj.GetObjectMeta().GetNamespace()} + + err := data.Client.Get(context.TODO(), tenantName, tenant) + if err != nil { + if !errors.IsNotFound(err) { + return err + } + } + + if tenant != nil && tenant.Spec.GitRepoTemplate != nil { + obj.Spec.GitRepoTemplate = *tenant.Spec.GitRepoTemplate + } + + cluster := &synv1alpha1.Cluster{} + + clusterName := types.NamespacedName{Name: obj.GetName(), Namespace: obj.GetNamespace()} + + err = data.Client.Get(context.TODO(), clusterName, cluster) + if err != nil { + if !errors.IsNotFound(err) { + return err + } + } + + if cluster != nil && cluster.Spec.GitRepoTemplate != nil { + obj.Spec.GitRepoTemplate = *cluster.Spec.GitRepoTemplate + } + + return nil +} diff --git a/pkg/pipeline/git_test.go b/pkg/pipeline/git_test.go new file mode 100644 index 00000000..d4036c38 --- /dev/null +++ b/pkg/pipeline/git_test.go @@ -0,0 +1,322 @@ +package pipeline + +import ( + "context" + "fmt" + "net/url" + "testing" + + synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" + "github.com/projectsyn/lieutenant-operator/pkg/git/manager" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type gitRepoArgs struct { + repo *synv1alpha1.GitRepo + cluster *synv1alpha1.Cluster + template *synv1alpha1.GitRepoTemplate + tenantRef corev1.LocalObjectReference + templateObj PipelineObject + data *ExecutionContext +} + +var createOrUpdateGitRepoCases = map[string]struct { + args gitRepoArgs + wantErr bool +}{ + "create git repo": { + args: gitRepoArgs{ + cluster: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test", + }, + }, + template: &synv1alpha1.GitRepoTemplate{ + APISecretRef: corev1.SecretReference{Name: "testSecret"}, + DeployKeys: nil, + Path: "testPath", + RepoName: "testRepo", + }, + tenantRef: corev1.LocalObjectReference{ + Name: "testTenant", + }, + }, + }, + "empty template": { + args: gitRepoArgs{ + cluster: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test", + }, + }, + template: nil, + tenantRef: corev1.LocalObjectReference{ + Name: "testTenant", + }, + }, + }, +} + +func TestCreateOrUpdateGitRepo(t *testing.T) { + for name, tt := range createOrUpdateGitRepoCases { + objs := []runtime.Object{ + &synv1alpha1.GitRepo{}, + } + + cl, _ := testSetupClient(objs) + + tt.args.cluster.Spec.GitRepoTemplate = tt.args.template + tt.args.cluster.Spec.TenantRef = tt.args.tenantRef + + t.Run(name, func(t *testing.T) { + if res := createGitRepo(tt.args.cluster, &ExecutionContext{Client: cl}); (res.Err != nil) != tt.wantErr { + t.Errorf("CreateGitRepo() error = %v, wantErr %v", res.Err, tt.wantErr) + } + + if tt.args.template != nil { + namespacedName := types.NamespacedName{ + Name: tt.args.cluster.GetName(), + Namespace: tt.args.cluster.GetNamespace(), + } + + checkRepo := &synv1alpha1.GitRepo{} + assert.NoError(t, cl.Get(context.TODO(), namespacedName, checkRepo)) + assert.Equal(t, tt.args.template, &checkRepo.Spec.GitRepoTemplate) + } + }) + } +} + +var fetchGitRepoTemplateCases = map[string]struct { + args gitRepoArgs + wantErr bool +}{ + "fetch tenant changes": { + args: gitRepoArgs{ + data: &ExecutionContext{}, + repo: &synv1alpha1.GitRepo{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + templateObj: &synv1alpha1.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: synv1alpha1.TenantSpec{ + GitRepoTemplate: &synv1alpha1.GitRepoTemplate{ + RepoName: "Test Repo", + RepoType: synv1alpha1.AutoRepoType, + Path: "test", + }, + }, + }, + }, + }, + "fetch cluster changes": { + args: gitRepoArgs{ + data: &ExecutionContext{}, + repo: &synv1alpha1.GitRepo{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + templateObj: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: synv1alpha1.ClusterSpec{ + GitRepoTemplate: &synv1alpha1.GitRepoTemplate{ + RepoName: "Test Repo", + RepoType: synv1alpha1.AutoRepoType, + Path: "test", + }, + }, + }, + }, + }, +} + +func Test_fetchGitRepoTemplate(t *testing.T) { + for name, tt := range fetchGitRepoTemplateCases { + t.Run(name, func(t *testing.T) { + rtObj := tt.args.templateObj.(runtime.Object) + + tt.args.data.Client, _ = testSetupClient([]runtime.Object{ + tt.args.repo, + &synv1alpha1.Tenant{}, + &synv1alpha1.Cluster{}, + rtObj, + }) + + if err := fetchGitRepoTemplate(tt.args.repo, tt.args.data); (err != nil) != tt.wantErr { + t.Errorf("fetchGitRepoTemplate() error = %v, wantErr %v", err, tt.wantErr) + } + + assert.Equal(t, tt.args.templateObj.GetGitTemplate(), &tt.args.repo.Spec.GitRepoTemplate) + tt.args.templateObj.GetGitTemplate().RepoName = "another test" + rtObj = tt.args.templateObj.(runtime.Object) + assert.NoError(t, tt.args.data.Client.Update(context.TODO(), rtObj)) + assert.NoError(t, fetchGitRepoTemplate(tt.args.repo, tt.args.data)) + assert.Equal(t, tt.args.templateObj.GetGitTemplate(), &tt.args.repo.Spec.GitRepoTemplate) + }) + } +} + +type repoMock struct { + failRead bool +} + +func (r *repoMock) Type() string { return "mock" } +func (r *repoMock) FullURL() *url.URL { return &url.URL{} } +func (r *repoMock) Create() error { return nil } +func (r *repoMock) Update() (bool, error) { return false, nil } +func (r *repoMock) Read() error { + if r.failRead { + return fmt.Errorf("this should fail") + } + return nil +} +func (r *repoMock) Connect() error { return nil } +func (r *repoMock) Remove() error { return nil } +func (r *repoMock) CommitTemplateFiles() error { return nil } + +var repoExistsCases = map[string]struct { + args manager.Repo + want bool +}{ + "repo exists": { + want: true, + args: &repoMock{}, + }, + "repo doesn't exist": { + want: false, + args: &repoMock{ + failRead: true, + }, + }} + +func Test_repoExists(t *testing.T) { + for name, tt := range repoExistsCases { + t.Run(name, func(t *testing.T) { + if got := repoExists(tt.args); got != tt.want { + t.Errorf("repoExists() = %v, want %v", got, tt.want) + } + }) + } +} + +type handleRepoErrorArgs struct { + err error + instance *synv1alpha1.GitRepo + repo manager.Repo + fail bool +} + +var handleRepoErrorCases = map[string]struct { + args handleRepoErrorArgs +}{ + "add error": { + args: handleRepoErrorArgs{ + err: fmt.Errorf("lol nope"), + instance: &synv1alpha1.GitRepo{}, + repo: &repoMock{}, + }, + }, + "add error failure": { + args: handleRepoErrorArgs{ + err: fmt.Errorf("lol nope"), + instance: &synv1alpha1.GitRepo{}, + repo: &repoMock{}, + fail: true, + }, + }, +} + +func Test_handleRepoError(t *testing.T) { + for name, tt := range handleRepoErrorCases { + t.Run(name, func(t *testing.T) { + + var client client.Client + + if tt.args.fail { + client, _ = testSetupClient([]runtime.Object{}) + } else { + client, _ = testSetupClient([]runtime.Object{tt.args.instance}) + } + + err := handleRepoError(tt.args.err, tt.args.instance, tt.args.repo, client) + assert.Error(t, err) + failedPhase := synv1alpha1.Failed + assert.Equal(t, &failedPhase, tt.args.instance.Status.Phase) + + if tt.args.fail { + assert.Contains(t, err.Error(), "could not set status") + } + + }) + } +} + +var setGitRepoURLAndHostKeysCases = map[string]struct { + args gitRepoArgs + wantErr bool +}{ + "set url and keys": { + wantErr: false, + args: gitRepoArgs{ + cluster: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + data: &ExecutionContext{}, + repo: &synv1alpha1.GitRepo{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Status: synv1alpha1.GitRepoStatus{ + URL: "someURL", + HostKeys: "someKeys", + }, + }, + }, + }, + "set url and keys not found": { + wantErr: false, + args: gitRepoArgs{ + cluster: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid", + }, + }, + data: &ExecutionContext{}, + repo: &synv1alpha1.GitRepo{}, + }, + }, +} + +func Test_setGitRepoURLAndHostKeys(t *testing.T) { + for name, tt := range setGitRepoURLAndHostKeysCases { + t.Run(name, func(t *testing.T) { + tt.args.data.Client, _ = testSetupClient([]runtime.Object{ + tt.args.cluster, + tt.args.repo, + }) + + if got := setGitRepoURLAndHostKeys(tt.args.cluster, tt.args.data); (got.Err != nil) != tt.wantErr { + t.Errorf("setGitRepoURLAndHostKeys() = had error: %v", got.Err) + } + + assert.Equal(t, tt.args.repo.Status.URL, tt.args.cluster.Spec.GitRepoURL) + assert.Equal(t, tt.args.repo.Status.HostKeys, tt.args.cluster.Spec.GitHostKeys) + }) + } +} diff --git a/pkg/pipeline/pipelines.go b/pkg/pipeline/pipelines.go new file mode 100644 index 00000000..5993eb04 --- /dev/null +++ b/pkg/pipeline/pipelines.go @@ -0,0 +1,58 @@ +package pipeline + +import "fmt" + +// Function defines the general form of a pipeline function. +type Function func(PipelineObject, *ExecutionContext) ExecutionResult + +type Step struct { + Name string + F Function +} + +func ReconcileTenant(obj PipelineObject, data *ExecutionContext) error { + steps := []Step{ + {Name: "tenant specific steps", F: tenantSpecificSteps}, + {Name: "create git repo", F: createGitRepo}, + {Name: "set gitrepo url and hostkeys", F: setGitRepoURLAndHostKeys}, + {Name: "common", F: common}, + } + + return RunPipeline(obj, data, steps) +} + +func ReconcileCluster(obj PipelineObject, data *ExecutionContext) error { + steps := []Step{ + {Name: "cluster specific steps", F: clusterSpecificSteps}, + {Name: "create git repo", F: createGitRepo}, + {Name: "set gitrepo url and hostkeys", F: setGitRepoURLAndHostKeys}, + {Name: "add tenant label", F: addTenantLabel}, + {Name: "common", F: common}, + } + + return RunPipeline(obj, data, steps) +} + +func ReconcileGitRep(obj PipelineObject, data *ExecutionContext) error { + steps := []Step{ + {Name: "deletion check", F: checkIfDeleted}, + {Name: "git repo specific steps", F: gitRepoSpecificSteps}, + {Name: "add tenant label", F: addTenantLabel}, + {Name: "common", F: common}, + } + + return RunPipeline(obj, data, steps) +} + +func RunPipeline(obj PipelineObject, data *ExecutionContext, steps []Step) error { + for _, step := range steps { + if r := step.F(obj, data); r.Abort || r.Err != nil { + if r.Err == nil { + return nil + } + return fmt.Errorf("step %s failed: %w", step.Name, r.Err) + } + } + + return nil +} diff --git a/pkg/pipeline/reconcile_types.go b/pkg/pipeline/reconcile_types.go new file mode 100644 index 00000000..fd1c7fde --- /dev/null +++ b/pkg/pipeline/reconcile_types.go @@ -0,0 +1,36 @@ +package pipeline + +import ( + "github.com/go-logr/logr" + synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// PipelineObject defines an interface to extract necessary information from the CRs +type PipelineObject interface { + GetObjectMeta() metav1.Object + GetGitTemplate() *synv1alpha1.GitRepoTemplate + GroupVersionKind() schema.GroupVersionKind + GetTenantRef() corev1.LocalObjectReference + GetDeletionPolicy() synv1alpha1.DeletionPolicy + GetDisplayName() string + SetGitRepoURLAndHostKeys(URL, hostKeys string) +} + +// ExecutionContext contains additional data about the CRD bein processed. +type ExecutionContext struct { + FinalizerName string + Client client.Client + Log logr.Logger + Deleted bool +} + +// ExecutionResult indicates wether the current execution should be aborted and +// if there was an error. +type ExecutionResult struct { + Abort bool + Err error +} diff --git a/pkg/pipeline/reconcile_types_test.go b/pkg/pipeline/reconcile_types_test.go new file mode 100644 index 00000000..4baf62e8 --- /dev/null +++ b/pkg/pipeline/reconcile_types_test.go @@ -0,0 +1,15 @@ +package pipeline + +import synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" + +type genericCases map[string]struct { + args args + wantErr bool +} + +type args struct { + cluster *synv1alpha1.Cluster + tenant *synv1alpha1.Tenant + data *ExecutionContext + finalizerName string +} diff --git a/pkg/pipeline/tenant.go b/pkg/pipeline/tenant.go new file mode 100644 index 00000000..87e0575c --- /dev/null +++ b/pkg/pipeline/tenant.go @@ -0,0 +1,19 @@ +package pipeline + +const ( + // CommonClassName is the name of the tenant's common class + CommonClassName = "common" + DefaultGlobalGitRepoURL = "DEFAULT_GLOBAL_GIT_REPO_URL" +) + +func tenantSpecificSteps(obj PipelineObject, data *ExecutionContext) ExecutionResult { + steps := []Step{ + {Name: "add default class file", F: addDefaultClassFile}, + {Name: "uptade tenant git repo", F: updateTenantGitRepo}, + {Name: "set global git repo url", F: setGlobalGitRepoURL}, + } + + err := RunPipeline(obj, data, steps) + + return ExecutionResult{Err: err} +} diff --git a/pkg/pipeline/tenant_steps.go b/pkg/pipeline/tenant_steps.go new file mode 100644 index 00000000..ff10c6b8 --- /dev/null +++ b/pkg/pipeline/tenant_steps.go @@ -0,0 +1,85 @@ +package pipeline + +import ( + "context" + "fmt" + "os" + + "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/labels" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func addDefaultClassFile(obj PipelineObject, data *ExecutionContext) ExecutionResult { + commonClassFile := CommonClassName + ".yml" + if obj.GetGitTemplate().TemplateFiles == nil { + obj.GetGitTemplate().TemplateFiles = map[string]string{} + } + if _, ok := obj.GetGitTemplate().TemplateFiles[commonClassFile]; !ok { + obj.GetGitTemplate().TemplateFiles[commonClassFile] = "" + } + return ExecutionResult{} +} + +func updateTenantGitRepo(obj PipelineObject, data *ExecutionContext) ExecutionResult { + tenantCR, ok := obj.(*synv1alpha1.Tenant) + if !ok { + return ExecutionResult{Err: fmt.Errorf("object is not a tenant")} + } + + var oldFiles map[string]string + if tenantCR.Spec.GitRepoTemplate != nil { + oldFiles = tenantCR.Spec.GitRepoTemplate.TemplateFiles + } else { + tenantCR.Spec.GitRepoTemplate = &synv1alpha1.GitRepoTemplate{} + } + + tenantCR.Spec.GitRepoTemplate.TemplateFiles = map[string]string{} + + clusterList := &synv1alpha1.ClusterList{} + + selector := labels.Set(map[string]string{apis.LabelNameTenant: tenantCR.Name}).AsSelector() + + listOptions := &client.ListOptions{ + LabelSelector: selector, + Namespace: obj.GetObjectMeta().GetNamespace(), + } + + err := data.Client.List(context.TODO(), clusterList, listOptions) + if err != nil { + return ExecutionResult{Err: err} + } + + for _, cluster := range clusterList.Items { + fileName := cluster.GetName() + ".yml" + fileContent := fmt.Sprintf(clusterClassContent, cluster.GetName(), CommonClassName) + tenantCR.Spec.GitRepoTemplate.TemplateFiles[fileName] = fileContent + delete(oldFiles, fileName) + } + + for fileName := range oldFiles { + if fileName == CommonClassName+".yml" { + tenantCR.Spec.GitRepoTemplate.TemplateFiles[CommonClassName+".yml"] = "" + } else { + tenantCR.Spec.GitRepoTemplate.TemplateFiles[fileName] = manager.DeletionMagicString + + } + } + + return ExecutionResult{} +} + +func setGlobalGitRepoURL(obj PipelineObject, data *ExecutionContext) ExecutionResult { + instance, ok := obj.(*synv1alpha1.Tenant) + if !ok { + return ExecutionResult{Err: fmt.Errorf("object is not a tenant")} + } + + defaultGlobalGitRepoURL := os.Getenv(DefaultGlobalGitRepoURL) + if len(instance.Spec.GlobalGitRepoURL) == 0 && len(defaultGlobalGitRepoURL) > 0 { + instance.Spec.GlobalGitRepoURL = defaultGlobalGitRepoURL + } + return ExecutionResult{} +} diff --git a/pkg/pipeline/tenant_steps_test.go b/pkg/pipeline/tenant_steps_test.go new file mode 100644 index 00000000..4e083a8e --- /dev/null +++ b/pkg/pipeline/tenant_steps_test.go @@ -0,0 +1,173 @@ +package pipeline + +import ( + "os" + "testing" + + "github.com/projectsyn/lieutenant-operator/pkg/apis" + synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +var addDefaultClassFileCases = genericCases{ + "add default class": { + args: args{ + tenant: &synv1alpha1.Tenant{}, + data: &ExecutionContext{}, + }, + }, +} + +func Test_addDefaultClassFile(t *testing.T) { + for name, tt := range addDefaultClassFileCases { + t.Run(name, func(t *testing.T) { + + got := addDefaultClassFile(tt.args.tenant, tt.args.data) + assert.NoError(t, got.Err) + + assert.Contains(t, tt.args.tenant.Spec.GitRepoTemplate.TemplateFiles, "common.yml") + assert.NotEmpty(t, tt.args.tenant.Spec.GitRepoTemplate.TemplateFiles) + + }) + } +} + +type setGlobalGitRepoURLArgs struct { + tenant *synv1alpha1.Tenant + defaultRepo string + data *ExecutionContext +} + +var setGlobalGitRepoURLCases = map[string]struct { + want string + args setGlobalGitRepoURLArgs +}{ + "set global git repo url via env var": { + want: "test", + args: setGlobalGitRepoURLArgs{ + tenant: &synv1alpha1.Tenant{}, + defaultRepo: "test", + }, + }, + "don't override": { + want: "foo", + args: setGlobalGitRepoURLArgs{ + tenant: &synv1alpha1.Tenant{ + Spec: synv1alpha1.TenantSpec{ + GlobalGitRepoURL: "foo", + }, + }, + defaultRepo: "test", + }, + }, +} + +func Test_setGlobalGitRepoURL(t *testing.T) { + for name, tt := range setGlobalGitRepoURLCases { + t.Run(name, func(t *testing.T) { + + os.Setenv(DefaultGlobalGitRepoURL, tt.args.defaultRepo) + + got := setGlobalGitRepoURL(tt.args.tenant, tt.args.data) + assert.NoError(t, got.Err) + + assert.Equal(t, tt.want, tt.args.tenant.Spec.GlobalGitRepoURL) + + }) + } +} + +var updateTenantGitRepoCases = map[string]struct { + want *synv1alpha1.GitRepoTemplate + args args +}{ + "fetch git repos": { + want: &synv1alpha1.GitRepoTemplate{ + TemplateFiles: map[string]string{ + "testCluster.yml": "classes:\n- testCluster.common\n", + }, + }, + args: args{ + data: &ExecutionContext{}, + tenant: &synv1alpha1.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testTenant", + }, + }, + cluster: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testCluster", + Labels: map[string]string{ + apis.LabelNameTenant: "testTenant", + }, + }, + Spec: synv1alpha1.ClusterSpec{ + GitRepoTemplate: &synv1alpha1.GitRepoTemplate{ + TemplateFiles: map[string]string{ + "testCluster.yml": "classes:\n- testCluster.common\n", + }, + }, + }, + }, + }, + }, + "remove files": { + want: &synv1alpha1.GitRepoTemplate{ + TemplateFiles: map[string]string{ + "testCluster.yml": "classes:\n- testCluster.common\n", + "oldFile.yml": "{delete}", + }, + }, + args: args{ + data: &ExecutionContext{}, + tenant: &synv1alpha1.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testTenant", + }, + Spec: synv1alpha1.TenantSpec{ + GitRepoTemplate: &synv1alpha1.GitRepoTemplate{ + TemplateFiles: map[string]string{ + "oldFile.yml": "not important", + }, + }, + }, + }, + cluster: &synv1alpha1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testCluster", + Labels: map[string]string{ + apis.LabelNameTenant: "testTenant", + }, + }, + Spec: synv1alpha1.ClusterSpec{ + GitRepoTemplate: &synv1alpha1.GitRepoTemplate{ + TemplateFiles: map[string]string{ + "testCluster.yml": "classes:\n- testCluster.common\n", + }, + }, + }, + }, + }, + }, +} + +func Test_updateTenantGitRepo(t *testing.T) { + for name, tt := range updateTenantGitRepoCases { + t.Run(name, func(t *testing.T) { + + tt.args.data.Client, _ = testSetupClient([]runtime.Object{ + tt.args.cluster, + tt.args.tenant, + &synv1alpha1.ClusterList{}, + }) + + got := updateTenantGitRepo(tt.args.tenant, tt.args.data) + assert.NoError(t, got.Err) + + assert.Equal(t, tt.want, tt.args.tenant.GetGitTemplate()) + + }) + } +} diff --git a/pkg/pipeline/vault.go b/pkg/pipeline/vault.go new file mode 100644 index 00000000..6677eb58 --- /dev/null +++ b/pkg/pipeline/vault.go @@ -0,0 +1,105 @@ +package pipeline + +import ( + "context" + "fmt" + "os" + "path" + "sort" + "strings" + + "github.com/projectsyn/lieutenant-operator/pkg/collection" + "github.com/projectsyn/lieutenant-operator/pkg/vault" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func getVaultClient(obj PipelineObject, data *ExecutionContext) (vault.VaultClient, error) { + deletionPolicy := obj.GetDeletionPolicy() + if deletionPolicy == "" { + deletionPolicy = getDefaultDeletionPolicy() + } + + return vault.NewClient(deletionPolicy, data.Log) +} + +func createOrUpdateVault(obj PipelineObject, data *ExecutionContext) ExecutionResult { + if strings.ToLower(os.Getenv("SKIP_VAULT_SETUP")) == "true" { + return ExecutionResult{} + } + + secretPath := path.Join(obj.GetTenantRef().Name, obj.GetObjectMeta().GetName(), "steward") + + token, err := GetServiceAccountToken(obj.GetObjectMeta(), data) + if err != nil { + return ExecutionResult{Err: err} + } + + vaultClient, err := getVaultClient(obj, data) + if err != nil { + return ExecutionResult{Err: err} + } + + err = vaultClient.AddSecrets([]vault.VaultSecret{{Path: secretPath, Value: token}}) + if err != nil { + return ExecutionResult{Err: err} + } + + return ExecutionResult{} +} + +func GetServiceAccountToken(instance metav1.Object, data *ExecutionContext) (string, error) { + secrets := &corev1.SecretList{} + + err := data.Client.List(context.TODO(), secrets) + if err != nil { + return "", err + } + + sortSecrets := collection.SecretSortList(*secrets) + + sort.Sort(sort.Reverse(sortSecrets)) + + for _, secret := range sortSecrets.Items { + + if secret.Type != corev1.SecretTypeServiceAccountToken { + continue + } + + if secret.Annotations[corev1.ServiceAccountNameKey] == instance.GetName() { + if string(secret.Data["token"]) == "" { + // We'll skip the secrets if the token is not yet populated. + continue + } + return string(secret.Data["token"]), nil + } + } + + return "", fmt.Errorf("no matching secrets found") +} + +func handleVaultDeletion(obj PipelineObject, data *ExecutionContext) ExecutionResult { + if strings.ToLower(os.Getenv("SKIP_VAULT_SETUP")) == "true" { + return ExecutionResult{} + } + + repoName := types.NamespacedName{ + Name: obj.GetTenantRef().Name, + Namespace: obj.GetObjectMeta().GetNamespace(), + } + + secretPath := path.Join(repoName.Name, obj.GetObjectMeta().GetName(), "steward") + + if data.Deleted { + vaultClient, err := getVaultClient(obj, data) + if err != nil { + return ExecutionResult{Err: err} + } + err = vaultClient.RemoveSecrets([]vault.VaultSecret{{Path: path.Dir(secretPath), Value: ""}}) + if err != nil { + return ExecutionResult{Err: err} + } + } + return ExecutionResult{} +} diff --git a/pkg/pipeline/vault_test.go b/pkg/pipeline/vault_test.go new file mode 100644 index 00000000..5133d691 --- /dev/null +++ b/pkg/pipeline/vault_test.go @@ -0,0 +1,139 @@ +package pipeline + +import ( + "os" + "testing" + + synv1alpha1 "github.com/projectsyn/lieutenant-operator/pkg/apis/syn/v1alpha1" + "github.com/projectsyn/lieutenant-operator/pkg/vault" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +type testMockClient struct { + deletionPolicy synv1alpha1.DeletionPolicy +} + +func (m *testMockClient) AddSecrets(secrets []vault.VaultSecret) error { return nil } + +func (m *testMockClient) RemoveSecrets(secrets []vault.VaultSecret) error { return nil } + +func (m *testMockClient) SetDeletionPolicy(deletionPolicy synv1alpha1.DeletionPolicy) { + m.deletionPolicy = deletionPolicy +} + +var getVaultCases = map[string]struct { + want synv1alpha1.DeletionPolicy + args args +}{ + "without specific deletion policy": { + want: getDefaultDeletionPolicy(), + args: args{ + cluster: &synv1alpha1.Cluster{}, + data: &ExecutionContext{ + Log: zap.New(), + }, + }, + }, + "specific deletion policy": { + want: synv1alpha1.DeletePolicy, + args: args{ + cluster: &synv1alpha1.Cluster{ + Spec: synv1alpha1.ClusterSpec{ + DeletionPolicy: synv1alpha1.DeletePolicy, + }, + }, + data: &ExecutionContext{ + Log: zap.New(), + }, + }, + }, +} + +func Test_getVaultClient(t *testing.T) { + // ensure that it isn't set to anything from previous tests + os.Unsetenv("DEFAULT_DELETION_POLICY") + + mockClient := &testMockClient{} + + vault.SetCustomClient(mockClient) + + for name, tt := range getVaultCases { + + t.Run(name, func(t *testing.T) { + _, err := getVaultClient(tt.args.cluster, tt.args.data) + assert.NoError(t, err) + + assert.Equal(t, tt.want, mockClient.deletionPolicy) + + }) + } +} + +var handleVaultDeletionCases = map[string]struct { + want synv1alpha1.DeletionPolicy + args args +}{ + "noop": { + want: getDefaultDeletionPolicy(), + args: args{ + cluster: &synv1alpha1.Cluster{ + Spec: synv1alpha1.ClusterSpec{ + DeletionPolicy: getDefaultDeletionPolicy(), + }, + }, + data: &ExecutionContext{}, + }, + }, + "archive": { + want: synv1alpha1.ArchivePolicy, + args: args{ + cluster: &synv1alpha1.Cluster{ + Spec: synv1alpha1.ClusterSpec{ + DeletionPolicy: synv1alpha1.ArchivePolicy, + }, + }, + data: &ExecutionContext{ + Deleted: true, + }, + }, + }, + "delete": { + want: synv1alpha1.DeletePolicy, + args: args{ + cluster: &synv1alpha1.Cluster{ + Spec: synv1alpha1.ClusterSpec{ + DeletionPolicy: synv1alpha1.DeletePolicy, + }, + }, + data: &ExecutionContext{ + Deleted: true, + }, + }, + }, +} + +func Test_handleVaultDeletion(t *testing.T) { + // ensure that it isn't set to anything from previous tests + os.Unsetenv("DEFAULT_DELETION_POLICY") + + mockClient := &testMockClient{ + deletionPolicy: getDefaultDeletionPolicy(), + } + + vault.SetCustomClient(mockClient) + + for name, tt := range handleVaultDeletionCases { + t.Run(name, func(t *testing.T) { + + tt.args.data.Client, _ = testSetupClient([]runtime.Object{ + tt.args.cluster, + }) + + got := handleVaultDeletion(tt.args.cluster, tt.args.data) + assert.NoError(t, got.Err) + assert.Equal(t, tt.want, mockClient.deletionPolicy) + }) + } +} diff --git a/pkg/vault/client.go b/pkg/vault/client.go index 267e4b27..792aafcb 100644 --- a/pkg/vault/client.go +++ b/pkg/vault/client.go @@ -33,6 +33,7 @@ type VaultClient interface { AddSecrets(secrets []VaultSecret) error // remove specific secret RemoveSecrets(secret []VaultSecret) error + SetDeletionPolicy(synv1alpha1.DeletionPolicy) } type BankVaultClient struct { @@ -48,6 +49,7 @@ type BankVaultClient struct { func NewClient(deletionPolicy synv1alpha1.DeletionPolicy, log logr.Logger) (VaultClient, error) { if instanceClient != nil { + instanceClient.SetDeletionPolicy(deletionPolicy) return instanceClient, nil } @@ -259,3 +261,7 @@ func (b *BankVaultClient) listSecrets(secretPath string) ([]string, error) { return result, nil } + +func (b *BankVaultClient) SetDeletionPolicy(deletionPolicy synv1alpha1.DeletionPolicy) { + b.deletionPolicy = deletionPolicy +}