diff --git a/cmd/flux/build_kustomization.go b/cmd/flux/build_kustomization.go index 5b337ef5..e0a1da30 100644 --- a/cmd/flux/build_kustomization.go +++ b/cmd/flux/build_kustomization.go @@ -68,6 +68,7 @@ type buildKsFlags struct { kustomizationFile string path string ignorePaths []string + stripSopsMetadata bool dryRun bool strictSubst bool recursive bool @@ -81,6 +82,8 @@ func init() { buildKsCmd.Flags().StringVar(&buildKsArgs.path, "path", "", "Path to the manifests location.") buildKsCmd.Flags().StringVar(&buildKsArgs.kustomizationFile, "kustomization-file", "", "Path to the Flux Kustomization YAML file.") buildKsCmd.Flags().StringSliceVar(&buildKsArgs.ignorePaths, "ignore-paths", nil, "set paths to ignore in .gitignore format") + buildKsCmd.Flags().BoolVar(&buildKsArgs.stripSopsMetadata, "strip-sops-metadata", false, + "Strip top-level .sops metadata from non-Secret resources in build output.") buildKsCmd.Flags().BoolVar(&buildKsArgs.dryRun, "dry-run", false, "Dry run mode.") buildKsCmd.Flags().BoolVar(&buildKsArgs.strictSubst, "strict-substitute", false, "When enabled, the post build substitutions will fail if a var without a default value is declared in files but is missing from the input vars.") @@ -128,6 +131,7 @@ func buildKsCmdRun(cmd *cobra.Command, args []string) (err error) { build.WithTimeout(rootArgs.timeout), build.WithKustomizationFile(buildKsArgs.kustomizationFile), build.WithDryRun(buildKsArgs.dryRun), + build.WithStripSopsMetadata(buildKsArgs.stripSopsMetadata), build.WithNamespace(*kubeconfigArgs.Namespace), build.WithIgnore(buildKsArgs.ignorePaths), build.WithStrictSubstitute(buildKsArgs.strictSubst), @@ -140,6 +144,7 @@ func buildKsCmdRun(cmd *cobra.Command, args []string) (err error) { build.WithClientConfig(kubeconfigArgs, kubeclientOptions), build.WithTimeout(rootArgs.timeout), build.WithKustomizationFile(buildKsArgs.kustomizationFile), + build.WithStripSopsMetadata(buildKsArgs.stripSopsMetadata), build.WithIgnore(buildKsArgs.ignorePaths), build.WithStrictSubstitute(buildKsArgs.strictSubst), build.WithRecursive(buildKsArgs.recursive), diff --git a/cmd/flux/build_kustomization_test.go b/cmd/flux/build_kustomization_test.go index 8c5577ee..f0917e90 100644 --- a/cmd/flux/build_kustomization_test.go +++ b/cmd/flux/build_kustomization_test.go @@ -213,13 +213,13 @@ spec: }, { name: "build helmrelease with sops metadata", - args: "build kustomization podinfo --kustomization-file " + tmpFile + " --path ./testdata/build-kustomization/sops-helmrelease", + args: "build kustomization podinfo --kustomization-file " + tmpFile + " --path ./testdata/build-kustomization/sops-helmrelease --strip-sops-metadata", 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", + args: "build kustomization podinfo --kustomization-file " + tmpFile + " --path ./testdata/build-kustomization/sops-configmap --strip-sops-metadata", resultFile: "./testdata/build-kustomization/sops-configmap-result.yaml", assertFunc: "assertGoldenTemplateFile", }, diff --git a/cmd/flux/diff_kustomization.go b/cmd/flux/diff_kustomization.go index 9e1ec770..2b6eabfc 100644 --- a/cmd/flux/diff_kustomization.go +++ b/cmd/flux/diff_kustomization.go @@ -58,6 +58,7 @@ type diffKsFlags struct { kustomizationFile string path string ignorePaths []string + stripSopsMetadata bool progressBar bool strictSubst bool recursive bool @@ -73,6 +74,8 @@ func init() { diffKsCmd.Flags().BoolVar(&diffKsArgs.progressBar, "progress-bar", true, "Boolean to set the progress bar. The default value is true.") diffKsCmd.Flags().StringSliceVar(&diffKsArgs.ignorePaths, "ignore-paths", nil, "set paths to ignore in .gitignore format") diffKsCmd.Flags().StringVar(&diffKsArgs.kustomizationFile, "kustomization-file", "", "Path to the Flux Kustomization YAML file.") + diffKsCmd.Flags().BoolVar(&diffKsArgs.stripSopsMetadata, "strip-sops-metadata", false, + "Strip top-level .sops metadata from non-Secret resources in diff output.") diffKsCmd.Flags().BoolVar(&diffKsArgs.strictSubst, "strict-substitute", false, "When enabled, the post build substitutions will fail if a var without a default value is declared in files but is missing from the input vars.") diffKsCmd.Flags().BoolVarP(&diffKsArgs.recursive, "recursive", "r", false, "Recursively diff Kustomizations") @@ -115,6 +118,7 @@ func diffKsCmdRun(cmd *cobra.Command, args []string) error { build.WithKustomizationFile(diffKsArgs.kustomizationFile), build.WithProgressBar(), build.WithIgnore(diffKsArgs.ignorePaths), + build.WithStripSopsMetadata(diffKsArgs.stripSopsMetadata), build.WithStrictSubstitute(diffKsArgs.strictSubst), build.WithRecursive(diffKsArgs.recursive), build.WithLocalSources(diffKsArgs.localSources), @@ -128,6 +132,7 @@ func diffKsCmdRun(cmd *cobra.Command, args []string) error { build.WithTimeout(rootArgs.timeout), build.WithKustomizationFile(diffKsArgs.kustomizationFile), build.WithIgnore(diffKsArgs.ignorePaths), + build.WithStripSopsMetadata(diffKsArgs.stripSopsMetadata), build.WithStrictSubstitute(diffKsArgs.strictSubst), build.WithRecursive(diffKsArgs.recursive), build.WithLocalSources(diffKsArgs.localSources), diff --git a/internal/build/build.go b/internal/build/build.go index 81f08e27..a7af72f0 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -146,6 +146,9 @@ type Builder struct { strictSubst bool recursive bool localSources map[string]string + // stripSopsMetadata controls whether top-level .sops metadata is stripped + // from non-Secret resources in build/diff output. + stripSopsMetadata bool // diff needs to handle kustomizations one by one, and opt-in to ignore kustomizations missing on cluster singleKustomization bool ignoreNotFound bool @@ -256,6 +259,15 @@ func WithLocalSources(localSources map[string]string) BuilderOptionFunc { } } +// WithStripSopsMetadata enables stripping top-level .sops metadata from +// non-Secret resources in build/diff output. +func WithStripSopsMetadata(strip bool) BuilderOptionFunc { + return func(b *Builder) error { + b.stripSopsMetadata = strip + return nil + } +} + // WithInMemoryBuild sets the in-memory build backend func WithInMemoryBuild(inMemoryBuild bool) BuilderOptionFunc { return func(b *Builder) error { @@ -493,7 +505,7 @@ func (b *Builder) build() (m resmap.ResMap, err error) { } // make sure secrets are masked - err = maskSopsData(res) + err = maskSopsData(res, b.stripSopsMetadata) if err != nil { return } @@ -520,6 +532,7 @@ func (b *Builder) kustomizationBuild(k *kustomizev1.Kustomization) ([]*unstructu WithStrictSubstitute(b.strictSubst), WithRecursive(b.recursive), WithLocalSources(b.localSources), + WithStripSopsMetadata(b.stripSopsMetadata), WithDryRun(b.dryRun), withFsBackend(b.fsBackend), ) @@ -672,7 +685,7 @@ func (b *Builder) setOwnerLabels(res *resource.Resource) error { return nil } -func maskSopsData(res *resource.Resource) error { +func maskSopsData(res *resource.Resource, stripNonSecretSopsMetadata bool) error { // sopsMess is the base64 encoded mask sopsMess := base64.StdEncoding.EncodeToString([]byte(mask)) @@ -744,7 +757,7 @@ func maskSopsData(res *resource.Resource) error { return fmt.Errorf("failed to mask secret %s sops data: %w", res.GetName(), err) } } - } else { + } else if stripNonSecretSopsMetadata { // For non-Secret resources (e.g. HelmRelease), strip top-level .sops metadata // so it is not persisted in the cluster or exposed in build/diff output. sopsField, err := res.Pipe(yaml.Lookup("sops")) diff --git a/internal/build/build_test.go b/internal/build/build_test.go index f8874047..ec20467e 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -146,13 +146,16 @@ type: kubernetes.io/dockerconfigjson for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - r, err := yaml.Parse(tc.yamlStr) + input := strings.ReplaceAll(tc.yamlStr, "\t", " ") + expected := strings.ReplaceAll(tc.expected, "\t", " ") + + r, err := yaml.Parse(input) if err != nil { t.Fatalf("unable to parse yaml: %v", err) } resource := &resource.Resource{RNode: *r} - err = maskSopsData(resource) + err = maskSopsData(resource, true) if err != nil { t.Fatalf("unable to trim sops data: %v", err) } @@ -161,7 +164,7 @@ type: kubernetes.io/dockerconfigjson if err != nil { t.Fatalf("unable to convert sanitized resources to yaml: %v", err) } - if diff := cmp.Diff(string(sYaml), tc.expected); diff != "" { + if diff := cmp.Diff(string(sYaml), expected); diff != "" { t.Errorf("unexpected sanitized resources: (-got +want)%v", diff) } }) @@ -172,133 +175,205 @@ func TestMaskSopsDataNonSecret(t *testing.T) { testCases := []struct { name string yamlStr string + strip bool 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", + name: "HelmRelease with sops metadata and opt-in strip", + strip: true, yamlStr: `apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: - name: mysql - namespace: default + 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] + 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 + 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 + 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] + 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", + name: "HelmRelease without sops metadata", + strip: true, yamlStr: `apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: - name: podinfo - namespace: default + name: podinfo + namespace: default spec: - chart: - spec: - chart: podinfo - sourceRef: - kind: HelmRepository - name: podinfo - values: - replicaCount: 2 + 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 + name: podinfo + namespace: default spec: - chart: - spec: - chart: podinfo - sourceRef: - kind: HelmRepository - name: podinfo - values: - replicaCount: 2 + chart: + spec: + chart: podinfo + sourceRef: + kind: HelmRepository + name: podinfo + values: + replicaCount: 2 `, }, { - // Strip top-level sops metadata whenever present, even if mac is absent. - name: "ConfigMap with top-level sops but no mac", + name: "ConfigMap with top-level sops and opt-in strip", + strip: true, yamlStr: `apiVersion: v1 kind: ConfigMap metadata: - name: app-config - namespace: default + name: app-config + namespace: default data: - values.yaml: | - hello: world + values.yaml: | + hello: world sops: - version: 3.7.0 - encrypted_regex: ^(data)$ + version: 3.7.0 + encrypted_regex: ^(data)$ `, expected: `apiVersion: v1 data: - values.yaml: | - hello: world + values.yaml: | + hello: world kind: ConfigMap metadata: - name: app-config - namespace: default + name: app-config + namespace: default +`, + }, + { + name: "HelmRelease with sops metadata without opt-in strip", + strip: false, + 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: + version: 3.7.3 +`, + expected: `apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: mysql + namespace: default +sops: + version: 3.7.3 +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] +`, + }, + { + name: "ConfigMap with top-level sops without opt-in strip", + strip: false, + yamlStr: `apiVersion: v1 +kind: ConfigMap +metadata: + name: app-config + namespace: default +data: + values.yaml: | + hello: world +sops: + version: 3.7.0 + encrypted_regex: ^(data)$ +`, + expected: `apiVersion: v1 +data: + values.yaml: | + hello: world +kind: ConfigMap +metadata: + name: app-config + namespace: default +sops: + encrypted_regex: ^(data)$ + version: 3.7.0 `, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - r, err := yaml.Parse(tc.yamlStr) + input := strings.ReplaceAll(tc.yamlStr, "\t", " ") + expected := strings.ReplaceAll(tc.expected, "\t", " ") + + r, err := yaml.Parse(input) if err != nil { t.Fatalf("unable to parse yaml: %v", err) } res := &resource.Resource{RNode: *r} - if err := maskSopsData(res); err != nil { + if err := maskSopsData(res, tc.strip); err != nil { t.Fatalf("maskSopsData returned unexpected error: %v", err) } @@ -306,7 +381,7 @@ metadata: if err != nil { t.Fatalf("unable to convert resource to yaml: %v", err) } - if diff := cmp.Diff(string(got), tc.expected); diff != "" { + if diff := cmp.Diff(string(got), expected); diff != "" { t.Errorf("unexpected output (-got +want):\n%v", diff) } })