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
9 changes: 9 additions & 0 deletions demo/casts/atmos.d/screengrabs/cli.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,15 @@ commands:
atmos aws eks update-kubeconfig --help
atmos aws security --help
atmos aws security analyze --help
atmos aws cloudformation --help
atmos aws cloudformation render --help
atmos aws cloudformation plan --help
atmos aws cloudformation diff --help
atmos aws cloudformation apply --help
atmos aws cloudformation deploy --help
atmos aws cloudformation delete --help
atmos aws cloudformation validate --help
atmos aws cloudformation output --help
atmos completion --help
atmos describe --help
atmos describe affected --help
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Fix: source provisioner no longer misdetects one-file module directories as template sources

**Date:** 2026-08-31

**Implementation status:** Pending in the documentation-only [PR #3156](https://github.com/cloudposse/atmos/pull/3156).
The implementation and regression tests are in the follow-up [PR #3157](https://github.com/cloudposse/atmos/pull/3157).
This report preserves the development findings and validation of that implementation;
it does not mean the fix is present in the documentation-only revision.

## Summary

`VendorSource`'s single-file detection — added to support bare template URIs like
`source: {uri: https://.../dns.yaml}` — inspected only the shape of the downloaded staging
directory (exactly one entry, not a directory) to decide whether to write the result as a file
or a directory. That heuristic misfired for any Git/S3/archive source whose resolved
subdirectory happened to contain exactly one file, writing a *file* at the target path instead
of a directory. Every downstream consumer that assumes a JIT-provisioned workdir is a directory
then failed.

## Context

Discovered while investigating a persistently-failing CI shard on an unrelated branch
(`osterman/cfn-phase4-migration-graduation`). The failing tests
(`TestJITSource_WorkdirWithLocalComponent`, its `_AllSubcommands` variants,
`TestJITSource_GenerateVarfile`, `TestJITSource_GenerateBackend`,
`TestTerraformOutputJITWorkdirFromSource`) were initially assumed to be a flaky, pre-existing
race condition unrelated to the branch under review — the affected test file had zero diff
against `main`. Re-running the exact same test locally 5/5 times reproduced the failure
deterministically, which ruled out a race and prompted a real root-cause investigation instead
of continuing to report it as an environment flake.

## Proposed implementation (PR #3157)

- `pkg/provisioner/source/vendor.go`: excluded Git, S3, and archive sources from the
`singleFileInDir` single-file heuristic in `VendorSource`. These getters always unpack to a
directory — even a directory containing just one file (e.g.,
`github.com/cloudposse/terraform-null-label//exports`, which only has `context.tf`, or a
module tarball whose sole member is `main.tf`) — so they must never be routed through
`copySingleFileToTarget`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- `pkg/vendor/uri.go`: added `IsArchiveURI`, matching go-getter's own directory-producing
decompressor extensions (`.tar`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`, `.tar.xz`/`.txz`,
`.tar.zst`/`.tzst`, `.zip`). Deliberately excludes the single-compressed-file formats (bare
`.gz`, `.bz2`, `.xz`, `.zst`), which legitimately decompress to exactly one file and must keep
going through the single-file path.
- `pkg/provisioner/source/vendor_test.go`: added
`TestVendorSourceArchiveWithOneFileIsNotMisdetectedAsSingleFileURI`, a regression test that
packages a one-file tarball and asserts the target is a directory, not a file.
- `pkg/vendor/uri_test.go`: added `TestIsArchiveURI` covering the directory-producing and
single-compressed-file extension cases.

## Development validation (implementation revision)

- Reverted the fix locally and confirmed the new regression test fails with the exact
`"...: not a directory"` signature seen in CI, then restored the fix and confirmed it passes.
- `go test ./tests -run 'TestJITSource_WorkdirWithLocalComponent$|TestJITSource_WorkdirWithLocalComponent_AllSubcommands|TestJITSource_GenerateVarfile|TestJITSource_GenerateBackend|TestTerraformOutputJITWorkdirFromSource' -v`: all 5 previously-failing tests now pass.
- `go test ./pkg/provisioner/source/... ./pkg/vendor/...`: pass.
- `atmos lint --changed`: 0 issues.
- Broader local sweep (`TestJITSource*`, `TestVendor*`, `TestCLICommands`) run to check for
regressions: only pre-existing, unrelated failures remained
(`atmos_vendor_pull#01`/`atmos_vendor_pull_oci`, both blocked by this sandbox having no network
route to `ghcr.io`'s anonymous OCI auth — confirmed via the captured `UNAUTHORIZED` error, not
caused by this change).

## Follow-ups

None.
164 changes: 164 additions & 0 deletions docs/fixes/2026-09-09-cfn-apply-publish-only-gating-and-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Fix: `aws/cloudformation apply` no longer crashes on a publish-only target, and gains a friendlier "stack not found" error

**Date:** 2026-09-09

**Implementation status:** Pending in the documentation-only [PR #3156](https://github.com/cloudposse/atmos/pull/3156).
The implementation and regression tests are in the follow-up [PR #3157](https://github.com/cloudposse/atmos/pull/3157).
This report preserves the development findings and validation of that implementation;
it does not mean the fix is present in the documentation-only revision.

## Summary

`atmos aws cfn apply <component> -s <stack> --target <publish-only-target>` crashed with a raw,
unwrapped AWS SDK error (`ValidationError: Stack [...] does not exist`) when the named stack was
absent and the selected provision target did not deploy a stack directly — a `kind: aws/s3` target selected via
`--target`, or any other non-`aws/cloudformation` target kind (e.g. `kind: git`). `runApply`
unconditionally ran the stack-policy, termination-protection, and outputs follow-up steps after
`deliverApply` returned, regardless of whether the direct CloudFormation path had returned a
result without error. Fixed by gating those three follow-up steps behind `deliverApply`'s
`*changeSetResult` return value.
Separately, a user explicitly asked for friendlier error messages after hitting the raw AWS error
verbatim; a narrowly-scoped helper now recognizes AWS's "stack does not exist" validation-error
shape and adds an explanation + actionable hint via the error builder, reusing this package's
existing `isStackNotFoundError` pattern-recognition helper.

## Context

`pkg/component/aws/cloudformation/provision.go`'s `deliverApply` resolves the selected provision
target for an `apply` and has three possible outcomes:

1. `kind: aws/cloudformation` (the default/implicit target) — uses `deployDirect` and its
changeset flow, returning a non-nil `*changeSetResult` on success, including a no-op.
2. `kind: aws/s3` selected explicitly (e.g. `--target artifacts`) — publish-only: uploads the
template to S3 and returns immediately with `result == nil`. No stack is created or touched.
3. Any other kind (e.g. `kind: git`) — packages through S3, then delivers via the generic target
registry (`target.Deliver`), also never touching a direct stack. Returns `result == nil`.

`pkg/component/aws/cloudformation/executor.go`'s `runApply` called `setStackPolicy`,
`applyTerminationProtection`, and `describeStackOutputs` unconditionally after `deliverApply`
returned without error — regardless of which of the three outcomes had occurred. For outcomes 2
and 3, the target does not deploy the stack directly. If the named stack is absent, the
stack-scoped calls fail; if an unrelated stack with that name already exists, policy or
termination-protection calls may change its settings, and output lookup may return its outputs.
The confirmed live repro: a component with
`termination_protection: true` and an `aws/s3` provision target named `artifacts`, never yet
deployed as a direct stack, running `atmos aws cfn apply <component> -s <stack> --target
artifacts`, hit:

```text
Error: aws/cloudformation API call failed: operation error CloudFormation: UpdateTerminationProtection, https response error
StatusCode: 400, ... api error ValidationError: Stack [<name>] does not exist
```

This error is a raw AWS SDK error string with no explanation or actionable next step, following
this package's dominant (but not universal) `fmt.Errorf("%w: %w", errUtils.ErrAwsCloudFormationAPICallFailed, err)`
wrapping pattern — contrasted with the curated errors elsewhere in the same package (e.g.
`delete.go`'s termination-protection/retain-resources gates) that use the full error-builder
pattern with an explanation and hint.

## Changes

**Proposed implementation (PR #3157).**

**Bug 1 (primary) — `pkg/component/aws/cloudformation/executor.go`, `runApply` (~line 397-448):**

After the error check, `result != nil` means the direct CloudFormation path returned a result
without an error, including when the operation was a no-op. It does not prove that a stack
deploy occurred. Verified by reading `deliverApply`, `deployDirect`, `createChangeSet`, and
`waitForChangeSet` in full:

- `deliverApply` only ever returns a non-nil `result` from the `selected.Kind ==
cfg.CloudFormationComponentType` branch (`deployDirect`'s outcome); both other branches
(`aws/s3` publish-only, and the generic external-target delivery) explicitly return `nil` for
`result`.
- `deployDirect` returns the non-nil result from `createChangeSet` immediately when
`result.NoOp` is true, without executing the changeset. Otherwise it executes the changeset
and waits for the stack operation to complete. Both successful paths return a non-nil result;
failures return a non-nil error.
- `runApply` already checks `if err != nil { return summary, err }` *before* looking at `result`,
so by the time the code reaches the `result` check, any path that could produce `(nil, non-nil
err)` has already returned. At this point, `result != nil` identifies a direct CloudFormation
result without an error, including a no-op — no separate boolean needed.

`runApply` now returns immediately after merging `deliverApply`'s summary when `result == nil`,
skipping `setStackPolicy`, `applyTerminationProtection`, and `describeStackOutputs` entirely for
the publish-only and external-target outcomes. The direct-deploy path's behavior (all three
follow-up steps still run, in the same order) is unchanged.

**Bug 2 (secondary, narrowly scoped) — new helper `wrapAPICallError` in
`pkg/component/aws/cloudformation/changeset.go`, applied at 3 call sites:**

Rather than rewrapping every `fmt.Errorf("%w: %w", errUtils.ErrAwsCloudFormationAPICallFailed,
err)` call site in the package (a much larger effort spanning `executor.go`, `validate.go`,
`delete.go`, `stackset.go`, `backend.go`, `drift.go`, `get.go`, `list.go`, `observability.go` —
out of scope here and a real regression risk), this fix adds one small, targeted helper next to
the package's existing `isStackNotFoundError` pattern-recognition function (already used by
`changeset.go`, `changeset_verbs.go`, and `events.go` to special-case this exact AWS error shape):

```go
func wrapAPICallError(stackName string, err error) error {
if !isStackNotFoundError(err) {
return fmt.Errorf(wrapFmt, errUtils.ErrAwsCloudFormationAPICallFailed, err)
}
return errUtils.Build(errUtils.ErrAwsCloudFormationAPICallFailed).
WithCause(err).
WithExplanationf("Stack %q doesn't exist yet.", stackName).
WithHint("Check `--target`/`-s`/component name, or run `apply` without `--target` first if you need to create the stack directly.").
Err()
}
```

`WithCause` preserves `errors.Is` matching against both the sentinel and the original AWS error,
per the error builder's documented contract. Unrecognized AWS error shapes fall through to the
existing plain wrap unchanged — this deliberately avoids inventing a hint for error shapes this
fix hasn't specifically verified.

Applied at the 3 call sites most directly implicated by the confirmed bug (all three are
stack-scoped follow-up calls that can legitimately target a non-existent stack):

- `pkg/component/aws/cloudformation/validate.go`, `setStackPolicy`
- `pkg/component/aws/cloudformation/validate.go`, `applyTerminationProtection`
- `pkg/component/aws/cloudformation/output.go`, `describeStackOutputs` (also used by the
standalone `output` verb, not just `runApply`'s end-of-deploy summary)

`output.go` no longer needs the `fmt` or `errUtils` imports after this change and had both
removed.

## Validation

The documentation-only correction was checked against PR #3157 commit `a2feba08b` and the
CloudFormation CLI configuration reference. The fix-log validator, MDX parser, and
`git diff --check` passed. No implementation tests were rerun for this prose-only correction.

**Development validation (implementation revision).**

- `go build ./...` — passes.
- `go vet ./pkg/component/aws/cloudformation/...` — passes.
- `go test ./pkg/component/aws/cloudformation/...` — passes (full package, including new tests).
- New/updated tests in `pkg/component/aws/cloudformation/executor_test.go` and
`pkg/component/aws/cloudformation/changeset_test.go`:
- `TestRunApply_PublishOnlyTarget_SkipsPostDeploySteps` — regression test for Bug 1. Uses a
`MockCloudFormationClient` with **zero** `EXPECT()` calls set, so any
`UpdateTerminationProtection`/`SetStackPolicy`/`DescribeStacks` call fails the test outright.
The `stackSpec` sets both `TerminationProtection: true` and a non-empty `StackPolicyBody` to
prove the gate actively skips those steps rather than merely having nothing to do.
- `TestRunApply_Success`, `TestRunApply_SetsStackPolicy`, `TestRunApply_TerminationProtectionError`,
`TestRunApply_DescribeOutputsError` (pre-existing, unmodified) — continue to pass unchanged,
proving the direct-deploy path still runs all three follow-up steps exactly as before.
- `TestWrapAPICallError_StackNotFound` — asserts the wrapped error matches both
`errUtils.ErrAwsCloudFormationAPICallFailed` and the original AWS error via `errors.Is`, and
carries a hint (via `cockroachdb/errors.GetAllHints`) mentioning `--target` and `apply`.
- `TestWrapAPICallError_OtherError_PlainWrap` — asserts an unrecognized AWS error still matches
the sentinel and original error, but carries **no** hint.
- `atmos lint --changed` (via `GOTOOLCHAIN=go1.26.6 ./custom-gcl run --config=.golangci.yml
--allow-serial-runners --new-from-rev=origin/main`, working around a known Homebrew-Go/go.mod
toolchain mismatch in this environment): zero findings on any file touched by this fix. Three
pre-existing `dupl` findings surfaced in `executor_test.go` (among three untouched, already
near-identical `TestRunApply_*Error` tests) and one unrelated `godot` finding in
`internal/exec/yaml_func_terraform_output.go` — both pre-date this change (confirmed via `git
diff origin/main`, which shows the whole `executor_test.go` file as new relative to `origin/main`
since this feature branch hasn't merged yet) and are out of this fix's scope.

## Follow-ups

None.
72 changes: 72 additions & 0 deletions docs/fixes/2026-09-09-cfn-base-path-empty-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Fix: `aws/cloudformation` base path no longer collapses to the project root when unset

**Date:** 2026-09-09

**Implementation status:** Pending in the documentation-only [PR #3156](https://github.com/cloudposse/atmos/pull/3156).
The implementation and regression tests are in the follow-up [PR #3157](https://github.com/cloudposse/atmos/pull/3157).
This report preserves the development findings and validation of that implementation;
it does not mean the fix is present in the documentation-only revision.

## Summary

`pkg/config/config.go`'s absolute-path resolution for `aws/cloudformation` components joined
`atmosConfig.Components.CloudFormation.BasePath` onto the base path with no fallback for the Go
zero-value (empty string). Because every pre-existing `atmos.yaml` never declares a
`components."aws/cloudformation"` section, `BasePath` was always empty in practice, and the join
collapsed to the bare repository root instead of the documented default
`components/cloudformation/`. Atmos then looked for `<repo-root>/<component>/template.yaml`
instead of `<repo-root>/components/cloudformation/<component>/template.yaml`, producing a
confusing "no such file or directory" error instead of working out of the box.

## Context

Found via a real-AWS field test against an external, pre-existing repo (infra-live) that had
never configured `components."aws/cloudformation"` — the scenario every existing atmos.yaml is
actually in, since that section did not exist before CloudFormation support was added. The exact
same bug shape was already found and fixed for `components.container.base_path` a few lines above
this block in the same function (`AtmosConfigAbsolutePaths`), with an explicit defensive default
and an explanatory comment. `aws/cloudformation` was added later and missed the same treatment.

A correct, independent fallback already existed in
`pkg/component/aws/cloudformation/config.go` (`DefaultConfig()`, returning
`Config{BasePath: "components/cloudformation"}`) and is already consumed by
`ComponentProvider.GetBasePath()` in `pkg/component/aws/cloudformation/cloudformation.go` — but
neither is on the path that `pkg/config/config.go` uses to compute
`CloudFormationDirAbsolutePath`, so that resolution had no fallback at all.

## Proposed implementation (PR #3157)

- `pkg/config/config.go` (`AtmosConfigAbsolutePaths`): before joining
`atmosConfig.Components.CloudFormation.BasePath` into an absolute path, default it to
`"components/cloudformation"` when empty, mirroring the Container fix's style and comment
tone. Switched the `filepath.Abs` call to the same `absPathOrError` helper the Container and
Vendor/Workflows blocks already use, for consistent error wrapping.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- `pkg/config/config_test.go` (`TestAtmosConfigAbsolutePaths`): added two sub-tests mirroring the
existing Vendor/Workflows empty-base-path sub-test —
`computes cloudformation absolute path from empty base_path default` (asserts
`CloudFormationDirAbsolutePath` resolves to `<root>/components/cloudformation` and that the
in-place default is applied) and `computes cloudformation absolute path from explicit base_path
override` (asserts an explicit `BasePath` is respected and not clobbered by the default).

Verified no other call site double-applies or conflicts with this default:
`pkg/component/aws/cloudformation/cloudformation.go`'s `GetBasePath()` is an independent consumer
with its own empty-check fallback to `DefaultConfig()`, not reached from
`AtmosConfigAbsolutePaths`. Other readers of `Components.CloudFormation.BasePath` (e.g.
`pkg/utils/component_path_utils.go`, `internal/exec/describe_affected_changed_files_index.go`,
`internal/exec/describe_stacks.go`) run after `AtmosConfigAbsolutePaths` has already defaulted
the field in place, exactly as they already do for `Components.Container.BasePath`.

## Development validation (implementation revision)

- `go build ./...` — passes.
- `go test ./pkg/config/...` — passes, including the two new sub-tests and the full existing
suite (no regressions).
- `GOTOOLCHAIN=go1.26.6 ./custom-gcl run --config=.golangci.yml --new-from-rev=origin/main
pkg/config/...` — 0 issues on the changed lines. (The repo-wide `atmos lint --changed`
currently fails on an unrelated, pre-existing typecheck error in
`pkg/component/aws/cloudformation/delete.go`, which is mid-edit in a separate, concurrent fix
and out of scope here.)

## Follow-ups

None.
Loading
Loading