Skip to content

Commit 500a333

Browse files
committed
feat: add sans-cluster kustomize varsub
This allows the flux cli `envsubst` to utilize the handling for `kustomize.toolkit.fluxcd.io/substitute` annotation. Previously this module only exposed methods requiring live cluster access. Signed-off-by: Jaakko Sirén <jaakko@craci.com>
1 parent ad86bcd commit 500a333

4 files changed

Lines changed: 232 additions & 0 deletions

File tree

kustomize/kustomize_varsub.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package kustomize
1818

1919
import (
20+
"bufio"
2021
"context"
2122
"errors"
2223
"fmt"
@@ -231,3 +232,88 @@ func getSubstituteFrom(kustomization unstructured.Unstructured) ([]SubstituteRef
231232

232233
return nil, resultErr
233234
}
235+
236+
// SubstituteEnvVariables performs variable substitution on multi-document YAML
237+
// input, skipping resources annotated or labeled with
238+
// kustomize.toolkit.fluxcd.io/substitute: disabled. The mapping function
239+
// resolves variable names to values; it is called for each ${var} reference
240+
// in non-disabled documents.
241+
func SubstituteEnvVariables(data string, mapping func(string) (string, bool)) (string, error) {
242+
chunks, seps := splitYAMLDocuments(data)
243+
244+
var b strings.Builder
245+
for i, chunk := range chunks {
246+
if i > 0 {
247+
b.WriteString(seps[i-1])
248+
}
249+
if isSubstituteDisabled(chunk) {
250+
b.WriteString(chunk)
251+
continue
252+
}
253+
out, err := envsubst.Eval(chunk, mapping)
254+
if err != nil {
255+
return "", err
256+
}
257+
b.WriteString(out)
258+
}
259+
return b.String(), nil
260+
}
261+
262+
// isSubstituteDisabled reports whether a raw YAML document carries the
263+
// kustomize.toolkit.fluxcd.io/substitute: disabled annotation or label.
264+
func isSubstituteDisabled(doc string) bool {
265+
if strings.TrimSpace(doc) == "" {
266+
return false
267+
}
268+
var m struct {
269+
Metadata struct {
270+
Labels map[string]string `json:"labels"`
271+
Annotations map[string]string `json:"annotations"`
272+
} `json:"metadata"`
273+
}
274+
if err := yaml.Unmarshal([]byte(doc), &m); err != nil {
275+
return false
276+
}
277+
return m.Metadata.Labels[substituteAnnotationKey] == DisabledValue ||
278+
m.Metadata.Annotations[substituteAnnotationKey] == DisabledValue
279+
}
280+
281+
// splitYAMLDocuments splits multi-document YAML into content chunks and the
282+
// separator strings between them. A separator is a line that is exactly "---"
283+
// with optional trailing whitespace. The returned slices satisfy
284+
// len(seps) == len(chunks)-1.
285+
func splitYAMLDocuments(data string) (chunks []string, seps []string) {
286+
scanner := bufio.NewScanner(strings.NewReader(data))
287+
var cur strings.Builder
288+
for scanner.Scan() {
289+
line := scanner.Text()
290+
if isDocSeparator(line) {
291+
chunks = append(chunks, cur.String())
292+
cur.Reset()
293+
seps = append(seps, line+"\n")
294+
} else {
295+
cur.WriteString(line)
296+
cur.WriteByte('\n')
297+
}
298+
}
299+
trailing := cur.String()
300+
if len(trailing) > 0 && !strings.HasSuffix(data, "\n") {
301+
trailing = strings.TrimSuffix(trailing, "\n")
302+
}
303+
chunks = append(chunks, trailing)
304+
return chunks, seps
305+
}
306+
307+
// isDocSeparator reports whether line is a YAML document separator,
308+
// i.e. exactly "---" optionally followed by spaces or tabs.
309+
func isDocSeparator(line string) bool {
310+
if !strings.HasPrefix(line, "---") {
311+
return false
312+
}
313+
for _, r := range line[3:] {
314+
if r != ' ' && r != '\t' {
315+
return false
316+
}
317+
}
318+
return true
319+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package kustomize_test
18+
19+
import (
20+
"os"
21+
"testing"
22+
23+
. "github.com/onsi/gomega"
24+
25+
"github.com/fluxcd/pkg/kustomize"
26+
)
27+
28+
func TestSubstituteEnvVariables(t *testing.T) {
29+
g := NewWithT(t)
30+
31+
t.Setenv("APP_NAME", "myapp")
32+
33+
input, err := os.ReadFile("./testdata/varsub_env_input.yaml")
34+
g.Expect(err).NotTo(HaveOccurred())
35+
36+
expected, err := os.ReadFile("./testdata/varsub_env_expected.yaml")
37+
g.Expect(err).NotTo(HaveOccurred())
38+
39+
result, err := kustomize.SubstituteEnvVariables(string(input), os.LookupEnv)
40+
g.Expect(err).NotTo(HaveOccurred())
41+
g.Expect(result).To(Equal(string(expected)))
42+
}
43+
44+
func TestSubstituteEnvVariables_StrictError(t *testing.T) {
45+
g := NewWithT(t)
46+
47+
// APP_NAME is not set, so the enabled resource should fail.
48+
input, err := os.ReadFile("./testdata/varsub_env_input.yaml")
49+
g.Expect(err).NotTo(HaveOccurred())
50+
51+
_, err = kustomize.SubstituteEnvVariables(string(input), os.LookupEnv)
52+
g.Expect(err).To(HaveOccurred())
53+
g.Expect(err.Error()).To(ContainSubstring("variable not set"))
54+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
apiVersion: v1
2+
kind: ConfigMap
3+
metadata:
4+
name: myapp
5+
namespace: default
6+
data:
7+
region: eu-west-1
8+
---
9+
apiVersion: v1
10+
kind: ConfigMap
11+
metadata:
12+
name: grafana-dashboard
13+
namespace: monitoring
14+
annotations:
15+
kustomize.toolkit.fluxcd.io/substitute: disabled
16+
data:
17+
dashboard.json: '{"panels": [{"datasource": "${DataSource}"}]}'
18+
---
19+
apiVersion: v1
20+
kind: ConfigMap
21+
metadata:
22+
name: init-scripts
23+
namespace: default
24+
labels:
25+
kustomize.toolkit.fluxcd.io/substitute: disabled
26+
data:
27+
setup.sh: |
28+
#!/bin/bash
29+
process_args() {
30+
echo "First arg: $1"
31+
echo "Second arg: $2"
32+
local name=${1:-default}
33+
local count=${2:-0}
34+
for i in $(seq 1 $count); do
35+
echo "$i: processing $name"
36+
done
37+
}
38+
process_args "$@"
39+
---
40+
apiVersion: v1
41+
kind: ConfigMap
42+
metadata:
43+
name: myapp-config
44+
namespace: default
45+
data:
46+
key: value
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
apiVersion: v1
2+
kind: ConfigMap
3+
metadata:
4+
name: ${APP_NAME}
5+
namespace: ${APP_NAMESPACE:=default}
6+
data:
7+
region: ${APP_REGION:=eu-west-1}
8+
---
9+
apiVersion: v1
10+
kind: ConfigMap
11+
metadata:
12+
name: grafana-dashboard
13+
namespace: monitoring
14+
annotations:
15+
kustomize.toolkit.fluxcd.io/substitute: disabled
16+
data:
17+
dashboard.json: '{"panels": [{"datasource": "${DataSource}"}]}'
18+
---
19+
apiVersion: v1
20+
kind: ConfigMap
21+
metadata:
22+
name: init-scripts
23+
namespace: default
24+
labels:
25+
kustomize.toolkit.fluxcd.io/substitute: disabled
26+
data:
27+
setup.sh: |
28+
#!/bin/bash
29+
process_args() {
30+
echo "First arg: $1"
31+
echo "Second arg: $2"
32+
local name=${1:-default}
33+
local count=${2:-0}
34+
for i in $(seq 1 $count); do
35+
echo "$i: processing $name"
36+
done
37+
}
38+
process_args "$@"
39+
---
40+
apiVersion: v1
41+
kind: ConfigMap
42+
metadata:
43+
name: ${APP_NAME}-config
44+
namespace: ${APP_NAMESPACE:=default}
45+
data:
46+
key: value

0 commit comments

Comments
 (0)