Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions kustomize/kustomize_varsub.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.
package kustomize

import (
"bufio"
"context"
"errors"
"fmt"
Expand Down Expand Up @@ -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
}
54 changes: 54 additions & 0 deletions kustomize/kustomize_varsub_envsubst_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
46 changes: 46 additions & 0 deletions kustomize/testdata/varsub_env_expected.yaml
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions kustomize/testdata/varsub_env_input.yaml
Original file line number Diff line number Diff line change
@@ -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