From a485d0ec60400ce9d5c594f3975e54c50ea8f95b Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 27 Apr 2026 06:55:13 -0700 Subject: [PATCH] build/diff: strip SOPS metadata on non-Secrets Signed-off-by: Sebastien Tardif Assisted-by: GitHub Copilot/GPT-5.3-Codex --- cmd/flux/build_kustomization_test.go | 12 ++ cmd/flux/create_kustomization_test.go | 14 ++ .../sops-configmap-result.yaml | 11 ++ .../sops-configmap/configmap.yaml | 22 ++++ .../sops-configmap/kustomization.yaml | 4 + .../sops-helmrelease-result.yaml | 19 +++ .../sops-helmrelease/helmrelease.yaml | 30 +++++ .../sops-helmrelease/kustomization.yaml | 4 + .../with-sops-decryption.yaml | 17 +++ internal/build/build.go | 13 ++ internal/build/build_test.go | 120 ++++++++++++++++++ 11 files changed, 266 insertions(+) create mode 100644 cmd/flux/testdata/build-kustomization/sops-configmap-result.yaml create mode 100644 cmd/flux/testdata/build-kustomization/sops-configmap/configmap.yaml create mode 100644 cmd/flux/testdata/build-kustomization/sops-configmap/kustomization.yaml create mode 100644 cmd/flux/testdata/build-kustomization/sops-helmrelease-result.yaml create mode 100644 cmd/flux/testdata/build-kustomization/sops-helmrelease/helmrelease.yaml create mode 100644 cmd/flux/testdata/build-kustomization/sops-helmrelease/kustomization.yaml create mode 100644 cmd/flux/testdata/create_kustomization/with-sops-decryption.yaml diff --git a/cmd/flux/build_kustomization_test.go b/cmd/flux/build_kustomization_test.go index 723fb40a..8c5577ee 100644 --- a/cmd/flux/build_kustomization_test.go +++ b/cmd/flux/build_kustomization_test.go @@ -211,6 +211,18 @@ spec: resultFile: "./testdata/build-kustomization/podinfo-with-my-app-result.yaml", assertFunc: "assertGoldenTemplateFile", }, + { + name: "build helmrelease with sops metadata", + args: "build kustomization podinfo --kustomization-file " + tmpFile + " --path ./testdata/build-kustomization/sops-helmrelease", + resultFile: "./testdata/build-kustomization/sops-helmrelease-result.yaml", + assertFunc: "assertGoldenTemplateFile", + }, + { + name: "build configmap with sops metadata", + args: "build kustomization podinfo --kustomization-file " + tmpFile + " --path ./testdata/build-kustomization/sops-configmap", + resultFile: "./testdata/build-kustomization/sops-configmap-result.yaml", + assertFunc: "assertGoldenTemplateFile", + }, } tmpl := map[string]string{ diff --git a/cmd/flux/create_kustomization_test.go b/cmd/flux/create_kustomization_test.go index ee743816..e7677dd9 100644 --- a/cmd/flux/create_kustomization_test.go +++ b/cmd/flux/create_kustomization_test.go @@ -34,6 +34,20 @@ func TestCreateKustomization(t *testing.T) { args: "create kustomization my-app --path=./deploy --export", assert: assertError("source is required"), }, + { + // Verify that --decryption-provider and --decryption-secret produce the + // expected Kustomization YAML with a spec.decryption block. + name: "with sops decryption", + args: "create kustomization mysql " + + "--source=GitRepository/apps " + + "--path=./apps " + + "--decryption-provider=sops " + + "--decryption-secret=sops-age " + + "--namespace=flux-system " + + "--interval=1m " + + "--export", + assert: assertGoldenFile("testdata/create_kustomization/with-sops-decryption.yaml"), + }, } for _, tt := range tests { diff --git a/cmd/flux/testdata/build-kustomization/sops-configmap-result.yaml b/cmd/flux/testdata/build-kustomization/sops-configmap-result.yaml new file mode 100644 index 00000000..b5110ef7 --- /dev/null +++ b/cmd/flux/testdata/build-kustomization/sops-configmap-result.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +data: + api-key: ENC[AES256_GCM,data:abc123,iv:xyz,tag:tag,type:str] +kind: ConfigMap +metadata: + labels: + kustomize.toolkit.fluxcd.io/name: podinfo + kustomize.toolkit.fluxcd.io/namespace: {{ .fluxns }} + name: app-config + namespace: default +--- diff --git a/cmd/flux/testdata/build-kustomization/sops-configmap/configmap.yaml b/cmd/flux/testdata/build-kustomization/sops-configmap/configmap.yaml new file mode 100644 index 00000000..91943f83 --- /dev/null +++ b/cmd/flux/testdata/build-kustomization/sops-configmap/configmap.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: app-config + namespace: default +data: + api-key: ENC[AES256_GCM,data:abc123,iv:xyz,tag:tag,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age10la2ge0wtvx3qr7datqf7rs4yngxszdal927fs9rukamr8u2pshsvtz7ce + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + abc + -----END AGE ENCRYPTED FILE----- + lastmodified: "2023-07-15T00:00:00Z" + mac: ENC[AES256_GCM,data:mac,iv:iv,tag:tag,type:str] + encrypted_regex: ^(data)$ + version: 3.7.3 diff --git a/cmd/flux/testdata/build-kustomization/sops-configmap/kustomization.yaml b/cmd/flux/testdata/build-kustomization/sops-configmap/kustomization.yaml new file mode 100644 index 00000000..45ea4337 --- /dev/null +++ b/cmd/flux/testdata/build-kustomization/sops-configmap/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- ./configmap.yaml diff --git a/cmd/flux/testdata/build-kustomization/sops-helmrelease-result.yaml b/cmd/flux/testdata/build-kustomization/sops-helmrelease-result.yaml new file mode 100644 index 00000000..9cd0e4f5 --- /dev/null +++ b/cmd/flux/testdata/build-kustomization/sops-helmrelease-result.yaml @@ -0,0 +1,19 @@ +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + labels: + kustomize.toolkit.fluxcd.io/name: podinfo + kustomize.toolkit.fluxcd.io/namespace: {{ .fluxns }} + name: mysql + namespace: default +spec: + chart: + spec: + chart: mysql + sourceRef: + kind: HelmRepository + name: bitnami + values: + mysql: + rootPassword: ENC[AES256_GCM,data:abc123,iv:xyz,tag:tag,type:str] +--- diff --git a/cmd/flux/testdata/build-kustomization/sops-helmrelease/helmrelease.yaml b/cmd/flux/testdata/build-kustomization/sops-helmrelease/helmrelease.yaml new file mode 100644 index 00000000..ebaab2fa --- /dev/null +++ b/cmd/flux/testdata/build-kustomization/sops-helmrelease/helmrelease.yaml @@ -0,0 +1,30 @@ +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: mysql + namespace: default +spec: + chart: + spec: + chart: mysql + sourceRef: + kind: HelmRepository + name: bitnami + values: + mysql: + rootPassword: ENC[AES256_GCM,data:abc123,iv:xyz,tag:tag,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age10la2ge0wtvx3qr7datqf7rs4yngxszdal927fs9rukamr8u2pshsvtz7ce + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + abc + -----END AGE ENCRYPTED FILE----- + lastmodified: "2023-07-15T00:00:00Z" + mac: ENC[AES256_GCM,data:mac,iv:iv,tag:tag,type:str] + encrypted_regex: ^(values)$ + version: 3.7.3 diff --git a/cmd/flux/testdata/build-kustomization/sops-helmrelease/kustomization.yaml b/cmd/flux/testdata/build-kustomization/sops-helmrelease/kustomization.yaml new file mode 100644 index 00000000..01b2de1d --- /dev/null +++ b/cmd/flux/testdata/build-kustomization/sops-helmrelease/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- ./helmrelease.yaml diff --git a/cmd/flux/testdata/create_kustomization/with-sops-decryption.yaml b/cmd/flux/testdata/create_kustomization/with-sops-decryption.yaml new file mode 100644 index 00000000..d9ae615c --- /dev/null +++ b/cmd/flux/testdata/create_kustomization/with-sops-decryption.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: mysql + namespace: flux-system +spec: + decryption: + provider: sops + secretRef: + name: sops-age + interval: 1m0s + path: ./apps + prune: false + sourceRef: + kind: GitRepository + name: apps diff --git a/internal/build/build.go b/internal/build/build.go index 17cfb65e..f34ba778 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -742,6 +742,19 @@ func maskSopsData(res *resource.Resource) error { return fmt.Errorf("failed to mask secret %s sops data: %w", res.GetName(), err) } } + } else { + // For non-Secret resources (e.g. HelmRelease), strip the top-level .sops metadata + // block so it is not persisted in the cluster or exposed in build/diff output. + // The kustomize-controller decrypts these resources before apply when + // spec.decryption.provider is set; the .sops field is not part of the CRD schema + // and would cause a server-side apply dry-run failure if left in place. + asYaml, err := res.AsYAML() + if err != nil { + return fmt.Errorf("failed to read %s %s for sops check: %w", res.GetKind(), res.GetName(), err) + } + if bytes.Contains(asYaml, []byte("sops:")) && bytes.Contains(asYaml, []byte("mac: ENC[")) { + res.PipeE(yaml.FieldClearer{Name: "sops"}) + } } return nil diff --git a/internal/build/build_test.go b/internal/build/build_test.go index fa7ffff3..370a8568 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -168,6 +168,126 @@ type: kubernetes.io/dockerconfigjson } } +func TestMaskSopsDataNonSecret(t *testing.T) { + testCases := []struct { + name string + yamlStr string + expected string + }{ + { + // A SOPS-encrypted HelmRelease (values block encrypted) must have its + // .sops metadata stripped so it is safe for build/diff output and does + // not cause a server-side apply schema error. + name: "HelmRelease with sops metadata", + yamlStr: `apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: mysql + namespace: default +spec: + chart: + spec: + chart: mysql + sourceRef: + kind: HelmRepository + name: bitnami + values: + mysql: + rootPassword: ENC[AES256_GCM,data:abc123,iv:xyz,tag:tag,type:str] + replicationPassword: ENC[AES256_GCM,data:def456,iv:xyz,tag:tag,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age10la2ge0wtvx3qr7datqf7rs4yngxszdal927fs9rukamr8u2pshsvtz7ce + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + abc + -----END AGE ENCRYPTED FILE----- + lastmodified: "2023-07-15T00:00:00Z" + mac: ENC[AES256_GCM,data:mac,iv:iv,tag:tag,type:str] + encrypted_regex: ^(values)$ + version: 3.7.3 +`, + expected: `apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: mysql + namespace: default +spec: + chart: + spec: + chart: mysql + sourceRef: + kind: HelmRepository + name: bitnami + values: + mysql: + replicationPassword: ENC[AES256_GCM,data:def456,iv:xyz,tag:tag,type:str] + rootPassword: ENC[AES256_GCM,data:abc123,iv:xyz,tag:tag,type:str] +`, + }, + { + // A HelmRelease without any SOPS metadata must pass through unchanged. + name: "HelmRelease without sops metadata", + yamlStr: `apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: podinfo + namespace: default +spec: + chart: + spec: + chart: podinfo + sourceRef: + kind: HelmRepository + name: podinfo + values: + replicaCount: 2 +`, + expected: `apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: podinfo + namespace: default +spec: + chart: + spec: + chart: podinfo + sourceRef: + kind: HelmRepository + name: podinfo + values: + replicaCount: 2 +`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r, err := yaml.Parse(tc.yamlStr) + if err != nil { + t.Fatalf("unable to parse yaml: %v", err) + } + + res := &resource.Resource{RNode: *r} + if err := maskSopsData(res); err != nil { + t.Fatalf("maskSopsData returned unexpected error: %v", err) + } + + got, err := res.AsYAML() + if err != nil { + t.Fatalf("unable to convert resource to yaml: %v", err) + } + if diff := cmp.Diff(string(got), tc.expected); diff != "" { + t.Errorf("unexpected output (-got +want):\n%v", diff) + } + }) + } +} + func Test_unMarshallKustomization(t *testing.T) { tests := []struct { name string