From eba4168672750c7b11d67ce500fd908bc1bd01e6 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.
Signed-off-by: Steven E. Harris
---
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 ++
docs/spec/v1beta2/kustomization.md | 13 ++
6 files changed, 206 insertions(+), 1 deletion(-)
diff --git a/api/v1beta2/kustomization_types.go b/api/v1beta2/kustomization_types.go
index 9a2d947..232c4ff 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 87eefc2..03d3c00 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 f1a39a6..484eb24 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 a00b53e..b4910d2 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 f3eb693..a86e069 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.
+ |
+
diff --git a/docs/spec/v1beta2/kustomization.md b/docs/spec/v1beta2/kustomization.md
index 5755e84..a378051 100644
--- a/docs/spec/v1beta2/kustomization.md
+++ b/docs/spec/v1beta2/kustomization.md
@@ -748,6 +748,16 @@ With `spec.postBuild.substituteFrom` you can provide a list of ConfigMaps and Se
from which the variables are loaded.
The ConfigMap and Secret data keys are used as the var names.
+The `spec.postBuild.substituteFrom.optional` field indicates how the
+controller should handle a referenced ConfigMap or Secret being absent
+at renconciliation time. The controller's default behavior ― with
+`optional` unspecified or set to `false` ― has it fail reconciliation if
+the referenced object is missing. By setting the `optional` field to
+`true`, you can indicate that controller should use the referenced
+object if it's there, but also tolerate its absence, treating that
+absence as if the object had been present but empty, defining no
+variables.
+
This offers basic templating for your manifests including support
for [bash string replacement functions](https://github.com/drone/envsubst) e.g.:
@@ -790,8 +800,11 @@ spec:
substituteFrom:
- kind: ConfigMap
name: cluster-vars
+ # Use this ConfigMap if it exists, but proceed if it doesn't.
+ optional: true
- kind: Secret
name: cluster-secret-vars
+ # Fail if this Secret does not exist.
```
Note that for substituting variables in a secret, `spec.stringData` field must be used i.e