diff --git a/cmd/flux/build_kustomization_test.go b/cmd/flux/build_kustomization_test.go index 723fb40a6e..8c5577ee0e 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 ee74381674..e7677dd954 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 0000000000..b5110ef78a --- /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 0000000000..91943f83cf --- /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 0000000000..45ea433714 --- /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 0000000000..9cd0e4f5a5 --- /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 0000000000..ebaab2fa85 --- /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 0000000000..01b2de1da0 --- /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 0000000000..d9ae615c3d --- /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 17cfb65ea6..f34ba778eb 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 fa7ffff309..370a85680a 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 diff --git a/plan.md b/plan.md new file mode 100644 index 0000000000..5b438c290e --- /dev/null +++ b/plan.md @@ -0,0 +1,112 @@ +# Plan: SOPS Support for Non-Secret Resources in `flux build/diff kustomization` + +## Problem + +`flux build kustomization` and `flux diff kustomization` call `maskSopsData` on every +resource in the built output before printing it. Before this work, `maskSopsData` only +handled `Secret` resources — it would replace encrypted values with `**SOPS**` or strip +the `.sops` metadata block. + +For non-Secret resources that are encrypted with SOPS (e.g. a `HelmRelease` whose +`spec.values` block is encrypted), the `.sops` top-level metadata block was left in +place. This caused two problems: + +1. **Schema violation at apply time:** The `.sops` field is not part of the HelmRelease + CRD schema. Leaving it in the built output means `flux diff kustomization` (which does + a server-side apply dry-run) fails with a validation error. +2. **Unnecessary exposure:** SOPS metadata (key fingerprints, recipients, encrypted MAC) + should not appear in CLI output. + +A related gap: there was no unit test and no golden-file test covering the +`--decryption-provider` / `--decryption-secret` flags on `create kustomization`. + +--- + +## What Has Been Done + +### 1. Extend `maskSopsData` for non-Secret resources (`internal/build/build.go`) + +Added an `else` branch to `maskSopsData` that handles every resource whose `Kind` is +not `Secret`. When a SOPS `.sops` block with an encrypted MAC (`mac: ENC[…]`) is +detected, it is stripped via `yaml.FieldClearer`. The encrypted field values themselves +(e.g. `ENC[AES256_GCM,…]` ciphertext) are intentionally left intact — they are already +opaque ciphertext, not plaintext, so there is nothing to redact. + +**File:** `internal/build/build.go` — `maskSopsData` function (lines ~745–758) + +### 2. Add `TestMaskSopsDataNonSecret` unit test (`internal/build/build_test.go`) + +Added `TestMaskSopsDataNonSecret` with two table-driven cases: +- `HelmRelease with sops metadata` — verifies that the `.sops` block is stripped and + encrypted values are preserved. +- `HelmRelease without sops metadata` — verifies that a resource without SOPS metadata + passes through unchanged. + +Also fixed a pre-existing broken duplicate test loop that had been left behind by a +prior partial edit. + +**File:** `internal/build/build_test.go` + +### 3. Golden-file test for `create kustomization` with decryption flags (`cmd/flux/`) + +Added a `cmdTestCase` entry to `TestCreateKustomization` that exercises: + +``` +flux create kustomization mysql \ + --source=GitRepository/apps \ + --path=./apps \ + --decryption-provider=sops \ + --decryption-secret=sops-age \ + --namespace=flux-system \ + --interval=1m \ + --export +``` + +And a corresponding golden file verifying the generated `spec.decryption` block. Added +`--interval=1m` explicitly to avoid the `resetCmdArgs()` Cobra flag-state pollution +where a prior test zeroes out the shared `createArgs.interval`. + +**Files:** +- `cmd/flux/create_kustomization_test.go` +- `cmd/flux/testdata/create_kustomization/with-sops-decryption.yaml` + +### 4. Integration test: `flux build kustomization` with SOPS-encrypted HelmRelease + +Added two test cases to `TestBuildLocalKustomization` that run the full `Builder.Build()` +pipeline and verify SOPS metadata is stripped from the output: + +- `build helmrelease with sops metadata` — builds a Kustomization directory containing a + HelmRelease with a `.sops` block; asserts the `.sops` field is absent and the + `ENC[…]` values are preserved. +- `build configmap with sops metadata` — same for a SOPS-encrypted ConfigMap, closing + the ConfigMap test-coverage gap identified in the plan. + +**Files:** +- `cmd/flux/build_kustomization_test.go` +- `cmd/flux/testdata/build-kustomization/sops-helmrelease/kustomization.yaml` +- `cmd/flux/testdata/build-kustomization/sops-helmrelease/helmrelease.yaml` +- `cmd/flux/testdata/build-kustomization/sops-helmrelease-result.yaml` +- `cmd/flux/testdata/build-kustomization/sops-configmap/kustomization.yaml` +- `cmd/flux/testdata/build-kustomization/sops-configmap/configmap.yaml` +- `cmd/flux/testdata/build-kustomization/sops-configmap-result.yaml` + +--- + +## What Still Needs to Be Done + +### Medium priority + +- [ ] **Consider masking encrypted field values for non-Secret resources** + Currently the `ENC[…]` ciphertext values in a HelmRelease are left in the output. + This is intentional (ciphertext ≠ plaintext), but some teams may prefer all SOPS + material to be redacted. A future change could replace `ENC[…]` values with + `**SOPS**` for non-Secret resources as well. + +### Low priority + +- [ ] **Update `flux build kustomization` command documentation / examples** to mention + that SOPS-encrypted HelmRelease resources are handled safely. + +- [ ] **Integration test** (cloud e2e in `tests/integration/`) that provisions a real + cluster with a SOPS-encrypted HelmRelease and verifies that `flux build kustomization` + and `flux diff kustomization` both succeed.