Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
f3586d4
test(container): cover combined buildx driver/cache/tags/context args
osterman Aug 5, 2026
2bedf69
fix(git): tolerate config errors for CI git-clone bootstrap pre-Cobra
osterman Aug 5, 2026
915f7c9
docs(fixes): record CI git-clone bootstrap profile fix
osterman Aug 5, 2026
6428e34
fix(git): parse CI bootstrap flags with real pflag arity, not a heuri…
osterman Aug 5, 2026
14c9abc
docs(fixes): update CI git-clone bootstrap fix record for pflag rewrite
osterman Aug 5, 2026
a7b992b
fix(schema): decode with: into Build/Run/Push/Inspect for custom comm…
osterman Aug 5, 2026
28e59ac
fix(workdir): sanitize nested component names in BuildPath
osterman Aug 5, 2026
3c3b864
docs(fixes): record custom-command with: and workdir path-depth fixes
osterman Aug 5, 2026
bf74e17
fix(terraform/output): retarget containment-guard test at stack trave…
osterman Aug 5, 2026
056b183
fix(terraform/output): strip ANSI before asserting cache-hit visibili…
osterman Aug 6, 2026
17efc2a
chore(claude): deny gofmt in Claude Code permissions
osterman Aug 6, 2026
427985e
test: close Codecov patch-coverage gaps on PR #2879
osterman Aug 6, 2026
c3ccf2d
docs(fixes): record terraform/output CI fixes; correct gofmt->gofumpt…
osterman Aug 6, 2026
c8e5fed
Merge remote-tracking branch 'origin/main' into osterman/test-contain…
osterman Aug 6, 2026
6577f9e
test(registry): widen timing margin in provider-mirror concurrency test
osterman Aug 7, 2026
f807b4d
fix(workdir): sanitize nested component names in createWorkdirDirectory
osterman Aug 7, 2026
6688f7b
fix(provisioner): guard path traversal in source-vendoring fallback
osterman Aug 7, 2026
a332d5e
fix(cli): decode and execute custom-command step-level container: ove…
osterman Aug 7, 2026
4befe0f
fix(container): pass restart/healthcheck through to ephemeral run steps
osterman Aug 7, 2026
5ad33c1
fix(config): surface swallowed import errors and hide-nothing validation
osterman Aug 7, 2026
3431325
fix(security): remediate 7 Dependabot alerts in website dependencies
osterman Aug 7, 2026
a3ca9e0
fix(schema): reject unknown fields in container step with:/driver: bl…
osterman Aug 8, 2026
96c1e1d
fix(security): remediate 5 Dependabot alerts, 2 unpatched and deferred
osterman Aug 10, 2026
1548c31
Merge remote-tracking branch 'origin/main' into osterman/test-contain…
osterman Aug 10, 2026
4b73560
Merge remote-tracking branch 'origin/main' into osterman/test-contain…
osterman Aug 12, 2026
e69be9f
fix(cmd): update stale path assertions in container build argv test
osterman Aug 12, 2026
cad624c
Merge remote-tracking branch 'origin/main' into osterman/test-contain…
osterman Aug 12, 2026
f96b4a8
fix(provisioner): pass OutputWriters in nested-workdir Provision test
osterman Aug 12, 2026
80653c6
test(cmd): assert cache/driver flag values, not just presence
osterman Aug 13, 2026
b17cfa3
fix(cmd): normalize marker path for cross-platform shell command
osterman Aug 13, 2026
101b59e
fix(provisioner): sanitize backslash in workdir component names
osterman Aug 13, 2026
4fbcaaf
test(runner): make step-name assertion independent, cover empty type
osterman Aug 13, 2026
25336e8
fix(schema): reject unknown fields in container: override blocks
osterman Aug 13, 2026
ab0bc4e
docs(fixtures): sync workdir-nested fixture with path-safety fixes
osterman Aug 13, 2026
8c69210
docs(fixes): correct formatter name, markdown syntax, typos, spelling
osterman Aug 13, 2026
d4d1646
docs(fixes): correct macOS spelling in CI platform list
osterman Aug 13, 2026
b5868b4
fix(provisioner): use filepath.Rel for component base path containment
osterman Aug 13, 2026
e049953
fix(schema): wrap WorkflowContainer JSON decode error
osterman Aug 13, 2026
16179fe
docs(fixtures): sync stack-manifest comments with path-safety fixes
osterman Aug 13, 2026
b2bbbf7
fix(provisioner,cmd): close symlink containment gap and route script …
osterman Aug 14, 2026
f9d1f32
fix(cmd): propagate Cobra cancellation to custom-command step execution
osterman Aug 14, 2026
3057f22
docs(fixes): add missing comma after "e.g." in fix-log doc
osterman Aug 14, 2026
193dfbe
fix(cmd,schema): address CodeRabbit findings on PR #2879
osterman Aug 14, 2026
ade24e6
Merge remote-tracking branch 'origin/main' into osterman/test-contain…
osterman Aug 14, 2026
61e816f
docs(workdir): document BuildPath's separator sanitization in its doc…
osterman Aug 14, 2026
8171ac6
test(cmd): add regression test for RootCmd.Commands() restoration
osterman Aug 14, 2026
7095b2c
fix(workdir,schema): close BuildPath collision/traversal gaps, stop m…
osterman Aug 14, 2026
f310e93
docs(fixes): fix EditorConfig indentation in workdir fix-log doc
osterman Aug 14, 2026
a7b8ad1
fix(workdir,cmd): make BuildPath's encoding fully injective, restore …
osterman Aug 15, 2026
903639a
test: update hardcoded workdir names for BuildPath's injective encoding
osterman Aug 15, 2026
640fa26
fix(workdir): give backslash its own escape token, route CleanWorkdir…
osterman Aug 16, 2026
26dd2f4
fix(workdir): handle filesystem-root basePath, de-tautologize workdir…
osterman Aug 17, 2026
5840877
Merge branch 'main' into osterman/test-container-fields-ignored
osterman Aug 17, 2026
ff9b9aa
test: cover BuildPath's error-propagation branches across workdir con…
osterman Aug 17, 2026
2244339
fix(workdir,cmd,tests): contain stack traversal to component-type roo…
osterman Aug 17, 2026
8c7606c
fix(workdir,cmd/git): address PR #2879 CodeRabbit round and fix local…
osterman Aug 17, 2026
2eda097
fix(workdir): reject only '.'/'..' segments in stack, not every '/'
osterman Aug 17, 2026
41fd4f5
refactor(cmd): extract shared container-override step helper
osterman Aug 17, 2026
92eb2fa
fix(cmd,workdir,terraform): address PR #2879 CodeRabbit round 3 findings
osterman Aug 17, 2026
1460d6c
fix(cmd): make workdir clean/describe/show honor atmos_component over…
osterman Aug 18, 2026
78d9b85
fix(cmd,provisioner): address PR #2879 CodeRabbit round 4 findings
osterman Aug 18, 2026
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
1 change: 1 addition & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"Bash(git reset --hard:*)",
"Bash(git clean:*)",
"Bash(go clean:*)",
"Bash(gofmt:*)",
"Bash(git add -A:*)",
"Bash(git add --all:*)",
"Bash(git add .:*)",
Expand Down
117 changes: 89 additions & 28 deletions cmd/cmd_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import (
"github.com/cloudposse/atmos/pkg/ui"
u "github.com/cloudposse/atmos/pkg/utils"
"github.com/cloudposse/atmos/pkg/version"
workflowPkg "github.com/cloudposse/atmos/pkg/workflow"
)

//go:embed markdown/getting_started.md
Expand Down Expand Up @@ -1280,15 +1281,85 @@ func executeCustomCommand(
commandResult, runErr = stepPkg.ExecuteCommandResult(step.Name, run)
return runErr
}
// Use cmd.Context() so cancellation (e.g. Ctrl-C on the top-level Cobra invocation)
// propagates into step execution and container runtime operations;
// context.Background() would let them run to completion after the user has already
// cancelled. cmd.Context() is nil only when this command is invoked directly in tests
// without going through Cobra's Execute().
executionCtx := cmd.Context()
if executionCtx == nil {
executionCtx = context.Background()
}
// runExtendedStep converts step to a schema.WorkflowStep and routes it through the
// registered pkg/runner/step handlers (used for genuinely-extended step types like
// input/confirm/choose, and for "script" steps with no active container override).
runExtendedStep := func(workflowStep schema.WorkflowStep) error {
// Carry env onto the step so handlers that read step.Env (e.g. the
// container handler's in-container env) see it. The step's own
// declared `env:` had its map keys lowercased by Viper, so restore
// the original case from the shared env case map, then merge it over
// the resolved command/process env (step vars win on collisions).
stepOwnEnv := workflowStep.Env
if atmosConfig.CaseMaps != nil {
stepOwnEnv = atmosConfig.CaseMaps.ApplyCase("env", stepOwnEnv)
}
mergedStepEnv := envpkg.SliceToMap(env)
for key, value := range stepOwnEnv {
mergedStepEnv[key] = value
}
workflowStep.Env = mergedStepEnv
workflowStep.WorkingDirectory = stepWorkDir

if stack, ok := flagsData["stack"].(string); ok && stack != "" {
executor.SetFlag("stack", stack)
}

// Execute the extended step.
_, execErr := executor.Execute(executionCtx, &workflowStep)
return execErr
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
// runContainerOverrideStep routes a step-level `container:` override through the same
// pkg/workflow session/merge logic internal/exec/workflow_utils.go uses for
// workflow-file container steps, shared by both the "shell" and "script" cases below
// (which differ only in the workflowStep and the command shown in output/logs).
runContainerOverrideStep := func(workflowStep *schema.WorkflowStep, displayCommand string) error {
return runCommandStep(func(stdout, stderr io.Writer) error {
return workflowPkg.RunStepContainerOverride(executionCtx, &workflowPkg.ContainerStepParams{
Workflow: commandConfig.Name,
WorkflowPath: atmosConfig.CliConfigPath,
BasePath: atmosConfig.BasePath,
WorkflowDef: &schema.WorkflowDefinition{},
Step: workflowStep,
HostWorkDir: stepWorkDir,
Command: displayCommand,
StepEnv: env,
RuntimeEnv: env,
StdoutCapture: stdout,
StderrCapture: stderr,
})
})
}
runStep := func() error {
switch stepType {
case "shell":
// Execute shell command (backward compatible).
// Steps with tty/interactive attach the user's terminal so commands
// like `aws ssm start-session` get a real TTY and own Ctrl-C.
commandName := fmt.Sprintf("%s-step-%d", commandConfig.Name, i)
// A step-level `container:` override (mapping config, or the
// bare `false` opt-out) routes the step through the same
// pkg/workflow session/merge logic internal/exec/workflow_utils.go
// uses for workflow-file steps, instead of always running on
// the host. Custom commands have no ambient command-level
// container block (unlike schema.WorkflowDefinition.Container
// for workflow files), so the merge base is always nil and
// the step's own `container:` block is the whole config.
workflowStep := step.ToWorkflowStep()
if workflowPkg.StepContainerOverride(&workflowStep) {
return runContainerOverrideStep(&workflowStep, commandToRun)
}
return runCommandStep(func(stdoutCapture, stderrCapture io.Writer) error {
return process.RunShellStep(context.Background(), &process.ShellSessionSpec{
return process.RunShellStep(executionCtx, &process.ShellSessionSpec{
Command: commandToRun,
Name: commandName,
Dir: stepWorkDir,
Expand All @@ -1303,6 +1374,7 @@ func executeCustomCommand(
stderr = io.Discard
}
return e.ExecuteShellWithWriters(&e.ExecuteShellSpec{
Context: executionCtx,
Command: commandToRun,
Name: commandName,
Dir: stepWorkDir,
Expand All @@ -1312,6 +1384,18 @@ func executeCustomCommand(
})
})
})
case schema.TaskTypeScript:
// A step-level `container:` override routes the step through the same
// pkg/workflow session/merge logic internal/exec/workflow_utils.go uses for
// workflow-file script steps, mirroring the "shell" case above.
// workflowPkg.RunStepContainerOverride's containerStepCommand already
// special-cases Type == script to invoke Interpreter/Script directly instead
// of wrapping the display command in `sh -lc`.
workflowStep := step.ToWorkflowStep()
if workflowPkg.StepContainerOverride(&workflowStep) {
return runContainerOverrideStep(&workflowStep, process.FormatScriptDisplay(step.Interpreter, step.Script))
}
return runExtendedStep(workflowStep)
case schema.TaskTypeExec:
// Replace the Atmos process with the command (shell exec semantics).
return process.ReplaceShellSession(&process.ExecSpec{
Expand All @@ -1329,6 +1413,7 @@ func executeCustomCommand(
}
return runCommandStep(func(stdout, stderr io.Writer) error {
execOpts := []e.ShellCommandOption{
e.WithProcessContext(executionCtx),
e.WithStdoutCapture(stdout),
e.WithStderrCapture(stderr),
}
Expand Down Expand Up @@ -1359,7 +1444,7 @@ func executeCustomCommand(
if s, ok := flagsData["stack"].(string); ok {
stack = s
}
return e.ExecuteCustomCommandControlStep(context.Background(), &e.CustomCommandControlContext{
return e.ExecuteCustomCommandControlStep(executionCtx, &e.CustomCommandControlContext{
AtmosConfig: atmosConfig,
CommandName: commandConfig.Name,
CommandEnv: envpkg.CommandEnvToMap(commandConfig.Env),
Expand All @@ -1372,38 +1457,14 @@ func executeCustomCommand(
default:
// Check if this is an extended step type (input, confirm, choose, etc.).
if stepPkg.IsExtendedStepType(stepType) {
// Convert Task to WorkflowStep for handler compatibility.
workflowStep := step.ToWorkflowStep()
// Carry env onto the step so handlers that read step.Env (e.g. the
// container handler's in-container env) see it. The step's own
// declared `env:` had its map keys lowercased by Viper, so restore
// the original case from the shared env case map, then merge it over
// the resolved command/process env (step vars win on collisions).
stepOwnEnv := workflowStep.Env
if atmosConfig.CaseMaps != nil {
stepOwnEnv = atmosConfig.CaseMaps.ApplyCase("env", stepOwnEnv)
}
mergedStepEnv := envpkg.SliceToMap(env)
for key, value := range stepOwnEnv {
mergedStepEnv[key] = value
}
workflowStep.Env = mergedStepEnv
workflowStep.WorkingDirectory = stepWorkDir

if stack, ok := flagsData["stack"].(string); ok && stack != "" {
executor.SetFlag("stack", stack)
}

// Execute the extended step.
_, execErr := executor.Execute(context.Background(), &workflowStep)
return execErr
return runExtendedStep(step.ToWorkflowStep())
}
return fmt.Errorf("%w: unsupported step type %q for custom command step %d", errUtils.ErrInvalidWorkflowStepType, stepType, i)
}
}
err = stepPkg.RunGroupedForType(&atmosConfig, step.Name, commandToRun, stepType, func() error {
if step.Retry != nil {
if retryErr := retry.Do(context.Background(), step.Retry, runStep); retryErr != nil {
if retryErr := retry.Do(executionCtx, step.Retry, runStep); retryErr != nil {
return retryErr
}
} else if runErr := runStep(); runErr != nil {
Expand Down
181 changes: 181 additions & 0 deletions cmd/custom_command_container_build_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package cmd

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/tests/testhelpers"
)

// TestCustomCommandContainerBuildPassesWithBlockToDocker reproduces
// https://github.com/cloudposse/atmos/issues/2876 end to end: a
// `.atmos.d/commands.yaml`-style custom command with a `type: container,
// action: build` step and a `with:` block must invoke docker with the full
// configured Buildx engine, builder, cache, tags, dockerfile, and context --
// not silently fall back to `docker build -f Dockerfile .`.
//
// This writes real config to disk, loads it through cfg.InitCliConfig (the
// same production config-loading path `atmos` itself uses), registers it via
// processCustomCommands, and invokes the resulting custom command
// through RootCmd.Execute() exactly as a user would from the shell. A fake,
// logging `docker` executable on PATH (testhelpers.InstallFakeContainerRuntime)
// captures the real argv Atmos emits, so this exercises the real command
// executor rather than manually constructing schema.Task/WorkflowStep/
// ContainerBuildStep values in Go.
func TestCustomCommandContainerBuildPassesWithBlockToDocker(t *testing.T) {
_ = NewTestKit(t)

tempDir := t.TempDir()
appDir := filepath.Join(tempDir, "app")
require.NoError(t, os.MkdirAll(appDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(appDir, "Dockerfile"), []byte("FROM scratch\n"), 0o644))

atmosYAML := `
base_path: "."
commands:
- name: test-container-build-with-block
description: Build the application image
steps:
- name: build
type: container
action: build
provider: docker
with:
engine: buildx
context: app
dockerfile: Dockerfile
tags:
- "example.invalid/demo:sha-test"
driver:
name: atmos-native-ci
provider: docker-container
opts:
image: mirror.gcr.io/moby/buildkit:buildx-stable-1
cache:
from:
- type: registry
ref: "example.invalid/demo:buildcache"
to:
- type: registry
ref: "example.invalid/demo:buildcache"
mode: max
`
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "atmos.yaml"), []byte(atmosYAML), 0o644))

t.Setenv("ATMOS_CLI_CONFIG_PATH", tempDir)
t.Setenv("ATMOS_BASE_PATH", tempDir)
t.Chdir(tempDir)

argsPath := filepath.Join(t.TempDir(), "docker-args.log")
t.Setenv("ATMOS_FAKE_RUNTIME_ARGS_FILE", argsPath)
testhelpers.InstallFakeContainerRuntime(t, testhelpers.FakeContainerRuntimeSpec{
Name: "docker",
Mode: testhelpers.FakeContainerRuntimeStep,
})

atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false)
require.NoError(t, err)

require.NoError(t, processCustomCommands(atmosConfig, atmosConfig.Commands, RootCmd))

RootCmd.SetArgs([]string{"test-container-build-with-block"})
require.NoError(t, RootCmd.Execute())

content, err := os.ReadFile(argsPath)
require.NoError(t, err, "the fake docker executable must have been invoked at least once")
lines := strings.Split(strings.TrimSpace(string(content)), "\n")

var buildLine string
for _, line := range lines {
fields := strings.Split(line, "\t")
if len(fields) > 1 && fields[0] == "buildx" && fields[1] == "build" {
buildLine = line
break
}
}
require.NotEmpty(t, buildLine,
"expected a `docker buildx build ...` invocation; got invocations: %v", lines)

fields := strings.Split(buildLine, "\t")
assert.Contains(t, fields, "--builder", "configured Buildx driver must be applied")
assert.Contains(t, fields, "atmos-native-ci")
assert.Contains(t, fields, "-t", "configured tag must be applied")
assert.Contains(t, fields, "example.invalid/demo:sha-test")
assert.Contains(t, fields, "-f", "configured Dockerfile must be applied")
// context/dockerfile are relative paths in the with: block, resolved
// against the step's working directory (#2880) into absolute paths --
// docker still receives the same file, just no longer as a bare relative
// string a differing subprocess cwd could silently misresolve.
assert.Contains(t, fields, filepath.Join(appDir, "Dockerfile"), "configured Dockerfile must be applied")
assert.Contains(t, fields, appDir, "configured context must be applied")

// The driver: block must provision a real Buildx builder before the build
// runs (pkg/container/docker.go's ensureBuilder calls `docker buildx
// create` as a separate invocation), and the cache entries must reach
// docker as the exact configured reference/mode, not merely as a bare
// `--cache-from`/`--cache-to` flag with an unchecked value. The fake
// runtime already records every invocation unconditionally, so both are
// present in the same recorded args log used above.
var createLine string
for _, line := range lines {
createFields := strings.Split(line, "\t")
if len(createFields) > 1 && createFields[0] == "buildx" && createFields[1] == "create" {
createLine = line
break
}
}
require.NotEmpty(t, createLine,
"expected a `docker buildx create ...` invocation provisioning the configured driver; got invocations: %v", lines)
createFields := strings.Split(createLine, "\t")

flagValueCases := []struct {
name string
fields []string
flag string
want string
}{
{"builder uses configured driver provider", createFields, "--driver", "docker-container"},
{"builder uses configured driver image opt", createFields, "--driver-opt", "image=mirror.gcr.io/moby/buildkit:buildx-stable-1"},
{"cache-from carries the configured ref", fields, "--cache-from", "ref=example.invalid/demo:buildcache,type=registry"},
{"cache-to carries the configured ref and mode=max", fields, "--cache-to", "mode=max,ref=example.invalid/demo:buildcache,type=registry"},
}
for _, tc := range flagValueCases {
t.Run(tc.name, func(t *testing.T) {
assertFlagValue(t, tc.fields, tc.flag, tc.want)
})
}
assert.Contains(t, createFields, "atmos-native-ci", "builder create must use the configured driver name")

// The exact bug report's symptom: Atmos must not fall back to a bare,
// unconfigured `docker build -f Dockerfile .`.
for _, line := range lines {
assert.NotEqual(t, "build\t-f\tDockerfile\t.", line,
"must not silently fall back to a bare, unconfigured docker build")
}
}

// assertFlagValue asserts fields contains flag immediately followed by want,
// so a flag's actual configured value is checked rather than merely its
// presence somewhere in the argv.
func assertFlagValue(t *testing.T, fields []string, flag, want string) {
t.Helper()

for i, field := range fields {
if field == flag {
if i+1 >= len(fields) {
t.Errorf("flag %q has no following value in %v", flag, fields)
return
}
assert.Equal(t, want, fields[i+1], "%s value", flag)
return
}
}
t.Errorf("expected flag %q not found in %v", flag, fields)
}
Loading
Loading