diff --git a/cmd/root.go b/cmd/root.go index dcbdd352bb..b0072f9bb7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -556,6 +556,10 @@ var RootCmd = &cobra.Command{ } } + // Check for experimental settings (non-command features gated by config values). + // This extends the experimental check above to cover settings like key_delimiter. + checkExperimentalSettings(&tmpConfig) + // Configure lipgloss color profile based on terminal capabilities. // This ensures tables and styled output degrade gracefully when piped or in non-TTY environments. term := terminal.New() @@ -898,6 +902,56 @@ func findExperimentalParent(cmd *cobra.Command) string { return "" } +// checkExperimentalSettings checks if any experimental settings are enabled in the config +// and applies the same experimental mode handling (silence/warn/error/disable) as commands. +// This extends the experimental system to cover non-command features gated by config values. +func checkExperimentalSettings(atmosConfig *schema.AtmosConfiguration) { + if atmosConfig == nil { + return + } + + // Collect experimental settings that are enabled. + var features []string + if atmosConfig.Settings.YAML.KeyDelimiter != "" { + features = append(features, "settings.yaml.key_delimiter") + } + + if len(features) == 0 { + return + } + + experimentalMode := atmosConfig.Settings.Experimental + if experimentalMode == "" { + experimentalMode = "warn" + } + + for _, feature := range features { + switch experimentalMode { + case "silence": + // Do nothing. + case "disable": + errUtils.CheckErrorPrintAndExit( + errUtils.Build(errUtils.ErrExperimentalDisabled). + WithContext("setting", feature). + WithHint("Enable with settings.experimental: warn"). + Err(), + "", "", + ) + case "warn": + ui.Experimental(feature) + case "error": + ui.Experimental(feature) + errUtils.CheckErrorPrintAndExit( + errUtils.Build(errUtils.ErrExperimentalRequiresIn). + WithContext("setting", feature). + WithHint("Enable with settings.experimental: warn"). + Err(), + "", "", + ) + } + } +} + // flagStyles holds the lipgloss styles for flag rendering. type flagStyles struct { flagStyle lipgloss.Style diff --git a/pkg/datafetcher/schema/atmos/manifest/1.0.json b/pkg/datafetcher/schema/atmos/manifest/1.0.json index 6f37a3403a..ad1b6a0842 100644 --- a/pkg/datafetcher/schema/atmos/manifest/1.0.json +++ b/pkg/datafetcher/schema/atmos/manifest/1.0.json @@ -613,6 +613,16 @@ }, "templates": { "$ref": "#/definitions/templates" + }, + "yaml": { + "type": "object", + "description": "YAML parsing settings", + "properties": { + "key_delimiter": { + "type": "string", + "description": "Delimiter for expanding dotted keys into nested maps (e.g., '.' or '::'). Empty or omitted disables expansion." + } + } } }, "required": [] diff --git a/pkg/datafetcher/schema/config/global/1.0.json b/pkg/datafetcher/schema/config/global/1.0.json index 3ffdbe7b7c..657dc4b260 100644 --- a/pkg/datafetcher/schema/config/global/1.0.json +++ b/pkg/datafetcher/schema/config/global/1.0.json @@ -584,6 +584,16 @@ }, "templates": { "$ref": "#/definitions/templates" + }, + "yaml": { + "type": "object", + "description": "YAML parsing settings", + "properties": { + "key_delimiter": { + "type": "string", + "description": "Delimiter for expanding dotted keys into nested maps (e.g., '.' or '::'). Empty or omitted disables expansion." + } + } } }, "required": [] diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index cbdc56efc2..148001475c 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -347,9 +347,20 @@ type SyntaxHighlighting struct { Wrap bool `yaml:"wrap" json:"wrap" mapstructure:"wrap"` } +// AtmosYAMLSettings controls YAML parsing behavior for stack files. +type AtmosYAMLSettings struct { + // KeyDelimiter enables nested key expansion in stack YAML files. + // When set (e.g., "."), unquoted keys containing the delimiter are expanded + // into nested map structures (e.g., "a.b: v" becomes "a: {b: v}"). + // Quoted keys are preserved as literal keys. + // Empty string (default) disables expansion, preserving current behavior. + KeyDelimiter string `yaml:"key_delimiter" json:"key_delimiter" mapstructure:"key_delimiter"` +} + type AtmosSettings struct { - ListMergeStrategy string `yaml:"list_merge_strategy" json:"list_merge_strategy" mapstructure:"list_merge_strategy"` - Terminal Terminal `yaml:"terminal,omitempty" json:"terminal,omitempty" mapstructure:"terminal"` + ListMergeStrategy string `yaml:"list_merge_strategy" json:"list_merge_strategy" mapstructure:"list_merge_strategy"` + Terminal Terminal `yaml:"terminal,omitempty" json:"terminal,omitempty" mapstructure:"terminal"` + YAML AtmosYAMLSettings `yaml:"yaml,omitempty" json:"yaml,omitempty" mapstructure:"yaml"` // Experimental controls how experimental features are handled. // Values: "silence" (no output), "disable" (disabled), "warn" (default), "error" (exit). Experimental string `yaml:"experimental" json:"experimental" mapstructure:"experimental"` diff --git a/pkg/utils/yaml_utils.go b/pkg/utils/yaml_utils.go index 1a47eb3f65..9572ee30b6 100644 --- a/pkg/utils/yaml_utils.go +++ b/pkg/utils/yaml_utils.go @@ -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) + } + // Process custom tags. if err := processCustomTags(atmosConfig, &parsedNode, file); err != nil { return nil, nil, err diff --git a/pkg/yaml/expand/expand.go b/pkg/yaml/expand/expand.go new file mode 100644 index 0000000000..f50b52e644 --- /dev/null +++ b/pkg/yaml/expand/expand.go @@ -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) + } +} diff --git a/pkg/yaml/expand/expand_test.go b/pkg/yaml/expand/expand_test.go new file mode 100644 index 0000000000..259ee7d4f5 --- /dev/null +++ b/pkg/yaml/expand/expand_test.go @@ -0,0 +1,261 @@ +package expand + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + goyaml "gopkg.in/yaml.v3" +) + +// parseAndExpand is a test helper that parses YAML, expands key delimiters, +// and returns the resulting map. +func parseAndExpand(t *testing.T, yamlStr, delimiter string) map[string]any { + t.Helper() + + var node goyaml.Node + err := goyaml.Unmarshal([]byte(yamlStr), &node) + require.NoError(t, err) + + KeyDelimiters(&node, delimiter) + + var result map[string]any + err = node.Decode(&result) + require.NoError(t, err) + + return result +} + +func TestKeyDelimiters(t *testing.T) { + tests := []struct { + name string + yaml string + delimiter string + check func(t *testing.T, result map[string]any) + }{ + { + name: "no_delimiter_in_keys", + yaml: "a: v", + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + assert.Equal(t, "v", result["a"]) + }, + }, + { + name: "single_dot_unquoted", + yaml: "a.b: v", + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + a, ok := result["a"].(map[string]any) + require.True(t, ok, "expected nested map under 'a'") + assert.Equal(t, "v", a["b"]) + }, + }, + { + name: "multi_level_dots", + yaml: "a.b.c: v", + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + a, ok := result["a"].(map[string]any) + require.True(t, ok) + b, ok := a["b"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "v", b["c"]) + }, + }, + { + name: "quoted_double_preserved", + yaml: `"a.b": v`, + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + // Quoted key stays literal. + assert.Equal(t, "v", result["a.b"]) + assert.Nil(t, result["a"]) + }, + }, + { + name: "quoted_single_preserved", + yaml: `'a.b': v`, + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + assert.Equal(t, "v", result["a.b"]) + assert.Nil(t, result["a"]) + }, + }, + { + name: "mixed_quoted_and_unquoted", + yaml: "a.b: v1\n\"c.d\": v2", + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + // Unquoted expanded. + a, ok := result["a"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "v1", a["b"]) + // Quoted preserved. + assert.Equal(t, "v2", result["c.d"]) + }, + }, + { + name: "same_prefix_multiple_keys", + yaml: "a.b: 1\na.c: 2", + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + a, ok := result["a"].(map[string]any) + require.True(t, ok) + assert.Equal(t, 1, a["b"]) + assert.Equal(t, 2, a["c"]) + }, + }, + { + name: "merge_with_existing_nested", + yaml: "a:\n c: old\na.b: new", + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + a, ok := result["a"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "new", a["b"]) + assert.Equal(t, "old", a["c"]) + }, + }, + { + name: "dotted_wins_conflict", + yaml: "a:\n b: old\na.b: new", + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + a, ok := result["a"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "new", a["b"]) + }, + }, + { + name: "recursive_expansion", + yaml: "p:\n c.k: v", + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + p, ok := result["p"].(map[string]any) + require.True(t, ok) + c, ok := p["c"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "v", c["k"]) + }, + }, + { + name: "list_value", + yaml: "a.b:\n - x\n - y", + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + a, ok := result["a"].(map[string]any) + require.True(t, ok) + b, ok := a["b"].([]any) + require.True(t, ok) + assert.Equal(t, []any{"x", "y"}, b) + }, + }, + { + name: "leading_dot_literal", + yaml: ".a: v", + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + assert.Equal(t, "v", result[".a"]) + }, + }, + { + name: "trailing_dot_literal", + yaml: "a.: v", + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + assert.Equal(t, "v", result["a."]) + }, + }, + { + name: "consecutive_dots_literal", + yaml: "a..b: v", + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + assert.Equal(t, "v", result["a..b"]) + }, + }, + { + name: "custom_delimiter_double_colon", + yaml: "a::b::c: v", + delimiter: "::", + check: func(t *testing.T, result map[string]any) { + a, ok := result["a"].(map[string]any) + require.True(t, ok) + b, ok := a["b"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "v", b["c"]) + }, + }, + { + name: "custom_delimiter_dots_not_expanded", + yaml: "a.b: v", + delimiter: "::", + check: func(t *testing.T, result map[string]any) { + // Dots are literal when delimiter is ::. + assert.Equal(t, "v", result["a.b"]) + }, + }, + { + name: "empty_map", + yaml: "{}", + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + assert.Empty(t, result) + }, + }, + { + name: "map_value_under_dotted_key", + yaml: "a.b:\n c: 1\n d: 2", + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + a, ok := result["a"].(map[string]any) + require.True(t, ok) + b, ok := a["b"].(map[string]any) + require.True(t, ok) + assert.Equal(t, 1, b["c"]) + assert.Equal(t, 2, b["d"]) + }, + }, + { + name: "deeply_nested_parent_with_dotted_child", + yaml: "components:\n terraform:\n vpc:\n metadata.component: vpc-base", + delimiter: ".", + check: func(t *testing.T, result map[string]any) { + components := result["components"].(map[string]any) + terraform := components["terraform"].(map[string]any) + vpc := terraform["vpc"].(map[string]any) + metadata, ok := vpc["metadata"].(map[string]any) + require.True(t, ok, "metadata should be a nested map, not a literal key") + assert.Equal(t, "vpc-base", metadata["component"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseAndExpand(t, tt.yaml, tt.delimiter) + tt.check(t, result) + }) + } +} + +func TestKeyDelimiters_NilNode(t *testing.T) { + // Should not panic. + KeyDelimiters(nil, ".") +} + +func TestKeyDelimiters_EmptyDelimiter(t *testing.T) { + var node goyaml.Node + err := goyaml.Unmarshal([]byte("a.b: v"), &node) + require.NoError(t, err) + + // Empty delimiter = no expansion. + KeyDelimiters(&node, "") + + var result map[string]any + err = node.Decode(&result) + require.NoError(t, err) + + assert.Equal(t, "v", result["a.b"]) +} diff --git a/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden b/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden index e519a8eed9..9a5552e5b6 100644 --- a/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden +++ b/tests/snapshots/TestCLICommands_atmos_describe_config.stdout.golden @@ -118,6 +118,9 @@ "enabled": false } }, + "yaml": { + "key_delimiter": "" + }, "experimental": "warn", "docs": { "max_width": 0, diff --git a/tests/snapshots/TestCLICommands_secrets-masking_describe_config.stdout.golden b/tests/snapshots/TestCLICommands_secrets-masking_describe_config.stdout.golden index abc64e8aa2..8cf423ea4c 100644 --- a/tests/snapshots/TestCLICommands_secrets-masking_describe_config.stdout.golden +++ b/tests/snapshots/TestCLICommands_secrets-masking_describe_config.stdout.golden @@ -126,6 +126,9 @@ ] } }, + "yaml": { + "key_delimiter": "" + }, "experimental": "warn", "docs": { "max_width": 0, diff --git a/website/blog/2026-03-03-yaml-key-delimiter.mdx b/website/blog/2026-03-03-yaml-key-delimiter.mdx new file mode 100644 index 0000000000..2ae1d9f034 --- /dev/null +++ b/website/blog/2026-03-03-yaml-key-delimiter.mdx @@ -0,0 +1,86 @@ +--- +slug: yaml-key-delimiter +title: "YAML Key Delimiter for Dot Notation in Stack Files" +authors: [osterman] +tags: [feature] +--- + +Atmos now supports expanding dotted YAML keys into nested maps in stack configuration files. Enable the `key_delimiter` setting in `atmos.yaml` to use concise dot notation like `metadata.component: vpc-base` instead of deeply nested YAML structures. + + + +## What Changed + +Stack YAML files now support **key delimiter expansion** — a configurable setting that transforms dotted keys into nested maps during YAML parsing. This brings stack files in line with how `atmos.yaml` already handles dotted keys via Viper. + +When enabled, unquoted keys containing the delimiter are automatically expanded: + +```yaml +# Before: deeply nested YAML +components: + terraform: + vpc: + metadata: + component: vpc-base + settings: + spacelift: + workspace_enabled: true + +# After: concise dot notation +components: + terraform: + vpc: + metadata.component: vpc-base + settings.spacelift.workspace_enabled: true +``` + +## Why This Matters + +Infrastructure configurations grow deep fast. Setting a single value like `settings.spacelift.workspace_enabled` previously required creating the full nested hierarchy — four levels of indentation for one boolean. Dot notation eliminates that ceremony while keeping the configuration readable. + +The feature is **opt-in**, **backwards compatible**, and marked as **experimental**. Existing configurations work exactly as before. Enable it only when you want it. The `settings.experimental` mode controls the notification behavior — see [Experimental Features](/cli/configuration/settings/experimental) for details. + +## How to Use It + +Add the `key_delimiter` setting to your `atmos.yaml`: + +```yaml +settings: + yaml: + key_delimiter: "." +``` + +### Quoting as Escape + +Quoted keys are never expanded, so you can mix dot notation with literal dotted key names: + +```yaml +# Expanded: becomes metadata: { component: vpc-base } +metadata.component: vpc-base + +# Literal: stays as "output.json" +"output.json": true +``` + +### Custom Delimiters + +Use any string as the delimiter: + +```yaml +settings: + yaml: + key_delimiter: "::" +``` + +```yaml +metadata::component: vpc-base # Expanded +output.json: true # Literal (dots are not the delimiter) +``` + +## External Tooling + +Dot notation is an Atmos-specific extension to YAML. External tools like IDE linters, `yamllint`, and YAML language servers don't know about key expansion — they see the raw dotted keys and may flag them as schema violations. Atmos validates stack files *after* expansion, so its own validation works correctly. But if your workflow depends on external YAML validation, keep this tradeoff in mind. + +## Get Involved + +Have feedback on this feature? [Open an issue](https://github.com/cloudposse/atmos/issues) or join the conversation in the [Atmos Slack community](https://slack.cloudposse.com). diff --git a/website/docs/cli/configuration/settings/settings.mdx b/website/docs/cli/configuration/settings/settings.mdx index c089fda606..b877a7edac 100644 --- a/website/docs/cli/configuration/settings/settings.mdx +++ b/website/docs/cli/configuration/settings/settings.mdx @@ -9,6 +9,7 @@ slug: /cli/configuration/settings --- import Intro from '@site/src/components/Intro' import File from '@site/src/components/File' +import Experimental from '@site/src/components/Experimental' The `settings` section configures Atmos global settings that affect how stacks are processed, how lists are merged, how output is displayed, and how authentication tokens are injected for private repositories. @@ -22,6 +23,10 @@ settings: # Specifies how lists are merged in Atmos stack manifests list_merge_strategy: replace + # YAML parsing settings + yaml: + key_delimiter: "." + # Terminal settings terminal: max_width: 120 @@ -40,12 +45,45 @@ settings: | Section | Description | |---------|-------------| +| [YAML Settings](#yaml-settings) | YAML parsing behavior, key delimiter for dot notation | | [Experimental](/cli/configuration/settings/experimental) | Control experimental feature behavior | | [Terminal](/cli/configuration/settings/terminal) | Terminal display, color, width, pager, and syntax highlighting | | [Secret Masking](/cli/configuration/settings/mask) | Automatic secret detection and masking in output | | [Markdown Styling](/cli/configuration/settings/markdown-styling) | Customize markdown rendering in terminal | | [Atmos Pro](/cli/configuration/settings/pro) | Atmos Pro integration for stack locking and workspace management | +## YAML Settings + + + +
+
`settings.yaml.key_delimiter`
+
+ Enables nested key expansion in stack YAML files. When set, unquoted YAML keys containing the delimiter are expanded into nested maps. + + For example, with `key_delimiter: "."`, the key `metadata.component: vpc-base` is expanded to `metadata: { component: vpc-base }`. + + Quoted keys (single or double quotes) are never expanded, allowing literal dotted key names when needed. + + **Default:** Empty string (disabled — current behavior preserved). + + ```yaml + settings: + yaml: + key_delimiter: "." + ``` + + **Custom delimiter example:** + ```yaml + settings: + yaml: + key_delimiter: "::" + ``` + + See [YAML Reference: Dot Notation](/reference/yaml#dot-notation-for-nested-keys) for usage examples. +
+
+ ## List Merge Strategy
diff --git a/website/docs/reference/yaml-reference.mdx b/website/docs/reference/yaml-reference.mdx index 2b053c89b7..b4a371a9ea 100644 --- a/website/docs/reference/yaml-reference.mdx +++ b/website/docs/reference/yaml-reference.mdx @@ -9,6 +9,7 @@ slug: /reference/yaml import File from '@site/src/components/File' import Intro from '@site/src/components/Intro' import KeyTakeaways from '@site/src/components/KeyTakeaways' +import Experimental from '@site/src/components/Experimental' YAML is the configuration language for Atmos stacks. Understanding YAML's features, gotchas, and best practices is essential for writing maintainable stack configurations at scale. @@ -53,8 +54,24 @@ components: ## Dot Notation for Nested Keys + + When you only need to set a single value deep in a nested structure, you can use **dot notation** instead of creating the full hierarchy. This makes your configuration more concise and readable. +:::info Configuration Required +Dot notation requires enabling the `key_delimiter` setting in your `atmos.yaml`. Without it, dotted keys are treated as literal key names. + + +```yaml +settings: + yaml: + key_delimiter: "." +``` + + +See [YAML Settings](/cli/configuration/settings#yaml-settings) for details. +::: + ### Traditional Nesting vs Dot Notation **Traditional approach (verbose):** @@ -123,6 +140,52 @@ components: ``` +### Quoting Preserves Literal Keys + +When `key_delimiter` is enabled, you can use YAML quoting (single or double quotes) to prevent expansion. Quoted keys are always treated as literal key names: + + +```yaml +components: + terraform: + vpc: + # Unquoted: expands to metadata: { component: vpc-base } + metadata.component: vpc-base + + # Quoted: stays as literal key "output.json" + "output.json": true + 'config.file': /etc/app.conf +``` + + +This is useful when your key names genuinely contain the delimiter character (e.g., filenames like `output.json`). + +### Custom Delimiters + +You can use any string as the delimiter, not just `.`: + + +```yaml +settings: + yaml: + key_delimiter: "::" +``` + + + +```yaml +components: + terraform: + vpc: + # With "::" delimiter, dots are literal + metadata::component: vpc-base + settings::spacelift::workspace_enabled: true + + # Dotted keys stay as-is (not expanded) + output.json: true +``` + + ### When to Use Dot Notation **✅ Use dot notation when:** @@ -176,6 +239,23 @@ env: ``` +### Edge Cases + +Keys with leading, trailing, or consecutive delimiters are treated as literal (never expanded): + +```yaml +# These are ALL treated as literal keys (not expanded) +.leading: value # Leading delimiter +trailing.: value # Trailing delimiter +double..dot: value # Consecutive delimiters +``` + +:::warning External Tooling +Dot notation is an Atmos-specific feature. External YAML tools — IDE linters, `yamllint`, YAML language servers, and schema validators — will see the unexpanded keys and may report false errors. For example, a linter validating against a JSON Schema that expects `metadata.component` to be a nested object will see `metadata.component` as a single flat key and flag it as invalid. + +If your workflow relies heavily on external YAML validation, consider using traditional nesting for validated sections and dot notation only where convenience outweighs tooling support. +::: + :::tip Best Practice Use dot notation when it improves readability. If you have 2+ values in the same section, traditional nesting is often clearer. ::: diff --git a/website/src/data/roadmap.js b/website/src/data/roadmap.js index 755567e794..f06f0161d9 100644 --- a/website/src/data/roadmap.js +++ b/website/src/data/roadmap.js @@ -215,6 +215,7 @@ export const roadmapConfig = { { label: 'Command aliases for vendor and workflow list', status: 'shipped', quarter: 'q1-2026', changelog: 'vendor-workflow-list-aliases', description: 'Added `atmos vendor list` and `atmos workflow list` as aliases for their `atmos list` counterparts for intuitive command discovery.', benefits: 'Users can use either command form. Natural command structure regardless of preference.' }, { label: 'Packer directory-based templates', status: 'shipped', quarter: 'q1-2026', pr: 1982, changelog: 'packer-directory-based-templates', description: 'Packer commands now default to directory mode, loading all *.pkr.hcl files from the component directory. Aligns with HashiCorp best practices for multi-file configurations.', benefits: 'Organize Packer configurations across multiple files without explicit template configuration. Just run atmos packer build and it works.' }, { label: 'AI Agent Skills (19 domain skills)', status: 'shipped', quarter: 'q1-2026', changelog: 'ai-agent-skills', docs: '/integrations/ai/agent-skills', description: 'Atmos ships 19 domain-specific agent skills covering stacks, components, vendoring, terraform, helmfile, packer, ansible, workflows, custom-commands, auth, stores, schemas, gitops, validation, templates, design-patterns, toolchain, introspection, and devcontainers. Compatible with Claude Code, OpenAI Codex, Gemini CLI, Cursor, Windsurf, GitHub Copilot, and more.', benefits: 'AI coding assistants get deep knowledge of Atmos conventions and patterns. Contributors and users get AI assistance that understands Atmos stack configuration, orchestration, and best practices.' }, + { label: 'YAML key delimiter for dot notation', status: 'shipped', quarter: 'q1-2026', changelog: 'yaml-key-delimiter', docs: '/reference/yaml#dot-notation-for-nested-keys', description: 'Configurable key delimiter expands dotted YAML keys into nested maps in stack files. Quoted keys stay literal. Supports custom delimiters.', benefits: 'Write concise dot notation like metadata.component: vpc-base instead of deeply nested YAML. Opt-in and backwards compatible.', experimental: true }, ], issues: [], prs: [