diff --git a/kustomize/kustomize_varsub.go b/kustomize/kustomize_varsub.go index 699565b7e..5d41196e6 100644 --- a/kustomize/kustomize_varsub.go +++ b/kustomize/kustomize_varsub.go @@ -17,6 +17,7 @@ limitations under the License. package kustomize import ( + "bufio" "context" "errors" "fmt" @@ -231,3 +232,88 @@ func getSubstituteFrom(kustomization unstructured.Unstructured) ([]SubstituteRef return nil, resultErr } + +// SubstituteEnvVariables performs variable substitution on multi-document YAML +// input, skipping resources annotated or labeled with +// kustomize.toolkit.fluxcd.io/substitute: disabled. The mapping function +// resolves variable names to values; it is called for each ${var} reference +// in non-disabled documents. +func SubstituteEnvVariables(data string, mapping func(string) (string, bool)) (string, error) { + chunks, seps := splitYAMLDocuments(data) + + var b strings.Builder + for i, chunk := range chunks { + if i > 0 { + b.WriteString(seps[i-1]) + } + if isSubstituteDisabled(chunk) { + b.WriteString(chunk) + continue + } + out, err := envsubst.Eval(chunk, mapping) + if err != nil { + return "", err + } + b.WriteString(out) + } + return b.String(), nil +} + +// isSubstituteDisabled reports whether a raw YAML document carries the +// kustomize.toolkit.fluxcd.io/substitute: disabled annotation or label. +func isSubstituteDisabled(doc string) bool { + if strings.TrimSpace(doc) == "" { + return false + } + var m struct { + Metadata struct { + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` + } `json:"metadata"` + } + if err := yaml.Unmarshal([]byte(doc), &m); err != nil { + return false + } + return m.Metadata.Labels[substituteAnnotationKey] == DisabledValue || + m.Metadata.Annotations[substituteAnnotationKey] == DisabledValue +} + +// splitYAMLDocuments splits multi-document YAML into content chunks and the +// separator strings between them. A separator is a line that is exactly "---" +// with optional trailing whitespace. The returned slices satisfy +// len(seps) == len(chunks)-1. +func splitYAMLDocuments(data string) (chunks []string, seps []string) { + scanner := bufio.NewScanner(strings.NewReader(data)) + var cur strings.Builder + for scanner.Scan() { + line := scanner.Text() + if isDocSeparator(line) { + chunks = append(chunks, cur.String()) + cur.Reset() + seps = append(seps, line+"\n") + } else { + cur.WriteString(line) + cur.WriteByte('\n') + } + } + trailing := cur.String() + if len(trailing) > 0 && !strings.HasSuffix(data, "\n") { + trailing = strings.TrimSuffix(trailing, "\n") + } + chunks = append(chunks, trailing) + return chunks, seps +} + +// isDocSeparator reports whether line is a YAML document separator, +// i.e. exactly "---" optionally followed by spaces or tabs. +func isDocSeparator(line string) bool { + if !strings.HasPrefix(line, "---") { + return false + } + for _, r := range line[3:] { + if r != ' ' && r != '\t' { + return false + } + } + return true +} diff --git a/kustomize/kustomize_varsub_envsubst_test.go b/kustomize/kustomize_varsub_envsubst_test.go new file mode 100644 index 000000000..31983734d --- /dev/null +++ b/kustomize/kustomize_varsub_envsubst_test.go @@ -0,0 +1,54 @@ +/* +Copyright 2025 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kustomize_test + +import ( + "os" + "testing" + + . "github.com/onsi/gomega" + + "github.com/fluxcd/pkg/kustomize" +) + +func TestSubstituteEnvVariables(t *testing.T) { + g := NewWithT(t) + + t.Setenv("APP_NAME", "myapp") + + input, err := os.ReadFile("./testdata/varsub_env_input.yaml") + g.Expect(err).NotTo(HaveOccurred()) + + expected, err := os.ReadFile("./testdata/varsub_env_expected.yaml") + g.Expect(err).NotTo(HaveOccurred()) + + result, err := kustomize.SubstituteEnvVariables(string(input), os.LookupEnv) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(result).To(Equal(string(expected))) +} + +func TestSubstituteEnvVariables_StrictError(t *testing.T) { + g := NewWithT(t) + + // APP_NAME is not set, so the enabled resource should fail. + input, err := os.ReadFile("./testdata/varsub_env_input.yaml") + g.Expect(err).NotTo(HaveOccurred()) + + _, err = kustomize.SubstituteEnvVariables(string(input), os.LookupEnv) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("variable not set")) +} diff --git a/kustomize/testdata/varsub_env_expected.yaml b/kustomize/testdata/varsub_env_expected.yaml new file mode 100644 index 000000000..52900eccf --- /dev/null +++ b/kustomize/testdata/varsub_env_expected.yaml @@ -0,0 +1,46 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: myapp + namespace: default +data: + region: eu-west-1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard + namespace: monitoring + annotations: + kustomize.toolkit.fluxcd.io/substitute: disabled +data: + dashboard.json: '{"panels": [{"datasource": "${DataSource}"}]}' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: init-scripts + namespace: default + labels: + kustomize.toolkit.fluxcd.io/substitute: disabled +data: + setup.sh: | + #!/bin/bash + process_args() { + echo "First arg: $1" + echo "Second arg: $2" + local name=${1:-default} + local count=${2:-0} + for i in $(seq 1 $count); do + echo "$i: processing $name" + done + } + process_args "$@" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: myapp-config + namespace: default +data: + key: value diff --git a/kustomize/testdata/varsub_env_input.yaml b/kustomize/testdata/varsub_env_input.yaml new file mode 100644 index 000000000..24c41e0d0 --- /dev/null +++ b/kustomize/testdata/varsub_env_input.yaml @@ -0,0 +1,46 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: ${APP_NAME} + namespace: ${APP_NAMESPACE:=default} +data: + region: ${APP_REGION:=eu-west-1} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard + namespace: monitoring + annotations: + kustomize.toolkit.fluxcd.io/substitute: disabled +data: + dashboard.json: '{"panels": [{"datasource": "${DataSource}"}]}' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: init-scripts + namespace: default + labels: + kustomize.toolkit.fluxcd.io/substitute: disabled +data: + setup.sh: | + #!/bin/bash + process_args() { + echo "First arg: $1" + echo "Second arg: $2" + local name=${1:-default} + local count=${2:-0} + for i in $(seq 1 $count); do + echo "$i: processing $name" + done + } + process_args "$@" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: ${APP_NAME}-config + namespace: ${APP_NAMESPACE:=default} +data: + key: value