From 827484c20733020c68bb56708643620bec47f504 Mon Sep 17 00:00:00 2001 From: "Steven E. Harris" Date: Thu, 10 Feb 2022 11:34:03 -0500 Subject: [PATCH] Tolerate absence of resources in post-build subst. In a Kustomization's post-build substitution sources, introduce a new "Optional" field to allow referencing a Kubernetes ConfigMap or Secret that may not exist at time of reconciliation. Treat substitution when the referenced object is missing as if the object had been present but empty, lacking any variable bindings. Retain the longstanding behavior of interpreting references to Kubernetes objects being mandatory by default, such that reconciliation fails if such a referenced object does not exist. Only when the "Optional" field is set to true will reconciliation tolerate finding the referenced object to be missing. --- api/v1beta2/kustomization_types.go | 9 +- ...mize.toolkit.fluxcd.io_kustomizations.yaml | 8 + controllers/kustomization_varsub.go | 7 + controllers/kustomization_varsub_test.go | 156 ++++++++++++++++++ docs/api/kustomize.md | 14 ++ 5 files changed, 193 insertions(+), 1 deletion(-) diff --git a/api/v1beta2/kustomization_types.go b/api/v1beta2/kustomization_types.go index 9a2d94753..232c4ffe9 100644 --- a/api/v1beta2/kustomization_types.go +++ b/api/v1beta2/kustomization_types.go @@ -17,10 +17,10 @@ limitations under the License. package v1beta2 import ( - apimeta "k8s.io/apimachinery/pkg/api/meta" "time" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -207,6 +207,13 @@ type SubstituteReference struct { // +kubebuilder:validation:MaxLength=253 // +required Name string `json:"name"` + + // Optional indicates whether the referenced resource must exist, or whether to + // tolerate its absence. If true and the referenced resource is absent, proceed + // as if the resource was present but empty, without any variables defined. + // +kubebuilder:default:=false + // +optional + Optional bool `json:"optional,omitempty"` } // KustomizationStatus defines the observed state of a kustomization. diff --git a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml index 87eefc253..03d3c0068 100644 --- a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml +++ b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml @@ -894,6 +894,14 @@ spec: maxLength: 253 minLength: 1 type: string + optional: + default: false + description: Optional indicates whether the referenced resource + must exist, or whether to tolerate its absence. If true + and the referenced resource is absent, proceed as if the + resource was present but empty, without any variables + defined. + type: boolean required: - kind - name diff --git a/controllers/kustomization_varsub.go b/controllers/kustomization_varsub.go index f1a39a628..484eb24d7 100644 --- a/controllers/kustomization_varsub.go +++ b/controllers/kustomization_varsub.go @@ -8,6 +8,7 @@ import ( "github.com/drone/envsubst" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/kustomize/api/resource" @@ -48,6 +49,9 @@ func substituteVariables( case "ConfigMap": resource := &corev1.ConfigMap{} if err := kubeClient.Get(ctx, namespacedName, resource); err != nil { + if reference.Optional && apierrors.IsNotFound(err) { + continue + } return nil, fmt.Errorf("substitute from 'ConfigMap/%s' error: %w", reference.Name, err) } for k, v := range resource.Data { @@ -56,6 +60,9 @@ func substituteVariables( case "Secret": resource := &corev1.Secret{} if err := kubeClient.Get(ctx, namespacedName, resource); err != nil { + if reference.Optional && apierrors.IsNotFound(err) { + continue + } return nil, fmt.Errorf("substitute from 'Secret/%s' error: %w", reference.Name, err) } for k, v := range resource.Data { diff --git a/controllers/kustomization_varsub_test.go b/controllers/kustomization_varsub_test.go index a00b53e06..b4910d20e 100644 --- a/controllers/kustomization_varsub_test.go +++ b/controllers/kustomization_varsub_test.go @@ -193,3 +193,159 @@ stringData: g.Expect(resultSA.Labels[fmt.Sprintf("%s/namespace", kustomizev1.GroupVersion.Group)]).To(Equal(client.ObjectKeyFromObject(resultK).Namespace)) }) } + +func TestKustomizationReconciler_VarsubOptional(t *testing.T) { + ctx := context.Background() + + g := NewWithT(t) + id := "vars-" + randStringRunes(5) + revision := "v1.0.0/" + randStringRunes(7) + + err := createNamespace(id) + g.Expect(err).NotTo(HaveOccurred(), "failed to create test namespace") + + err = createKubeConfigSecret(id) + g.Expect(err).NotTo(HaveOccurred(), "failed to create kubeconfig secret") + + manifests := func(name string) []testserver.File { + return []testserver.File{ + { + Name: "service-account.yaml", + Body: fmt.Sprintf(` +apiVersion: v1 +kind: ServiceAccount +metadata: + name: %[1]s + namespace: %[1]s + labels: + color: "${color:=blue}" + shape: "${shape:=square}" +`, name), + }, + } + } + + artifact, err := testServer.ArtifactFromFiles(manifests(id)) + g.Expect(err).NotTo(HaveOccurred()) + + repositoryName := types.NamespacedName{ + Name: randStringRunes(5), + Namespace: id, + } + + err = applyGitRepository(repositoryName, artifact, revision) + g.Expect(err).NotTo(HaveOccurred()) + + configName := types.NamespacedName{ + Name: randStringRunes(5), + Namespace: id, + } + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: configName.Name, + Namespace: configName.Namespace, + }, + Data: map[string]string{"color": "\nred\n"}, + } + g.Expect(k8sClient.Create(ctx, configMap)).Should(Succeed()) + + secretName := types.NamespacedName{ + Name: randStringRunes(5), + Namespace: id, + } + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName.Name, + Namespace: secretName.Namespace, + }, + StringData: map[string]string{"shape": "\ntriangle\n"}, + } + g.Expect(k8sClient.Create(ctx, secret)).Should(Succeed()) + + inputK := &kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: id, + Namespace: id, + }, + Spec: kustomizev1.KustomizationSpec{ + KubeConfig: &kustomizev1.KubeConfig{ + SecretRef: meta.LocalObjectReference{ + Name: "kubeconfig", + }, + }, + Interval: metav1.Duration{Duration: reconciliationInterval}, + Path: "./", + Prune: true, + SourceRef: kustomizev1.CrossNamespaceSourceReference{ + Kind: sourcev1.GitRepositoryKind, + Name: repositoryName.Name, + }, + PostBuild: &kustomizev1.PostBuild{ + Substitute: map[string]string{"var_substitution_enabled": "true"}, + SubstituteFrom: []kustomizev1.SubstituteReference{ + { + Kind: "ConfigMap", + Name: configName.Name, + Optional: true, + }, + { + Kind: "Secret", + Name: secretName.Name, + Optional: true, + }, + }, + }, + HealthChecks: []meta.NamespacedObjectKindReference{ + { + APIVersion: "v1", + Kind: "ServiceAccount", + Name: id, + Namespace: id, + }, + }, + }, + } + g.Expect(k8sClient.Create(ctx, inputK)).Should(Succeed()) + + resultSA := &corev1.ServiceAccount{} + + ensureReconciles := func(nameSuffix string) { + t.Run("reconciles successfully"+nameSuffix, func(t *testing.T) { + g.Eventually(func() bool { + resultK := &kustomizev1.Kustomization{} + _ = k8sClient.Get(ctx, client.ObjectKeyFromObject(inputK), resultK) + for _, c := range resultK.Status.Conditions { + if c.Reason == meta.ReconciliationSucceededReason { + return true + } + } + return false + }, timeout, interval).Should(BeTrue()) + + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: id, Namespace: id}, resultSA)).Should(Succeed()) + }) + } + + ensureReconciles(" with optional ConfigMap") + t.Run("replaces vars from optional ConfigMap", func(t *testing.T) { + g.Expect(resultSA.Labels["color"]).To(Equal("red")) + g.Expect(resultSA.Labels["shape"]).To(Equal("triangle")) + }) + + for _, o := range []client.Object{ + configMap, + secret, + } { + g.Expect(k8sClient.Delete(ctx, o)).Should(Succeed()) + } + + // Force a second detectable reconciliation of the Kustomization. + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(inputK), inputK)).Should(Succeed()) + inputK.Status.Conditions = nil + g.Expect(k8sClient.Status().Update(ctx, inputK)).Should(Succeed()) + ensureReconciles(" without optional ConfigMap") + t.Run("replaces vars tolerating absent ConfigMap", func(t *testing.T) { + g.Expect(resultSA.Labels["color"]).To(Equal("blue")) + g.Expect(resultSA.Labels["shape"]).To(Equal("square")) + }) +} diff --git a/docs/api/kustomize.md b/docs/api/kustomize.md index f3eb6935e..a86e06923 100644 --- a/docs/api/kustomize.md +++ b/docs/api/kustomize.md @@ -1126,6 +1126,20 @@ string referring resource.

+ + +optional
+ +bool + + + +(Optional) +

Optional indicates whether the referenced resource must exist, or whether to +tolerate its absence. If true and the referenced resource is absent, proceed +as if the resource was present but empty, without any variables defined.

+ +