diff --git a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/yaml-update.md b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/yaml-update.md index f9504eec80..e518abd386 100644 --- a/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/yaml-update.md +++ b/docs/docs/50-user-guide/60-reference-docs/30-promotion-steps/yaml-update.md @@ -24,6 +24,43 @@ followed by a [`helm-template` step](helm-template.md). |------|------|-------------| | `commitMessage` | `string` | A description of the change(s) applied by this step. Typically, a subsequent [`git-commit` step](git-commit.md) will reference this output and aggregate this commit message fragment with others like it to build a comprehensive commit message that describes all changes. | + +## Writing Keys + +1. **Nested keys:** +```yaml +image: + tag: v1.0.0 +``` +Update key: `image.tag` + +2. **Keys with literal dots:** + +```yaml +example.com/version: v1.0.0 +``` +Update key: `example\.com/version` + +3. **Sequences/arrays:** +```yaml +containers: + - name: my-app + image: my-app:v1.0 +``` +Update key: `containers.0.image` + +4. **Combination of literal dots and nested maps:** +```yaml +configs: + example.com/feature: + enabled: false +``` + +:::note +Use `\\` to represent a literal backslash in keys. For example, `path\\to.file` +results in the path `["path\to", "file"]`. +::: + ## Examples ### Common Usage diff --git a/pkg/yaml/yaml.go b/pkg/yaml/yaml.go index 00bae5d882..6396522612 100644 --- a/pkg/yaml/yaml.go +++ b/pkg/yaml/yaml.go @@ -28,7 +28,7 @@ type Update struct { } // SetValuesInFile overwrites the specified file with the changes specified by -// the the list of Updates. Keys are of the form ..... +// the list of Updates. Keys are of the form ..... // Integers may be used as keys in cases where a specific node needs to be // selected from a sequence. An error is returned for any attempted update to a // key that does not exist or does not address a scalar node. Importantly, all @@ -79,7 +79,7 @@ func SetValuesInBytes(inBytes []byte, updates []Update) ([]byte, error) { } changesByLine := map[int]change{} for _, update := range updates { - keyPath := strings.Split(update.Key, ".") + keyPath := splitKeyPath(update.Key) line, col, err := findScalarNode(doc, keyPath) if err != nil { return nil, fmt.Errorf("error finding key %s: %w", update.Key, err) @@ -162,3 +162,40 @@ func findScalarNode(node *yaml.Node, keyPath []string) (int, int, error) { } return 0, 0, fmt.Errorf("key path not found") } + +// splitKeyPath splits a key string into path elements for traversal. +// +// Escape sequences: +// - \. → literal dot (not a separator) +// - \\ → literal backslash +// - \x → x (any other escaped char becomes itself) +// +// Examples: +// - "image.tag" → ["image", "tag"] +// - "example\.com/version" → ["example.com/version"] +// - "path\\to.file" → ["path\to", "file"] +func splitKeyPath(key string) []string { + var parts []string + var current strings.Builder + escaped := false + for _, r := range key { + switch { + case escaped: + current.WriteRune(r) + escaped = false + case r == '\\': + escaped = true + case r == '.': + parts = append(parts, current.String()) + current.Reset() + default: + current.WriteRune(r) + } + } + // Trailing backslash is preserved literally + if escaped { + current.WriteRune('\\') + } + parts = append(parts, current.String()) + return parts +} diff --git a/pkg/yaml/yaml_test.go b/pkg/yaml/yaml_test.go index d39d66a4e3..09df38415c 100644 --- a/pkg/yaml/yaml_test.go +++ b/pkg/yaml/yaml_test.go @@ -199,3 +199,153 @@ characters: }) } } + +func TestSplitKeyPath(t *testing.T) { + testCases := []struct { + input string + expected []string + }{ + { + input: "image.tag", + expected: []string{"image", "tag"}, + }, + { + input: "example\\.com/version", + expected: []string{"example.com/version"}, + }, + { + input: "configs.example\\.com/feature", + expected: []string{"configs", "example.com/feature"}, + }, + { + input: "containers.0.image", + expected: []string{"containers", "0", "image"}, + }, + { + input: "foo\\\\bar", + expected: []string{"foo\\bar"}, + }, + { + input: "foo\\\\.bar", + expected: []string{"foo\\", "bar"}, + }, + { + input: "foo\\", + expected: []string{"foo\\"}, + }, + { + input: "foo\\.\\.bar", + expected: []string{"foo..bar"}, + }, + } + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + got := splitKeyPath(tc.input) + require.Equal(t, tc.expected, got) + }) + } +} + +func TestSetValuesInBytesWithEscapedDots(t *testing.T) { + yamlBytes := []byte(` +example.com/version: v1.0.0 +image: + tag: v1.0.0 +configs: + example.com/feature: false +containers: + - name: my-app + image: my-app:v1.0 +`) + updates := []Update{ + { + Key: "example\\.com/version", + Value: "v2.0.0", + }, + { + Key: "image.tag", + Value: "v2.0.0", + }, + { + Key: "configs.example\\.com/feature", + Value: true, + }, + { + Key: "containers.0.image", + Value: "my-app:v2.0.0", + }, + } + + expected := []byte(` +example.com/version: v2.0.0 +image: + tag: v2.0.0 +configs: + example.com/feature: true +containers: + - name: my-app + image: my-app:v2.0.0 +`) + + out, err := SetValuesInBytes(yamlBytes, updates) + require.NoError(t, err) + require.Equal(t, expected, out) +} + +func TestSetValuesInBytesWithEscapedDotNested(t *testing.T) { + yamlBytes := []byte(` +configs: + example.com/feature: + enabled: false +`) + + updates := []Update{ + { + Key: "configs.example\\.com/feature.enabled", + Value: true, + }, + } + + expected := []byte(` +configs: + example.com/feature: + enabled: true +`) + + out, err := SetValuesInBytes(yamlBytes, updates) + require.NoError(t, err) + require.Equal(t, expected, out) +} + +func TestSequenceAndLiteralDotTogether(t *testing.T) { + yamlBytes := []byte(` +services: + - name: api.example.com + port: 8080 + - name: web + port: 80 +`) + + updates := []Update{ + { + Key: "services.0.name", + Value: "api.newdomain.com", + }, + { + Key: "services.0.port", + Value: 9090, + }, + } + + expected := []byte(` +services: + - name: api.newdomain.com + port: 9090 + - name: web + port: 80 +`) + + out, err := SetValuesInBytes(yamlBytes, updates) + require.NoError(t, err) + require.Equal(t, expected, out) +}