diff --git a/apis/meta/reference_types.go b/apis/meta/reference_types.go index 8d9244a7a..48a2b0935 100644 --- a/apis/meta/reference_types.go +++ b/apis/meta/reference_types.go @@ -218,6 +218,17 @@ type ValuesReference struct { // transient error will still result in a reconciliation failure. // +optional Optional bool `json:"optional,omitempty"` + + // Literal marks this ValuesReference as a literal value. When set in + // combination with TargetPath, the referenced value is merged at the target + // path without interpreting Helm's `--set` syntax (commas, brackets, dots, + // equal signs, etc.), mirroring the behavior of `helm --set-literal`. This + // is the only safe way to inject arbitrary file content (config files, JSON + // blobs, multi-line strings containing special characters) through + // `valuesFrom`. Has no effect when TargetPath is empty: in that mode the + // referenced value is always YAML-merged at the root. + // +optional + Literal bool `json:"literal,omitempty"` } // GetValuesKey returns the defined ValuesKey, or the default ('values.yaml'). diff --git a/chartutil/values.go b/chartutil/values.go index 5ccbc8acd..1ba84208b 100644 --- a/chartutil/values.go +++ b/chartutil/values.go @@ -236,7 +236,11 @@ func ChartValuesFromReferences(ctx context.Context, log logr.Logger, client kube // TODO(hidde): this is a bit of hack, as it mimics the way the option string is passed // to Helm from a CLI perspective. Given the parser is however not publicly accessible // while it contains all logic around parsing the target path, it is a fair trade-off. - if err := ReplacePathValue(result, ref.TargetPath, string(valuesData)); err != nil { + merger := ReplacePathValue + if ref.Literal { + merger = ReplacePathLiteralValue + } + if err := merger(result, ref.TargetPath, string(valuesData)); err != nil { return nil, NewErrValuesReference(namespacedName, ref, ErrValueMerge, err) } continue @@ -269,3 +273,29 @@ func ReplacePathValue(values common.Values, path string, value string) error { value = path + "=" + value return strvals.ParseInto(value, values) } + +// ReplacePathLiteralValue replaces the value at the dot notation path with the +// given value, treating the value as a literal string. The value is consumed +// verbatim: commas, brackets, braces, equal signs and backslashes that `--set` +// would interpret as syntax are preserved as part of the value. This is the +// only safe way to inject arbitrary file content (config files, JSON blobs, +// multi-line strings containing special characters) at a target path. +// +// Mirrors the behavior of `helm --set-literal` for the value, while keeping +// `\.` escape support in the path (which `helm --set-literal` itself does +// not). Implemented by pre-escaping strvals metacharacters in the value and +// then delegating to strvals.ParseIntoString — that combination yields a +// verbatim value AND escape-aware path parsing. +func ReplacePathLiteralValue(values common.Values, path string, value string) error { + // Order matters: backslash must be escaped first so subsequent escapes + // don't get re-escaped. + escaper := strings.NewReplacer( + `\`, `\\`, + `,`, `\,`, + `[`, `\[`, + `]`, `\]`, + `{`, `\{`, + `}`, `\}`, + ) + return strvals.ParseIntoString(path+"="+escaper.Replace(value), values) +} diff --git a/chartutil/values_test.go b/chartutil/values_test.go index 00e2bd864..4a6c92d0f 100644 --- a/chartutil/values_test.go +++ b/chartutil/values_test.go @@ -279,6 +279,143 @@ invalid`, }, wantErr: true, }, + { + // Documents the bug that Literal exists to work around: a value + // containing helm-set metacharacters (here: a comma inside a YAML + // flow sequence) is interpreted by strvals.ParseInto and corrupts + // the merged value. Same input passes through cleanly in the + // "literal mode" test cases below. + name: "non-literal target path mangles value with commas", + resources: []runtime.Object{ + mockConfigMap("values", map[string]string{ + "application.yml": `endpoints: [a,b,c]`, + }), + }, + references: []meta.ValuesReference{ + { + Kind: kindConfigMap, + Name: "values", + ValuesKey: "application.yml", + TargetPath: `externalConfig.application\.yml.content`, + }, + }, + wantErr: true, + }, + { + name: "literal target path preserves helm-set metacharacters", + resources: []runtime.Object{ + mockConfigMap("values", map[string]string{ + "application.yml": "server:\n port: 8080\nmanagement:\n endpoints:\n web:\n exposure:\n include: [ \"prometheus\", \"health\" ]\n", + }), + }, + references: []meta.ValuesReference{ + { + Kind: kindConfigMap, + Name: "values", + ValuesKey: "application.yml", + TargetPath: `externalConfig.application\.yml.content`, + Literal: true, + }, + }, + want: common.Values{ + "externalConfig": map[string]interface{}{ + "application.yml": map[string]interface{}{ + "content": "server:\n port: 8080\nmanagement:\n endpoints:\n web:\n exposure:\n include: [ \"prometheus\", \"health\" ]\n", + }, + }, + }, + }, + { + name: "literal target path preserves multi-line HOCON with equals and braces", + resources: []runtime.Object{ + mockSecret("values", map[string][]byte{ + "application.conf": []byte("kafka {\n bootstrap = \"b-1:9098,b-2:9098\"\n group = \"my.service\"\n}\n"), + }), + }, + references: []meta.ValuesReference{ + { + Kind: kindSecret, + Name: "values", + ValuesKey: "application.conf", + TargetPath: `externalConfig.application\.conf.content`, + Literal: true, + }, + }, + want: common.Values{ + "externalConfig": map[string]interface{}{ + "application.conf": map[string]interface{}{ + "content": "kafka {\n bootstrap = \"b-1:9098,b-2:9098\"\n group = \"my.service\"\n}\n", + }, + }, + }, + }, + { + name: "literal flag without targetPath is ignored (root YAML merge)", + resources: []runtime.Object{ + mockConfigMap("values", map[string]string{ + "values.yaml": "flat: value\n", + }), + }, + references: []meta.ValuesReference{ + { + Kind: kindConfigMap, + Name: "values", + Literal: true, + }, + }, + want: common.Values{ + "flat": "value", + }, + }, + { + // Regression test for fluxcd/flux2#2625: a Secret carrying a + // comma-separated list of Kafka brokers fails to merge via + // targetPath because strvals interprets the commas as list + // separators and the colons inside `host:port` as key/value + // dividers, yielding `key "net:9092" has no value (cannot + // end with ,)`. Pinned here to document the bug that Literal + // mode resolves. + name: "flux2#2625 non-literal: comma-separated brokers fail to merge", + resources: []runtime.Object{ + mockSecret("kafka", map[string][]byte{ + "brokers": []byte("kafka01.net:9092,kafka02.net:9092,kafka03.net:9092"), + }), + }, + references: []meta.ValuesReference{ + { + Kind: kindSecret, + Name: "kafka", + ValuesKey: "brokers", + TargetPath: "kafka.brokers", + }, + }, + wantErr: true, + }, + { + // fluxcd/flux2#2625 resolved: with Literal: true the same + // comma-separated broker list is preserved verbatim at the + // target path. + name: "flux2#2625 literal: comma-separated brokers preserved verbatim", + resources: []runtime.Object{ + mockSecret("kafka", map[string][]byte{ + "brokers": []byte("kafka01.net:9092,kafka02.net:9092,kafka03.net:9092"), + }), + }, + references: []meta.ValuesReference{ + { + Kind: kindSecret, + Name: "kafka", + ValuesKey: "brokers", + TargetPath: "kafka.brokers", + Literal: true, + }, + }, + want: common.Values{ + "kafka": map[string]interface{}{ + "brokers": "kafka01.net:9092,kafka02.net:9092,kafka03.net:9092", + }, + }, + }, } for _, tt := range tests { @@ -406,6 +543,139 @@ func TestReplacePathValue(t *testing.T) { } } +// TestReplacePathLiteralValue covers the helm `--set-literal` equivalent: +// the value is consumed verbatim, with metacharacters preserved. +func TestReplacePathLiteralValue(t *testing.T) { + tests := []struct { + name string + value []byte + path string + want map[string]interface{} + wantErr bool + }{ + { + name: "simple string", + value: []byte("value"), + path: "outer.inner", + want: map[string]interface{}{ + "outer": map[string]interface{}{ + "inner": "value", + }, + }, + }, + { + name: "value with commas is preserved", + value: []byte("a,b,c"), + path: "name", + want: map[string]interface{}{ + "name": "a,b,c", + }, + }, + { + name: "value with braces is preserved (not parsed as inline list)", + value: []byte("{a,b,c}"), + path: "name", + want: map[string]interface{}{ + "name": "{a,b,c}", + }, + }, + { + name: "value with equals signs is preserved", + value: []byte("foo=bar=baz"), + path: "name", + want: map[string]interface{}{ + "name": "foo=bar=baz", + }, + }, + { + name: "value with brackets is preserved (not parsed as array index)", + value: []byte("endpoints: [a, b, c]"), + path: "config", + want: map[string]interface{}{ + "config": "endpoints: [a, b, c]", + }, + }, + { + name: "multi-line YAML content is preserved verbatim", + value: []byte("server:\n port: 8080\nmanagement:\n endpoints:\n web:\n exposure:\n include: [ \"prometheus\", \"health\" ]\n"), + path: `externalConfig.application\.yml.content`, + want: map[string]interface{}{ + "externalConfig": map[string]interface{}{ + "application.yml": map[string]interface{}{ + "content": "server:\n port: 8080\nmanagement:\n endpoints:\n web:\n exposure:\n include: [ \"prometheus\", \"health\" ]\n", + }, + }, + }, + }, + { + name: "HOCON content with equals and quoted strings is preserved", + value: []byte("kafka {\n bootstrap = \"b-1:9098,b-2:9098\"\n group = \"svc.consumer\"\n}\n"), + path: `externalConfig.application\.conf.content`, + want: map[string]interface{}{ + "externalConfig": map[string]interface{}{ + "application.conf": map[string]interface{}{ + "content": "kafka {\n bootstrap = \"b-1:9098,b-2:9098\"\n group = \"svc.consumer\"\n}\n", + }, + }, + }, + }, + { + name: "JSON string is preserved verbatim (no escape needed)", + value: []byte(`["a","b","c"]`), + path: "subnet_ids", + want: map[string]interface{}{ + "subnet_ids": `["a","b","c"]`, + }, + }, + { + name: "boolean-like string stays a string", + value: []byte("true"), + path: "feature.enabled", + want: map[string]interface{}{ + "feature": map[string]interface{}{ + "enabled": "true", + }, + }, + }, + { + name: "dot escape in path still works", + value: []byte("master"), + path: `nodeSelector.kubernetes\.io/role`, + want: map[string]interface{}{ + "nodeSelector": map[string]interface{}{ + "kubernetes.io/role": "master", + }, + }, + }, + { + // fluxcd/flux2#2625: comma-separated host:port list. Without + // Literal mode strvals parses this as a malformed inline list + // and errors with `key "net:9092" has no value`. + name: "flux2#2625 kafka broker list with host:port and commas", + value: []byte("kafka01.net:9092,kafka02.net:9092,kafka03.net:9092"), + path: "kafka.brokers", + want: map[string]interface{}{ + "kafka": map[string]interface{}{ + "brokers": "kafka01.net:9092,kafka02.net:9092,kafka03.net:9092", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + values := map[string]interface{}{} + err := ReplacePathLiteralValue(values, tt.path, string(tt.value)) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + return + } + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(values).To(Equal(tt.want)) + }) + } +} + func mockSecret(name string, data map[string][]byte) *corev1.Secret { return &corev1.Secret{ TypeMeta: metav1.TypeMeta{