diff --git a/api/v1beta1/kustomization_types.go b/api/v1beta1/kustomization_types.go index ab443f7c..b01b3ae6 100644 --- a/api/v1beta1/kustomization_types.go +++ b/api/v1beta1/kustomization_types.go @@ -33,6 +33,7 @@ const ( KustomizationKind = "Kustomization" KustomizationFinalizer = "finalizers.fluxcd.io" MaxConditionMessageLength = 20000 + DisabledValue = "disabled" ) // KustomizationSpec defines the desired state of a kustomization. @@ -166,6 +167,29 @@ type PostBuild struct { // e.g. ${var:=default}, ${var:position} and ${var/substring/replacement}. // +optional Substitute map[string]string `json:"substitute,omitempty"` + + // SubstituteFrom holds references to ConfigMaps and Secrets containing + // the variables and their values to be substituted in the YAML manifests. + // The ConfigMap and the Secret data keys represent the var names and they + // must match the vars declared in the manifests for the substitution to happen. + // +optional + SubstituteFrom []SubstituteReference `json:"substituteFrom,omitempty"` +} + +// SubstituteReference contains a reference to a resource containing +// the variables name and value. +type SubstituteReference struct { + // Kind of the values referent, valid values are ('Secret', 'ConfigMap'). + // +kubebuilder:validation:Enum=Secret;ConfigMap + // +required + Kind string `json:"kind"` + + // Name of the values referent. Should reside in the same namespace as the + // referring resource. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +required + Name string `json:"name"` } // KustomizationStatus defines the observed state of a kustomization. diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 068c548d..74448f61 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -248,6 +248,11 @@ func (in *PostBuild) DeepCopyInto(out *PostBuild) { (*out)[key] = val } } + if in.SubstituteFrom != nil { + in, out := &in.SubstituteFrom, &out.SubstituteFrom + *out = make([]SubstituteReference, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostBuild. @@ -303,3 +308,18 @@ func (in *SnapshotEntry) DeepCopy() *SnapshotEntry { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SubstituteReference) DeepCopyInto(out *SubstituteReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SubstituteReference. +func (in *SubstituteReference) DeepCopy() *SubstituteReference { + if in == nil { + return nil + } + out := new(SubstituteReference) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml index 1918ac12..2873efed 100644 --- a/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml +++ b/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml @@ -263,6 +263,34 @@ spec: support for bash string replacement functions e.g. ${var:=default}, ${var:position} and ${var/substring/replacement}. type: object + substituteFrom: + description: SubstituteFrom holds references to ConfigMaps and + Secrets containing the variables and their values to be substituted + in the YAML manifests. The ConfigMap and the Secret data keys + represent the var names and they must match the vars declared + in the manifests for the substitution to happen. + items: + description: SubstituteReference contains a reference to a resource + containing the variables name and value. + properties: + kind: + description: Kind of the values referent, valid values are + ('Secret', 'ConfigMap'). + enum: + - Secret + - ConfigMap + type: string + name: + description: Name of the values referent. Should reside + in the same namespace as the referring resource. + maxLength: 253 + minLength: 1 + type: string + required: + - kind + - name + type: object + type: array type: object prune: description: Prune enables garbage collection. diff --git a/controllers/kustomization_controller.go b/controllers/kustomization_controller.go index 5076c282..eb15a599 100644 --- a/controllers/kustomization_controller.go +++ b/controllers/kustomization_controller.go @@ -298,19 +298,20 @@ func (r *KustomizationReconciler) reconcile( ), err } - // generate kustomization.yaml and calculate the manifests checksum - checksum, err := r.generate(kustomization, dirPath) + // create any necessary kube-clients for impersonation + impersonation := NewKustomizeImpersonation(kustomization, r.Client, r.StatusPoller, dirPath) + kubeClient, statusPoller, err := impersonation.GetClient(ctx) if err != nil { return kustomizev1.KustomizationNotReady( kustomization, source.GetArtifact().Revision, - kustomizev1.BuildFailedReason, + meta.ReconciliationFailedReason, err.Error(), - ), err + ), fmt.Errorf("failed to build kube client: %w", err) } - // build the kustomization and generate the GC snapshot - snapshot, err := r.build(kustomization, checksum, dirPath) + // generate kustomization.yaml and calculate the manifests checksum + checksum, err := r.generate(ctx, kubeClient, kustomization, dirPath) if err != nil { return kustomizev1.KustomizationNotReady( kustomization, @@ -320,16 +321,15 @@ func (r *KustomizationReconciler) reconcile( ), err } - // create any necessary kube-clients for impersonation - impersonation := NewKustomizeImpersonation(kustomization, r.Client, r.StatusPoller, dirPath) - client, statusPoller, err := impersonation.GetClient(ctx) + // build the kustomization and generate the GC snapshot + snapshot, err := r.build(ctx, kustomization, checksum, dirPath) if err != nil { return kustomizev1.KustomizationNotReady( kustomization, source.GetArtifact().Revision, - meta.ReconciliationFailedReason, + kustomizev1.BuildFailedReason, err.Error(), - ), fmt.Errorf("failed to build kube client: %w", err) + ), err } // dry-run apply @@ -355,7 +355,7 @@ func (r *KustomizationReconciler) reconcile( } // prune - err = r.prune(ctx, client, kustomization, checksum) + err = r.prune(ctx, kubeClient, kustomization, checksum) if err != nil { return kustomizev1.KustomizationNotReady( kustomization, @@ -490,12 +490,12 @@ func (r *KustomizationReconciler) getSource(ctx context.Context, kustomization k return source, nil } -func (r *KustomizationReconciler) generate(kustomization kustomizev1.Kustomization, dirPath string) (string, error) { - gen := NewGenerator(kustomization) - return gen.WriteFile(dirPath) +func (r *KustomizationReconciler) generate(ctx context.Context, kubeClient client.Client, kustomization kustomizev1.Kustomization, dirPath string) (string, error) { + gen := NewGenerator(kustomization, kubeClient) + return gen.WriteFile(ctx, dirPath) } -func (r *KustomizationReconciler) build(kustomization kustomizev1.Kustomization, checksum, dirPath string) (*kustomizev1.Snapshot, error) { +func (r *KustomizationReconciler) build(ctx context.Context, kustomization kustomizev1.Kustomization, checksum, dirPath string) (*kustomizev1.Snapshot, error) { timeout := kustomization.GetTimeout() ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() @@ -517,9 +517,9 @@ func (r *KustomizationReconciler) build(kustomization kustomizev1.Kustomization, return nil, fmt.Errorf("kustomize build failed: %w", err) } - // check if resources are encrypted and decrypt them before generating the final YAML - if kustomization.Spec.Decryption != nil { - for _, res := range m.Resources() { + for _, res := range m.Resources() { + // check if resources are encrypted and decrypt them before generating the final YAML + if kustomization.Spec.Decryption != nil { outRes, err := dec.Decrypt(res) if err != nil { return nil, fmt.Errorf("decryption failed for '%s': %w", res.GetName(), err) @@ -532,6 +532,21 @@ func (r *KustomizationReconciler) build(kustomization kustomizev1.Kustomization, } } } + + // run variable substitutions + if kustomization.Spec.PostBuild != nil { + outRes, err := substituteVariables(ctx, r.Client, kustomization, res) + if err != nil { + return nil, fmt.Errorf("var substitution failed for '%s': %w", res.GetName(), err) + } + + if outRes != nil { + _, err = m.Replace(res) + if err != nil { + return nil, err + } + } + } } resources, err := m.AsYaml() @@ -539,12 +554,6 @@ func (r *KustomizationReconciler) build(kustomization kustomizev1.Kustomization, return nil, fmt.Errorf("kustomize build failed: %w", err) } - // run post-build actions - resources, err = runPostBuildActions(kustomization, resources) - if err != nil { - return nil, fmt.Errorf("post-build actions failed: %w", err) - } - manifestsFile := filepath.Join(dirPath, fmt.Sprintf("%s.yaml", kustomization.GetUID())) if err := fs.WriteFile(manifestsFile, resources); err != nil { return nil, err @@ -678,7 +687,7 @@ func (r *KustomizationReconciler) applyWithRetry(ctx context.Context, kustomizat return changeSet, nil } -func (r *KustomizationReconciler) prune(ctx context.Context, client client.Client, kustomization kustomizev1.Kustomization, newChecksum string) error { +func (r *KustomizationReconciler) prune(ctx context.Context, kubeClient client.Client, kustomization kustomizev1.Kustomization, newChecksum string) error { if !kustomization.Spec.Prune || kustomization.Status.Snapshot == nil { return nil } @@ -686,7 +695,7 @@ func (r *KustomizationReconciler) prune(ctx context.Context, client client.Clien return nil } - gc := NewGarbageCollector(client, *kustomization.Status.Snapshot, newChecksum, logr.FromContext(ctx)) + gc := NewGarbageCollector(kubeClient, *kustomization.Status.Snapshot, newChecksum, logr.FromContext(ctx)) if output, ok := gc.Prune(kustomization.GetTimeout(), kustomization.GetName(), diff --git a/controllers/kustomization_controller_test.go b/controllers/kustomization_controller_test.go index 561a698c..2349abb0 100644 --- a/controllers/kustomization_controller_test.go +++ b/controllers/kustomization_controller_test.go @@ -150,6 +150,32 @@ var _ = Describe("KustomizationReconciler", func() { Expect(k8sClient.Status().Update(context.Background(), repository)).Should(Succeed()) defer k8sClient.Delete(context.Background(), repository) + configName := types.NamespacedName{ + Name: fmt.Sprintf("%s", randStringRunes(5)), + Namespace: namespace.Name, + } + config := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: configName.Name, + Namespace: configName.Namespace, + }, + Data: map[string]string{"zone": "\naz-1a\n"}, + } + Expect(k8sClient.Create(context.Background(), config)).Should(Succeed()) + + secretName := types.NamespacedName{ + Name: fmt.Sprintf("%s", randStringRunes(5)), + Namespace: namespace.Name, + } + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName.Name, + Namespace: secretName.Namespace, + }, + StringData: map[string]string{"zone": "\naz-1b\n"}, + } + Expect(k8sClient.Create(context.Background(), secret)).Should(Succeed()) + kName := types.NamespacedName{ Name: fmt.Sprintf("%s", randStringRunes(5)), Namespace: namespace.Name, @@ -173,6 +199,16 @@ var _ = Describe("KustomizationReconciler", func() { Validation: "client", PostBuild: &kustomizev1.PostBuild{ Substitute: map[string]string{"region": "eu-central-1"}, + SubstituteFrom: []kustomizev1.SubstituteReference{ + { + Kind: "ConfigMap", + Name: configName.Name, + }, + { + Kind: "Secret", + Name: secretName.Name, + }, + }, }, HealthChecks: []meta.NamespacedObjectKindReference{ { @@ -213,6 +249,7 @@ var _ = Describe("KustomizationReconciler", func() { Expect(k8sClient.Get(context.Background(), types.NamespacedName{Name: "test", Namespace: "test"}, sa)).Should(Succeed()) Expect(sa.Labels["environment"]).To(Equal("dev")) Expect(sa.Labels["region"]).To(Equal("eu-central-1")) + Expect(sa.Labels["zone"]).To(Equal("az-1b")) }, Entry("namespace-sa", refTestCase{ artifacts: []testserver.File{ @@ -236,6 +273,7 @@ metadata: labels: environment: ${env:=dev} region: "${region}" + zone: "${zone}" `, }, }, diff --git a/controllers/kustomization_gc.go b/controllers/kustomization_gc.go index cdb8f54c..b17d93e3 100644 --- a/controllers/kustomization_gc.go +++ b/controllers/kustomization_gc.go @@ -144,9 +144,8 @@ func (kgc *KustomizeGarbageCollector) isStale(obj unstructured.Unstructured) boo func (kgc *KustomizeGarbageCollector) shouldSkip(obj unstructured.Unstructured) bool { key := fmt.Sprintf("%s/prune", kustomizev1.GroupVersion.Group) - val := "disabled" - return obj.GetLabels()[key] == val || obj.GetAnnotations()[key] == val + return obj.GetLabels()[key] == kustomizev1.DisabledValue || obj.GetAnnotations()[key] == kustomizev1.DisabledValue } func (kgc *KustomizeGarbageCollector) matchingLabels(name, namespace string) client.MatchingLabels { diff --git a/controllers/kustomization_generator.go b/controllers/kustomization_generator.go index a26fbdaf..8ec0e076 100644 --- a/controllers/kustomization_generator.go +++ b/controllers/kustomization_generator.go @@ -17,6 +17,7 @@ limitations under the License. package controllers import ( + "context" "crypto/sha1" "encoding/json" "fmt" @@ -25,7 +26,7 @@ import ( "path/filepath" "strings" - "github.com/drone/envsubst" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/kustomize/api/filesys" "sigs.k8s.io/kustomize/api/k8sdeps/kunstruct" "sigs.k8s.io/kustomize/api/konfig" @@ -45,18 +46,20 @@ const ( type KustomizeGenerator struct { kustomization kustomizev1.Kustomization + client.Client } -func NewGenerator(kustomization kustomizev1.Kustomization) *KustomizeGenerator { +func NewGenerator(kustomization kustomizev1.Kustomization, kubeClient client.Client) *KustomizeGenerator { return &KustomizeGenerator{ kustomization: kustomization, + Client: kubeClient, } } -func (kg *KustomizeGenerator) WriteFile(dirPath string) (string, error) { +func (kg *KustomizeGenerator) WriteFile(ctx context.Context, dirPath string) (string, error) { kfile := filepath.Join(dirPath, konfig.DefaultKustomizationFileName()) - checksum, err := kg.checksum(dirPath) + checksum, err := kg.checksum(ctx, dirPath) if err != nil { return "", err } @@ -236,7 +239,7 @@ func (kg *KustomizeGenerator) generateKustomization(dirPath string) error { return ioutil.WriteFile(kfile, kd, os.ModePerm) } -func (kg *KustomizeGenerator) checksum(dirPath string) (string, error) { +func (kg *KustomizeGenerator) checksum(ctx context.Context, dirPath string) (string, error) { if err := kg.generateKustomization(dirPath); err != nil { return "", fmt.Errorf("kustomize create failed: %w", err) } @@ -247,15 +250,26 @@ func (kg *KustomizeGenerator) checksum(dirPath string) (string, error) { return "", fmt.Errorf("kustomize build failed: %w", err) } - resources, err := m.AsYaml() - if err != nil { - return "", fmt.Errorf("kustomize build failed: %w", err) + // run variable substitutions + if kg.kustomization.Spec.PostBuild != nil { + for _, res := range m.Resources() { + outRes, err := substituteVariables(ctx, kg.Client, kg.kustomization, res) + if err != nil { + return "", fmt.Errorf("var substitution failed for '%s': %w", res.GetName(), err) + } + + if outRes != nil { + _, err = m.Replace(res) + if err != nil { + return "", err + } + } + } } - // run post-build actions - resources, err = runPostBuildActions(kg.kustomization, resources) + resources, err := m.AsYaml() if err != nil { - return "", fmt.Errorf("post-build actions failed: %w", err) + return "", fmt.Errorf("kustomize build failed: %w", err) } return fmt.Sprintf("%x", sha1.Sum(resources)), nil @@ -340,24 +354,3 @@ func buildKustomization(fs filesys.FileSystem, dirPath string) (resmap.ResMap, e k := krusty.MakeKustomizer(fs, buildOptions) return k.Run(dirPath) } - -// runPostBuildActions runs actions on the multi-doc YAML manifest generated by kustomize build -func runPostBuildActions(kustomization kustomizev1.Kustomization, manifests []byte) ([]byte, error) { - if kustomization.Spec.PostBuild == nil { - return manifests, nil - } - - // run bash variable substitutions - vars := kustomization.Spec.PostBuild.Substitute - if vars != nil && len(vars) > 0 { - output, err := envsubst.Eval(string(manifests), func(s string) string { - return vars[s] - }) - if err != nil { - return nil, fmt.Errorf("variable substitution failed: %w", err) - } - manifests = []byte(output) - } - - return manifests, nil -} diff --git a/controllers/kustomization_varsub.go b/controllers/kustomization_varsub.go new file mode 100644 index 00000000..2052eb90 --- /dev/null +++ b/controllers/kustomization_varsub.go @@ -0,0 +1,90 @@ +package controllers + +import ( + "context" + "fmt" + "strings" + + "github.com/drone/envsubst" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/kustomize/api/resource" + "sigs.k8s.io/yaml" + + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta1" +) + +// substituteVariables replaces the vars with their values in the specified resource. +// If a resource is labeled or annotated with +// 'kustomize.toolkit.fluxcd.io/substitute: disabled' the substitution is skipped. +func substituteVariables( + ctx context.Context, + kubeClient client.Client, + kustomization kustomizev1.Kustomization, + res *resource.Resource) (*resource.Resource, error) { + resData, err := res.AsYAML() + if err != nil { + return nil, err + } + + key := fmt.Sprintf("%s/substitute", kustomizev1.GroupVersion.Group) + + if res.GetLabels()[key] == kustomizev1.DisabledValue || res.GetAnnotations()[key] == kustomizev1.DisabledValue { + return nil, nil + } + + vars := make(map[string]string) + + // load vars from ConfigMaps and Secrets data keys + for _, reference := range kustomization.Spec.PostBuild.SubstituteFrom { + namespacedName := types.NamespacedName{Namespace: kustomization.Namespace, Name: reference.Name} + switch reference.Kind { + case "ConfigMap": + resource := &corev1.ConfigMap{} + if err := kubeClient.Get(ctx, namespacedName, resource); err != nil { + 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) + } + case "Secret": + resource := &corev1.Secret{} + if err := kubeClient.Get(ctx, namespacedName, resource); err != nil { + 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) + } + } + } + + // 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) + } + } + + // run bash variable substitutions + if len(vars) > 0 { + output, err := envsubst.Eval(string(resData), func(s string) string { + return vars[s] + }) + if err != nil { + return nil, fmt.Errorf("variable substitution failed: %w", err) + } + + jsonData, err := yaml.YAMLToJSON([]byte(output)) + if err != nil { + return nil, fmt.Errorf("YAMLToJSON: %w", err) + } + + err = res.UnmarshalJSON(jsonData) + if err != nil { + return nil, fmt.Errorf("UnmarshalJSON: %w", err) + } + } + + return res, nil +} diff --git a/docs/api/kustomize.md b/docs/api/kustomize.md index 83eb1617..d257674c 100644 --- a/docs/api/kustomize.md +++ b/docs/api/kustomize.md @@ -902,6 +902,23 @@ Includes support for bash string replacement functions e.g. ${var:=default}, ${var:position} and ${var/substring/replacement}.
+substituteFrom
SubstituteFrom holds references to ConfigMaps and Secrets containing +the variables and their values to be substituted in the YAML manifests. +The ConfigMap and the Secret data keys represent the var names and they +must match the vars declared in the manifests for the substitution to happen.
++(Appears on: +PostBuild) +
+SubstituteReference contains a reference to a resource containing +the variables name and value.
+Field | +Description | +
---|---|
+kind + +string + + |
+
+ Kind of the values referent, valid values are (‘Secret’, ‘ConfigMap’). + |
+
+name + +string + + |
+
+ Name of the values referent. Should reside in the same namespace as the +referring resource. + |
+
This page was automatically generated with gen-crd-api-reference-docs