diff --git a/api/v1beta2/kustomization_types.go b/api/v1beta2/kustomization_types.go index 9a2d9475..232c4ffe 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 87eefc25..03d3c006 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 4fbd07cb..484eb24d 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,18 +49,24 @@ 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 { - vars[k] = strings.Replace(v, "\n", "", -1) + vars[k] = strings.ReplaceAll(v, "\n", "") } 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 { - vars[k] = strings.Replace(string(v), "\n", "", -1) + vars[k] = strings.ReplaceAll(string(v), "\n", "") } } } @@ -67,7 +74,7 @@ func substituteVariables( // load in-line vars (overrides the ones from resources) if kustomization.Spec.PostBuild.Substitute != nil { for k, v := range kustomization.Spec.PostBuild.Substitute { - vars[k] = strings.Replace(v, "\n", "", -1) + vars[k] = strings.ReplaceAll(v, "\n", "") } } diff --git a/controllers/kustomization_varsub_test.go b/controllers/kustomization_varsub_test.go index a00b53e0..b4910d20 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/controllers/suite_test.go b/controllers/suite_test.go index a3ae8da7..04843f82 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -121,8 +121,11 @@ func runInContext(registerControllers func(*testenv.Environment), run func() err panic(fmt.Sprintf("Failed to create k8s client: %v", err)) } - // Create a vault test instance + // Create a Vault test instance. pool, resource, err := createVaultTestInstance() + if err != nil { + panic(fmt.Sprintf("Failed to create Vault instance: %v", err)) + } defer func() { pool.Purge(resource) }() diff --git a/docs/api/kustomize.md b/docs/api/kustomize.md index f3eb6935..a86e0692 100644 --- a/docs/api/kustomize.md +++ b/docs/api/kustomize.md @@ -1126,6 +1126,20 @@ string referring resource.
+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.
+