Hierarchical imports: context values referencing earlier-import vars break when passed to a .tmpl import (regression since v1.223.0)
- Dominant language
- Go
- Stars
- 1.4k
- Forks
- 175
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 134
Description
### Describe the Bug
Per the ["Referencing Earlier Imports in Import Paths"](https://atmos.tools/stacks/imports) docs, import processing's template data is supposed to include the `settings`, `vars`, and `env` defined by imports listed earlier in the same manifest. This works for values used directly in an import `path:`, but it's broken for values inside an import's `context:` block when that import points at a `.tmpl` file — the documented, supported pattern from ["Hierarchical Imports with Context"](https://atmos.tools/stacks/imports).
The root cause turned out to be two separate bugs stacked on top of each other, in `internal/exec/stack_processor_utils.go`:
**1. `context:` values are never rendered against earlier-import vars.**
`importStruct.Context` is merged into the propagated import context completely unrendered ([stack_processor_utils.go:1580](https://github.com/cloudposse/atmos/blob/main/internal/exec/stack_processor_utils.go#L1580)). There's no equivalent of `renderImportPath` ([lines 1849-1907](https://github.com/cloudposse/atmos/blob/main/internal/exec/stack_processor_utils.go#L1849-L1907)) — which already renders the import *path* string against the `settings`/`vars`/`env` accumulated from earlier imports — for the `context:` block's own values. So a value like `cluster_domain: "{{ .vars.root_domain }}"` is passed down to the child `.tmpl` file completely un-rendered.
**2. A still-templated value gets pasted into the child file and executed a second time, in the wrong scope.**
When the child `.tmpl` file's own content plainly references that context key (e.g. `{{ .cluster_domain }}`), `processStructuredTemplateRefs` ([lines 360-386](https://github.com/cloudposse/atmos/blob/main/internal/exec/stack_processor_utils.go#L360-L386)) does a literal map-value substitution rather than full template execution — it pastes whatever raw string is in the context map directly into the child's YAML tree, `{{ }}` characters and all. That tree is then re-serialized and handed to `ProcessTmpl` for a real execution pass, which now finds and executes the *freshly reintroduced* `{{ .vars.root_domain }}` syntax — but against the child's narrow context (only the explicit `context:` keys), which has no `vars` key. Hence:
```
Error: invalid stack manifest: template: catalog/child.yaml.tmpl:N:N: executing "catalog/child.yaml.tmpl"
at <.vars.X>: map has no entry for key "vars"
```
The error is misattributed to a line in the *child* `.tmpl` file, even though that file never references `.vars` itself.
Bug 2 is the more fundamental one: `processStructuredTemplateRefs` is a general-purpose helper also used to resolve a manifest's own `locals`/`settings`/`vars`/`env` sections, so the same leak is reachable through more than just import `context:` blocks — anywhere a "resolved" value is itself still a templated string (e.g. a var that references another var whose resolution was deferred to a later phase, common with credential/node-config chains sourced from a separately-generated credentials file).
This affects both plain string context values (`"{{ .vars.X }}"`) and `!template`-tagged values (`!template "{{ toJson .vars.X }}"`) — same underlying failure, since both go through the same context-rendering step.
Note: this is **not** [#2778](https://github.com/cloudposse/atmos/issues/2778) (a `!template[...]` whitespace tag-matching bug, fixed in #2779/#2780, released in v1.223.1-rc.4). This reproduces even with plain string context values that don't use the `!template` tag at all.
### Expected Behavior
A `context:` value that references `.vars.X`, where `X` is set by an import listed earlier in the same manifest, should render correctly against the parent's accumulated vars before being passed into the child `.tmpl` import — matching the documented behavior for import-path templating. And a value that can't be fully resolved yet (because it depends on another var whose own resolution was deferred) should be left alone for later resolution, not partially baked into content that gets re-executed in the wrong scope.
### Steps to Reproduce
**Case 1 — direct reference to an earlier-import var:**
`stacks/vars.yml`:
```yaml
vars:
root_domain: example.com
```
`stacks/main.yml`:
```yaml
import:
- vars.yml
- path: catalog/child.yaml.tmpl
context:
cluster_domain: "{{ .vars.root_domain }}"
```
`stacks/catalog/child.yaml.tmpl`:
```yaml
components:
terraform:
example:
vars:
domain: "{{ .cluster_domain }}"
```
Run:
```
atmos describe component example -s main
```
Expected: `vars.domain` resolves to `example.com`.
Actual:
```
Error: invalid stack manifest: template: catalog/child.yaml.tmpl:5:16: executing "catalog/child.yaml.tmpl"
at <.cluster_domain>: map has no entry for key "vars"
```
**Case 2 — a nested/deferred reference (var-references-var across sibling imports), the pattern that exercises bug 2 specifically:**
`stacks/tenants/_credentials.yml`:
```yaml
vars:
node_secret: s3cr3t
```
`stacks/tenants/_settings.yml`:
```yaml
vars:
cluster_nodes:
node1:
secret: "{{ .vars.node_secret }}"
```
`stacks/tenants/main.yml`:
```yaml
import:
- tenants/_credentials.yml
- tenants/_settings.yml
- path: catalog/cluster.yaml.tmpl
context:
nodes_json: !template "{{ toJson .vars.cluster_nodes }}"
vars:
stage: main
```
`stacks/catalog/cluster.yaml.tmpl`:
```yaml
components:
terraform:
cluster:
vars:
nodes: "{{ .nodes_json }}"
```
Run:
```
atmos describe component cluster -s main
```
Expected: `vars.nodes.node1.secret` resolves to `s3cr3t`.
Actual: same class of error (`map has no entry for key "vars"`) even though `node_secret` genuinely is defined by an earlier import — because `_settings.yml`'s *own* content-rendering pass never sees sibling imports' vars (a separate, narrower channel than the one that would fix case 1 alone), so `cluster_nodes.node1.secret` stays literally `"{{ .vars.node_secret }}"` all the way through, and gets executed a second time when `toJson`-serialized into the child's context.
(exact line/col and error text will vary slightly by atmos version)
### Screenshots
N/A (text error output included above)
### Environment
- Atmos version: reproduced on v1.223.0-1 and v1.225.0-1 (apt packages, `dl.cloudsmith.io/public/cloudposse/packages/deb`) and on a from-source build of `cloudposse/atmos` `main` HEAD as of 2026-08-17
- Does NOT reproduce on: v1.222.0-1 (last known-good version)
- OS/Arch: Linux amd64 (Debian trixie)
- Regression window: introduced between v1.222.0-1 (good) and v1.223.0-1 (bad) — v1.223.0-1 is the very first stable release after v1.222.0-1 on the cloudposse apt channel (no intermediate patch), so it's already broken there. This is as tight as package-level bisection allows without a source-level `git bisect` between the `v1.222.0`/`v1.223.0` tags.
### Additional Context
I have a working fix for both parts, validated against a real ~20-import-chain, multi-hundred-variable production stack (not just the minimal repros above):
1. Added a `renderImportContext` function, mirroring `renderImportPath`, that renders an import's `context:` values against the `settings`/`vars`/`env` accumulated from earlier imports before merging them into the propagated context. A value that fails to render (e.g. references a var not yet available) is left as-is rather than erroring — matching how this file already treats a manifest's own settings/vars/env sections when their template dependencies aren't available yet (see the `originalContextProvided` fallback around [line 1150](https://github.com/cloudposse/atmos/blob/main/internal/exec/stack_processor_utils.go#L1150)).
2. In `processStructuredTemplateRefs`, skip substitution when the resolved value is itself still a templated string (contains `{{`) — leave the original `{{ .field }}` reference in place so the next full-template render pass handles it as a normal, single-execution field lookup (which just prints the string verbatim, template-looking characters and all) instead of re-parsing leaked text as source code in the wrong scope.
Validation against the real repo:
- `atmos validate stacks` passes clean across the entire repo (previously failed on multiple tenant manifests)
- `atmos describe component -s ` for the previously-failing components now resolves with zero leftover `{{` anywhere in output, including nested credential values pulled through 2+ levels of var-references-var (confirmed with `templates.settings.evaluations: 3`, needed for the final templating phase to fully resolve multi-hop chains — matches this repo's existing `atmos.yaml`)
- Full `internal/exec` Go test suite passes (only pre-existing, unrelated failures remain, from a missing `terraform` binary in the sandbox used to build)
- `go build ./...` clean across the whole module
We've pinned to v1.222.0-1 as a workaround in the affected repo in the meantime.
Disclaimer: Fix and Bug report written with the help of AI.
Contributor guide
Research direction
Start in internal/exec/stack_processor_utils.go, reading renderImportPath, the import-context handling around line 1580, and processStructuredTemplateRefs. Run the internal/exec Go tests plus the two stack reproductions with atmos validate stacks and atmos describe component. Done means earlier-import vars render correctly in .tmpl import context, deferred templated values are not re-executed in the wrong scope, and go build ./... passes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- cli, devops
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 25/100