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 723fb40a..f0917e90 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 --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 --strip-sops-metadata", + 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/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/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..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)) @@ -689,7 +702,9 @@ func maskSopsData(res *resource.Resource) error { // assume that both data and stringdata are encrypted if bytes.Contains(asYaml, []byte("sops:")) && bytes.Contains(asYaml, []byte("mac: ENC[")) { // delete the sops object - res.PipeE(yaml.FieldClearer{Name: "sops"}) + if err := res.PipeE(yaml.FieldClearer{Name: "sops"}); err != nil { + return fmt.Errorf("failed to clear sops field from %s %s: %w", res.GetKind(), res.GetName(), err) + } secretType, err := res.GetFieldValue(typeField) // If the intended type is Opaque, then it can be omitted from the manifest, since it's the default @@ -742,6 +757,19 @@ func maskSopsData(res *resource.Resource) error { return fmt.Errorf("failed to mask secret %s sops data: %w", res.GetName(), err) } } + } 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")) + if err != nil { + return fmt.Errorf("failed to inspect %s %s for top-level sops field: %w", res.GetKind(), res.GetName(), err) + } + + if sopsField != nil { + if err := res.PipeE(yaml.FieldClearer{Name: "sops"}); err != nil { + return fmt.Errorf("failed to strip top-level sops field from %s %s: %w", res.GetKind(), res.GetName(), err) + } + } } return nil diff --git a/internal/build/build_test.go b/internal/build/build_test.go index fa7ffff3..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,13 +164,230 @@ 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) } }) } } +func TestMaskSopsDataNonSecret(t *testing.T) { + testCases := []struct { + name string + yamlStr string + strip bool + expected string + }{ + { + 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 +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] +`, + }, + { + name: "HelmRelease without sops metadata", + strip: true, + 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 +`, + }, + { + name: "ConfigMap with top-level sops and opt-in strip", + strip: true, + 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 +`, + }, + { + 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) { + 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, tc.strip); 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), expected); diff != "" { + t.Errorf("unexpected output (-got +want):\n%v", diff) + } + }) + } +} + func Test_unMarshallKustomization(t *testing.T) { tests := []struct { name string