-
-
Notifications
You must be signed in to change notification settings - Fork 149
feat: YAML key delimiter for dot notation in stack files #2139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ import ( | |
| log "github.com/cloudposse/atmos/pkg/logger" | ||
| "github.com/cloudposse/atmos/pkg/perf" | ||
| "github.com/cloudposse/atmos/pkg/schema" | ||
| "github.com/cloudposse/atmos/pkg/yaml/expand" | ||
| ) | ||
|
|
||
| const ( | ||
|
|
@@ -277,6 +278,12 @@ func parseAndCacheYAML(atmosConfig *schema.AtmosConfiguration, input string, fil | |
| positions = ExtractYAMLPositions(&parsedNode, true) | ||
| } | ||
|
|
||
| // Expand delimited keys (e.g., "a.b: v" -> "a: {b: v}") if configured. | ||
| // This runs before custom tag processing so expanded keys are available for tag resolution. | ||
| if atmosConfig != nil && atmosConfig.Settings.YAML.KeyDelimiter != "" { | ||
| expand.KeyDelimiters(&parsedNode, atmosConfig.Settings.YAML.KeyDelimiter) | ||
| } | ||
|
Comment on lines
+281
to
+285
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cache key must include delimiter-dependent parse behavior. Line 283 makes parse output depend on Suggested direction-func generateParsedYAMLCacheKey(file string, content string) string {
+func generateParsedYAMLCacheKey(file string, content string, keyDelimiter string) string {
@@
- return file + ":" + contentHash
+ return file + ":" + contentHash + ":" + keyDelimiter
}-func getCachedParsedYAML(file string, content string) (*yaml.Node, PositionMap, bool) {
- cacheKey := generateParsedYAMLCacheKey(file, content)
+func getCachedParsedYAML(file string, content string, keyDelimiter string) (*yaml.Node, PositionMap, bool) {
+ cacheKey := generateParsedYAMLCacheKey(file, content, keyDelimiter)-func cacheParsedYAML(file string, content string, node *yaml.Node, positions PositionMap) {
- cacheKey := generateParsedYAMLCacheKey(file, content)
+func cacheParsedYAML(file string, content string, keyDelimiter string, node *yaml.Node, positions PositionMap) {
+ cacheKey := generateParsedYAMLCacheKey(file, content, keyDelimiter)+ keyDelimiter := ""
+ if atmosConfig != nil {
+ keyDelimiter = atmosConfig.Settings.YAML.KeyDelimiter
+ }
@@
- cacheParsedYAML(file, input, &parsedNode, positions)
+ cacheParsedYAML(file, input, keyDelimiter, &parsedNode, positions)🤖 Prompt for AI Agents |
||
|
|
||
| // Process custom tags. | ||
| if err := processCustomTags(atmosConfig, &parsedNode, file); err != nil { | ||
| return nil, nil, err | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| // Package expand provides YAML key delimiter expansion for yaml.Node trees. | ||
| // It walks mapping nodes and expands unquoted keys containing a configurable | ||
| // delimiter into nested map structures, modeled after Viper's deepSearch(). | ||
| package expand | ||
|
|
||
| import ( | ||
| "strings" | ||
|
|
||
| goyaml "gopkg.in/yaml.v3" | ||
|
|
||
| "github.com/cloudposse/atmos/pkg/perf" | ||
| ) | ||
|
|
||
| // KeyDelimiters walks a yaml.Node tree and expands unquoted mapping keys | ||
| // containing the delimiter into nested mapping structures. Quoted keys | ||
| // (single or double) are preserved as literal keys. | ||
| // | ||
| // For example, with delimiter ".", the unquoted key "metadata.component: vpc-base" | ||
| // becomes the nested structure "metadata: { component: vpc-base }". | ||
| // | ||
| // This is modeled after Viper's deepSearch() approach (viper@v1.21.0/util.go). | ||
| func KeyDelimiters(node *goyaml.Node, delimiter string) { | ||
| defer perf.Track(nil, "yaml.expand.KeyDelimiters")() | ||
|
|
||
| if node == nil || delimiter == "" { | ||
| return | ||
| } | ||
|
|
||
| keyDelimitersRecursive(node, delimiter) | ||
| } | ||
|
|
||
| // keyDelimitersRecursive is the recursive implementation. | ||
| // Separated from the public entry point so perf.Track fires only once. | ||
| func keyDelimitersRecursive(node *goyaml.Node, delimiter string) { | ||
| if node == nil { | ||
| return | ||
| } | ||
|
|
||
| switch node.Kind { | ||
| case goyaml.DocumentNode: | ||
| for _, child := range node.Content { | ||
| keyDelimitersRecursive(child, delimiter) | ||
| } | ||
| case goyaml.MappingNode: | ||
| expandMappingKeys(node, delimiter) | ||
| case goyaml.SequenceNode: | ||
| for _, child := range node.Content { | ||
| keyDelimitersRecursive(child, delimiter) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // expandMappingKeys processes a single MappingNode, expanding unquoted delimited keys | ||
| // into nested structures. | ||
| func expandMappingKeys(node *goyaml.Node, delimiter string) { | ||
| // First, recurse into all value nodes so nested maps are expanded bottom-up. | ||
| for i := 1; i < len(node.Content); i += 2 { | ||
| keyDelimitersRecursive(node.Content[i], delimiter) | ||
| } | ||
|
|
||
| // Collect expanded entries: for each expandable key, build nested nodes. | ||
| // Non-expandable keys pass through unchanged. | ||
| var newContent []*goyaml.Node | ||
|
|
||
| for i := 0; i < len(node.Content); i += 2 { | ||
| keyNode := node.Content[i] | ||
| valueNode := node.Content[i+1] | ||
|
|
||
| if shouldExpand(keyNode, delimiter) { | ||
| parts := strings.Split(keyNode.Value, delimiter) | ||
| nested := buildNestedNodes(parts, valueNode) | ||
| // Merge the expanded key-value pair into newContent. | ||
| mergeIntoContent(&newContent, nested[0], nested[1]) | ||
| } else { | ||
| // Non-expandable: merge as-is (handles duplicate keys by last-wins). | ||
| mergeIntoContent(&newContent, keyNode, valueNode) | ||
| } | ||
| } | ||
|
|
||
| node.Content = newContent | ||
| } | ||
|
|
||
| // shouldExpand returns true if the key node should be expanded: | ||
| // - Must be a scalar node. | ||
| // - Must be unquoted (Style == 0). | ||
| // - Must contain the delimiter. | ||
| // - Must not have leading, trailing, or consecutive delimiters. | ||
| func shouldExpand(keyNode *goyaml.Node, delimiter string) bool { | ||
| if keyNode.Kind != goyaml.ScalarNode { | ||
| return false | ||
| } | ||
|
|
||
| // Quoted keys are never expanded. | ||
| if keyNode.Style == goyaml.DoubleQuotedStyle || keyNode.Style == goyaml.SingleQuotedStyle { | ||
| return false | ||
| } | ||
|
|
||
| value := keyNode.Value | ||
| if !strings.Contains(value, delimiter) { | ||
| return false | ||
| } | ||
|
|
||
| // Reject malformed patterns: leading, trailing, or consecutive delimiters. | ||
| if strings.HasPrefix(value, delimiter) || strings.HasSuffix(value, delimiter) { | ||
| return false | ||
| } | ||
| if strings.Contains(value, delimiter+delimiter) { | ||
| return false | ||
| } | ||
|
|
||
| // All parts must be non-empty after splitting. | ||
| parts := strings.Split(value, delimiter) | ||
| for _, part := range parts { | ||
| if part == "" { | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| return true | ||
| } | ||
|
|
||
| // buildNestedNodes creates a chain of nested MappingNodes from the key parts. | ||
| // For parts ["a", "b", "c"] and a value node, it creates: | ||
| // | ||
| // a: { b: { c: value } } | ||
| // | ||
| // Returns [keyNode, valueNode] where valueNode may be a nested MappingNode. | ||
| func buildNestedNodes(parts []string, valueNode *goyaml.Node) [2]*goyaml.Node { | ||
| // Build from innermost to outermost. | ||
| currentValue := valueNode | ||
| for i := len(parts) - 1; i >= 1; i-- { | ||
| innerKey := &goyaml.Node{ | ||
| Kind: goyaml.ScalarNode, | ||
| Tag: "!!str", | ||
| Value: parts[i], | ||
| } | ||
| innerMap := &goyaml.Node{ | ||
| Kind: goyaml.MappingNode, | ||
| Tag: "!!map", | ||
| Content: []*goyaml.Node{innerKey, currentValue}, | ||
| } | ||
| currentValue = innerMap | ||
| } | ||
|
|
||
| outerKey := &goyaml.Node{ | ||
| Kind: goyaml.ScalarNode, | ||
| Tag: "!!str", | ||
| Value: parts[0], | ||
| } | ||
|
|
||
| return [2]*goyaml.Node{outerKey, currentValue} | ||
| } | ||
|
|
||
| // mergeIntoContent adds a key-value pair to the content slice. | ||
| // If the key already exists and both old and new values are MappingNodes, | ||
| // the entries are merged (new entries win on conflict). | ||
| // Otherwise, the old entry is replaced (last-wins semantics). | ||
| func mergeIntoContent(content *[]*goyaml.Node, keyNode, valueNode *goyaml.Node) { | ||
| // Look for an existing key with the same value. | ||
| for i := 0; i < len(*content); i += 2 { | ||
| existingKey := (*content)[i] | ||
| if existingKey.Kind == goyaml.ScalarNode && existingKey.Value == keyNode.Value { | ||
| existingValue := (*content)[i+1] | ||
|
|
||
| // If both are mappings, merge the entries. | ||
| if existingValue.Kind == goyaml.MappingNode && valueNode.Kind == goyaml.MappingNode { | ||
| mergeMappingNodes(existingValue, valueNode) | ||
| return | ||
| } | ||
|
|
||
| // Otherwise, replace (last-wins). | ||
| (*content)[i+1] = valueNode | ||
| return | ||
| } | ||
| } | ||
|
|
||
| // Key not found: append. | ||
| *content = append(*content, keyNode, valueNode) | ||
| } | ||
|
|
||
| // mergeMappingNodes merges entries from src into dst. | ||
| // If a key exists in both and both values are MappingNodes, they are recursively merged. | ||
| // Otherwise, src wins (last-wins). | ||
| func mergeMappingNodes(dst, src *goyaml.Node) { | ||
| for i := 0; i < len(src.Content); i += 2 { | ||
| srcKey := src.Content[i] | ||
| srcValue := src.Content[i+1] | ||
| mergeIntoContent(&dst.Content, srcKey, srcValue) | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.