Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions docs/fixes/2026-06-16-locals-name-template-import-derivation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Stack-name derivation reads vars/settings/env merged across imports

**Issues:** [#2374](https://github.com/cloudposse/atmos/issues/2374), partial
[#2343](https://github.com/cloudposse/atmos/issues/2343)
**Date:** 2026-06-16
**Status:** Fixed (derivation path)

---

## Problem

When `stacks.name_template` references identifying values that live in a parent `_defaults.yaml`
import, and a leaf stack file declares any `locals:` block, the locals pre-pass derived a
**malformed** stack name. Reported symptoms:

- **#2343** — `name_template: "{{ .vars.namespace }}-{{ .vars.stage }}"` with `namespace` in a
parent `_defaults.yaml`: `atmos list stacks` shows `-prod` instead of `acme-prod`; the stack
disappears from listings.
- **#2374** — same shape but `name_template` references `.settings.*`. `describe locals -s <name>`
returns `stack not found`, and `{{ .locals.* }}` renders as `<no value>-...` in component vars.

## Root cause

`deriveStackNameFromTemplate` (`internal/exec/describe_locals.go`) had two defects:

1. **Only `vars` was provided to the template.** The `templateData` map wrapped only
`varsSection`, never `settings` or `env`. So any `name_template` referencing `.settings.*`
(or `.env.*`) could never resolve — the direct cause of **#2374**.
2. **No import merge.** The template was rendered against the leaf file's own sections only. A
`namespace`/`tenant` defined in an imported `_defaults.yaml` was invisible — the cause of
**#2343**'s `-prod` malformed name.

## Fix

### `deriveStackNameFromTemplate` — vars + settings + env, reject `<no value>`

- `templateData` now carries `vars`, `settings`, and `env` (each defaulted to an empty, non-nil
map so `missingkey=default` renders `<no value>` rather than panicking).
- The derivation pre-pass always renders with `ignoreMissingTemplateValues=true` (independent of
the user-facing global flag, which governs the main rendering pipeline). Stack-name derivation
is internal best-effort machinery: a missing identifying value must fall back to the filename,
not abort.
- A rendered name containing `<no value>` is explicitly rejected (falls back to the filename), so
downstream code never sees a malformed identifier.

### `deriveStackNameSections` — lite, YAML-only import overlay

New helper that walks the import graph from the raw config and returns `vars`/`settings`/`env`
merged across the file and all transitively-imported files (imports are the base; the current
file overrides them). It deliberately does **not** process Go templates or YAML functions — it is
a fast, side-effect-free overlay used solely to feed `name_template` during derivation. Unresolvable
import paths and non-pure-YAML files are silently skipped; cycles are broken with a `visited` set.

Supporting helpers: `deriveStackNameSectionsInto`, `mergeImportedStackNameSections`,
`loadAndMergeStackNameImport`, `resolveImportFilePathsForStackName` (+ `hasYAMLExt`,
`findFirstExistingWithExt`, `globMatchesWithExt`), and `mergeMapShallow`.

`deriveStackName` now merges the imported sections (plus the caller's `varsSection` on top) before
trying `name_template` and `name_pattern`.

## Tests

Fixtures (shared with PR 3):

- `tests/fixtures/scenarios/locals-name-template-vars-imports/` (#2343 repro)
- `tests/fixtures/scenarios/locals-name-template-settings-imports/` (#2374 repro)

Integration (`tests/cli_locals_test.go`) — the `describe locals` path, fixed by this PR:

- `TestLocalsNameTemplateVarsFromImportsDescribeLocals`
- `TestLocalsNameTemplateSettingsFromImportsDescribeLocals`

Both verified **failing before** (`stack not found: acme-prod` / `cloudlabs-plat-ue1-prod`) and
passing after.

Unit (`internal/exec/describe_locals_test.go`):

- `TestDeriveStackNameFromTemplate_Sections` — settings/env/combined resolution, `<no value>`
rejection, empty template, nil sections.
- `TestDeriveStackNameFromTemplate_RejectsUnresolvedMarkers`, `..._MalformedTemplate`.
- `TestDeriveStackNameSections` — nil, leaf-only, import-merge with leaf override;
`..._ImportCycle`, `..._InvalidImportSection`.
- `TestStackNameImportHelpers`, `TestResolveImportFilePathsForStackName_Glob`.

All new functions are covered ≥80%.

## Out of scope (PR 3)

The **full `describe stacks` / `describe component` pipeline** still mis-derives these stacks
because imported `vars`/`settings` are clobbered during import recursion when the leaf declares
`locals:` (the `processYAMLConfigFileWithContextInternal` overwrite, fixed via `localsContextResult`).
PR 3 lands that fix plus:

- the `describe-stacks-name-template` site in `resolveStackName`
(`describe_stacks_component_processor.go`) which still hardcodes `false` (a multi-line
`ProcessTmpl` call that PR 1's grep missed — the exact site named by #2345); and
- the full-pipeline integration tests (`...DescribeStacks` / `...DescribeComponent`) that close
#2343 and #2374 end-to-end.
220 changes: 212 additions & 8 deletions internal/exec/describe_locals.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package exec
import (
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"reflect"
Expand Down Expand Up @@ -459,6 +460,17 @@ func deriveStackFileName(atmosConfig *schema.AtmosConfiguration, filePath string
}

// deriveStackName derives the stack name using the same logic as describe stacks.
//
// Stack-name derivation must see the merged view of vars/settings/env across
// imports because `name_template` (and `name_pattern`) commonly reference
// values that live in parent `_defaults.yaml` files. We do a lite, YAML-only
// import walk here -- no template processing, no YAML function resolution --
// so the derivation is fast and side-effect-free even when called repeatedly
// (the locals pre-pass and the main pipeline both reach this).
//
// Regression: GitHub issues #2343 (vars from imports) and #2374 (settings
// from imports). Before the fix, only `varsSection` from the leaf file was
// available, producing malformed names like "-prod" or "<no value>-prod".
func deriveStackName(
atmosConfig *schema.AtmosConfiguration,
stackFileName string,
Expand All @@ -472,13 +484,26 @@ func deriveStackName(
return name
}

// Try name template.
if name := deriveStackNameFromTemplate(atmosConfig, stackFileName, varsSection); name != "" {
// Lite-merge vars/settings/env from imports so name_template (which can
// reference any of them) sees the full picture rather than just the leaf
// file. The current file's sections take precedence over imports.
mergedVars, mergedSettings, mergedEnv := deriveStackNameSections(atmosConfig, stackSectionMap, stackFileName)
// Always include the caller's varsSection on top -- handles the case
// where the caller already merged or pre-processed vars.
for k, v := range varsSection {
if mergedVars == nil {
mergedVars = map[string]any{}
}
mergedVars[k] = v
}

// Try name template using merged sections.
if name := deriveStackNameFromTemplate(atmosConfig, stackFileName, mergedVars, mergedSettings, mergedEnv); name != "" {
return name
}

// Try name pattern.
if name := deriveStackNameFromPattern(atmosConfig, stackFileName, varsSection); name != "" {
// Try name pattern using merged vars.
if name := deriveStackNameFromPattern(atmosConfig, stackFileName, mergedVars); name != "" {
return name
}

Expand All @@ -501,21 +526,46 @@ func getExplicitStackName(stackSectionMap map[string]any) string {

// deriveStackNameFromTemplate derives a stack name using the configured name template.
// Returns empty string if template is not configured or evaluation fails.
//
// TemplateData includes vars, settings, and env so that name_template can
// reference any of them. Pre-fix this only included vars, breaking projects
// that use `name_template: "{{ .settings.* }}"` (GitHub #2374).
//
// The derivation pre-pass always renders with `ignoreMissingTemplateValues=true`
// (independent of the user-facing `templates.settings.ignore_missing_template_values`
// flag, which governs the main rendering pipeline). Stack-name derivation is
// internal best-effort machinery: a missing identifying value must fall back to
// the filename, not abort. We then explicitly reject any rendered name that
// contains `<no value>` so downstream code never sees a malformed identifier
// (GitHub #2343).
func deriveStackNameFromTemplate(
atmosConfig *schema.AtmosConfiguration,
stackFileName string,
varsSection map[string]any,
varsSection, settingsSection, envSection map[string]any,
) string {
if atmosConfig.Stacks.NameTemplate == "" {
return ""
}

// Wrap varsSection in "vars" key to match template syntax: {{ .vars.environment }}.
// Provide vars + settings + env so name_template can reference any of them.
// Empty maps are passed (not nil) so missingkey=default still produces
// "<no value>" rather than panicking on a nil map.
templateData := map[string]any{
"vars": varsSection,
cfg.VarsSectionName: varsSection,
cfg.SettingsSectionName: settingsSection,
cfg.EnvSectionName: envSection,
}
if templateData[cfg.VarsSectionName] == nil {
templateData[cfg.VarsSectionName] = map[string]any{}
}
if templateData[cfg.SettingsSectionName] == nil {
templateData[cfg.SettingsSectionName] = map[string]any{}
}
if templateData[cfg.EnvSectionName] == nil {
templateData[cfg.EnvSectionName] = map[string]any{}
}

stackName, err := ProcessTmpl(atmosConfig, "describe-locals-name-template", atmosConfig.Stacks.NameTemplate, templateData, atmosConfig.Templates.Settings.IgnoreMissingTemplateValues)
stackName, err := ProcessTmpl(atmosConfig, "describe-locals-name-template", atmosConfig.Stacks.NameTemplate, templateData, true)
if err != nil {
log.Debug("Failed to evaluate name template for stack", "file", stackFileName, "error", err)
return ""
Expand All @@ -532,9 +582,163 @@ func deriveStackNameFromTemplate(
return ""
}

// Reject any rendering that came from missing keys -- these are not usable
// as identifiers. Falls back to the filename.
if strings.Contains(stackName, "<no value>") {
log.Debug("Name template result contains <no value>, using filename",
"file", stackFileName, "result", stackName,
"hint", "ensure required vars/settings are defined or imported")
return ""
}

return stackName
}

// deriveStackNameSections walks the import graph starting from the given
// raw config and returns vars/settings/env merged across this file and all
// transitively-imported files. Imports become the base; the current file
// overrides them (matching the main pipeline's merge semantics).
//
// This is a lite, YAML-only overlay used SOLELY to feed `name_template`
// during stack-name derivation. It deliberately does NOT process Go
// templates or YAML functions -- the main pipeline does that later. Any
// import path that can't be resolved is silently skipped: a best-effort
// merge that errs on the side of producing a usable stack name (or
// falling back to the filename) rather than failing.
//
// Cycle detection via the visited set; deterministic top-level (last-wins)
// merge sufficient because name_template typically references scalar
// fields like `vars.namespace`, `settings.tenant`.
//
// Regression: GitHub issues #2343 and #2374. Without this walk, the
// locals pre-pass renders `name_template` against the leaf file alone,
// missing identifying values defined in parent `_defaults.yaml`.
func deriveStackNameSections(
atmosConfig *schema.AtmosConfiguration,
rawConfig map[string]any,
stackFileName string,
) (vars, settings, env map[string]any) {
defer perf.Track(atmosConfig, "exec.deriveStackNameSections")()

if atmosConfig == nil || rawConfig == nil {
return nil, nil, nil
}

visited := make(map[string]bool)

// stackFileName is a logical name relative to the stacks base (no extension).
// Resolve it to a path under the stacks base so cfg.ResolveStackImportFiles can
// resolve `./`-relative imports against the correct directory. Recursive calls
// already pass real imported file paths.
originatingFile := filepath.Join(atmosConfig.BasePath, atmosConfig.Stacks.BasePath, stackFileName)
return deriveStackNameSectionsInto(atmosConfig, rawConfig, originatingFile, visited)
}

// deriveStackNameSectionsInto is the recursive helper for deriveStackNameSections.
// It mutates the visited set to break cycles and is not safe for concurrent use.
func deriveStackNameSectionsInto(
atmosConfig *schema.AtmosConfiguration,
rawConfig map[string]any,
originatingFilePath string,
visited map[string]bool,
) (vars, settings, env map[string]any) {
if rawConfig == nil {
return nil, nil, nil
}

vars = make(map[string]any)
settings = make(map[string]any)
env = make(map[string]any)

// Walk imports DFS, merging their sections as the base.
dest := stackNameSectionMaps{vars: vars, settings: settings, env: env}
mergeImportedStackNameSections(atmosConfig, rawConfig, originatingFilePath, visited, dest)

// Apply current file's sections last so they override imports.
if v, ok := rawConfig[cfg.VarsSectionName].(map[string]any); ok {
maps.Copy(vars, v)
}
if s, ok := rawConfig[cfg.SettingsSectionName].(map[string]any); ok {
maps.Copy(settings, s)
}
if e, ok := rawConfig[cfg.EnvSectionName].(map[string]any); ok {
maps.Copy(env, e)
}
return vars, settings, env
}

// stackNameSectionMaps bundles the three destination maps that
// mergeImportedStackNameSections / loadAndMergeStackNameImport accumulate
// into, keeping the helpers' arg lists below the linter's per-function
// limit.
type stackNameSectionMaps struct {
vars map[string]any
settings map[string]any
env map[string]any
}

// mergeImportedStackNameSections walks imports of the given config and merges
// their vars/settings/env into dest. Each import is processed at most once
// per top-level call (cycle break via visited). Failures (unresolvable path,
// bad YAML) are silently skipped; this is a best-effort overlay used solely
// for stack-name derivation.
//
// Import-path resolution is delegated to cfg.ResolveStackImportFiles, the same
// lite YAML-only resolver used by the auth-defaults pre-pass, so both pre-passes
// resolve imports identically.
func mergeImportedStackNameSections(
atmosConfig *schema.AtmosConfiguration,
rawConfig map[string]any,
originatingFilePath string,
visited map[string]bool,
dest stackNameSectionMaps,
) {
imports, ok := rawConfig[cfg.ImportSectionName].([]any)
if !ok {
return
}

stacksBasePath := filepath.Join(atmosConfig.BasePath, atmosConfig.Stacks.BasePath)
for _, imp := range imports {
for _, p := range cfg.ResolveStackImportFiles(imp, originatingFilePath, stacksBasePath) {
loadAndMergeStackNameImport(atmosConfig, p, visited, dest)
}
}
}

// loadAndMergeStackNameImport loads a single resolved import path, recurses
// into it, and merges its vars/settings/env into dest. A no-op when the path
// was already visited or YAML can't be parsed.
func loadAndMergeStackNameImport(
atmosConfig *schema.AtmosConfiguration,
importedFilePath string,
visited map[string]bool,
dest stackNameSectionMaps,
) {
abs, err := filepath.Abs(importedFilePath)
if err != nil {
abs = importedFilePath
}
if visited[abs] {
return
}
visited[abs] = true

content, err := os.ReadFile(importedFilePath)
if err != nil {
return
}
var importedConfig map[string]any
if err := yaml.Unmarshal(content, &importedConfig); err != nil {
// Likely a .yaml.tmpl file or other non-pure-YAML; skip.
return
}
iv, is, ie := deriveStackNameSectionsInto(atmosConfig, importedConfig, importedFilePath, visited)
maps.Copy(dest.vars, iv)
maps.Copy(dest.settings, is)
maps.Copy(dest.env, ie)
}

// deriveStackNameFromPattern derives a stack name using the configured name pattern.
// Returns empty string if pattern is not configured or evaluation fails.
func deriveStackNameFromPattern(
Expand Down
Loading
Loading