Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9171ab7
feat: Add working_directory support to custom commands and workflows
osterman Dec 10, 2025
4949afb
fix: Change test Git root override log to trace level
osterman Dec 10, 2025
549b78b
fix: Address linting errors and add test sanitization
osterman Dec 10, 2025
fa5d8ba
fix: Add home directory path sanitization and regenerate snapshot
osterman Dec 10, 2025
6fd78f0
fix: Move temp path sanitization before external path sanitization
osterman Dec 10, 2025
53abe57
fix: Extend path sanitization to cover all CI environments
osterman Dec 10, 2025
44ec269
fix: Address CodeRabbit review feedback
osterman Dec 12, 2025
c0ed6df
fix: Move temp path sanitization before repo root replacement
osterman Dec 12, 2025
e334169
fix: Match sanitized paths in temp path regex patterns
osterman Dec 12, 2025
d1a53b9
fix: Use proper absolute paths in TestCalculateWorkingDirectory
osterman Dec 12, 2025
b0b9421
fix: Use proper absolute paths in TestExecutor_Execute_WorkingDirectory
osterman Dec 13, 2025
accfbde
test: Add unit tests for resolveWorkingDirectory function
osterman Dec 13, 2025
100da2d
docs: Split blog post into separate feature announcements
osterman Dec 13, 2025
c88fa77
docs: Update blog post example to use docker compose
osterman Dec 13, 2025
a05590b
fix: Add diff patterns to ignore platform-specific trace logs in test
osterman Dec 13, 2025
94e54c4
Merge branch 'main' into feature/dev-3007-add-optional-working_direct…
aknysh Dec 14, 2025
fcc87ac
fix: Address CodeRabbit review comments for working_directory feature
aknysh Dec 14, 2025
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
13 changes: 11 additions & 2 deletions cmd/cmd_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,15 @@ func executeCustomCommand(
log.Debug("Authenticated with identity for custom command", "identity", commandIdentity, "command", commandConfig.Name)
}

// Determine working directory for command execution.
workDir, err := resolveWorkingDirectory(commandConfig.WorkingDirectory, atmosConfig.BasePath, currentDirPath)
if err != nil {
errUtils.CheckErrorPrintAndExit(err, "Invalid working_directory", "https://atmos.tools/cli/configuration/commands#working-directory")
}
if commandConfig.WorkingDirectory != "" {
log.Debug("Using working directory for custom command", "command", commandConfig.Name, "working_directory", workDir)
}

// Execute custom command's steps
for i, step := range commandConfig.Steps {
// Prepare template data for arguments
Expand Down Expand Up @@ -554,7 +563,7 @@ func executeCustomCommand(
// If the command to get the value for the ENV var is provided, execute it
if valCommand != "" {
valCommandName := fmt.Sprintf("env-var-%s-valcommand", key)
res, err := u.ExecuteShellAndReturnOutput(valCommand, valCommandName, currentDirPath, env, false)
res, err := u.ExecuteShellAndReturnOutput(valCommand, valCommandName, workDir, env, false)
errUtils.CheckErrorPrintAndExit(err, "", "")
value = strings.TrimRight(res, "\r\n")
} else {
Expand Down Expand Up @@ -595,7 +604,7 @@ func executeCustomCommand(
commandName := fmt.Sprintf("%s-step-%d", commandConfig.Name, i)

// Pass the prepared environment with custom variables to the subprocess
err = e.ExecuteShell(commandToRun, commandName, currentDirPath, env, false)
err = e.ExecuteShell(commandToRun, commandName, workDir, env, false)
errUtils.CheckErrorPrintAndExit(err, "", "")
}
}
Expand Down
48 changes: 48 additions & 0 deletions cmd/working_directory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package cmd

import (
"os"
"path/filepath"

errUtils "github.com/cloudposse/atmos/errors"
)

// resolveWorkingDirectory resolves and validates a working directory path.
// If workDir is empty, returns defaultDir. If workDir is relative, resolves against basePath.
// Returns error if the directory doesn't exist or isn't a directory.
func resolveWorkingDirectory(workDir, basePath, defaultDir string) (string, error) {
if workDir == "" {
return defaultDir, nil
}

// Clean and resolve paths. filepath.Clean normalizes paths like /tmp/foo/.. to /tmp.
// For relative paths, filepath.Join already cleans the result.
resolvedDir := filepath.Clean(workDir)
if !filepath.IsAbs(workDir) {
resolvedDir = filepath.Join(basePath, workDir)
}

// Validate directory exists and is a directory.
info, err := os.Stat(resolvedDir)
if os.IsNotExist(err) {
return "", errUtils.Build(errUtils.ErrWorkingDirNotFound).
WithCause(err).
WithContext("path", resolvedDir).
WithHint("Check that the working_directory path exists").
Err()
}
if err != nil {
return "", errUtils.Build(errUtils.ErrWorkingDirAccessFailed).
WithCause(err).
WithContext("path", resolvedDir).
Err()
}
if !info.IsDir() {
return "", errUtils.Build(errUtils.ErrWorkingDirNotDirectory).
WithContext("path", resolvedDir).
WithHint("The working_directory must be a directory, not a file").
Err()
}

return resolvedDir, nil
}
279 changes: 279 additions & 0 deletions cmd/working_directory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
package cmd

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

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

errUtils "github.com/cloudposse/atmos/errors"
)

// TestResolveWorkingDirectory tests the resolveWorkingDirectory function.
func TestResolveWorkingDirectory(t *testing.T) {
tests := []struct {
name string
workDir string
basePath string
defaultDir string
setup func(t *testing.T) (workDir, basePath, defaultDir string)
expected func(t *testing.T, workDir, basePath, defaultDir string) string
expectedErr error
}{
{
name: "empty working directory returns default",
setup: func(t *testing.T) (string, string, string) {
defaultDir := t.TempDir()
return "", "/some/base", defaultDir
},
expected: func(t *testing.T, workDir, basePath, defaultDir string) string {
return defaultDir
},
expectedErr: nil,
},
{
name: "absolute path to valid directory",
setup: func(t *testing.T) (string, string, string) {
absDir := t.TempDir()
return absDir, "/some/base", "/default"
},
expected: func(t *testing.T, workDir, basePath, defaultDir string) string {
return workDir
},
expectedErr: nil,
},
{
name: "relative path resolved against basePath",
setup: func(t *testing.T) (string, string, string) {
baseDir := t.TempDir()
relDir := "subdir"
// Create the subdirectory.
fullPath := filepath.Join(baseDir, relDir)
err := os.MkdirAll(fullPath, 0o755)
require.NoError(t, err)
return relDir, baseDir, "/default"
},
expected: func(t *testing.T, workDir, basePath, defaultDir string) string {
return filepath.Join(basePath, workDir)
},
expectedErr: nil,
},
{
name: "nested relative path resolved against basePath",
setup: func(t *testing.T) (string, string, string) {
baseDir := t.TempDir()
relDir := filepath.Join("nested", "subdir")
// Create the nested subdirectory.
fullPath := filepath.Join(baseDir, relDir)
err := os.MkdirAll(fullPath, 0o755)
require.NoError(t, err)
return relDir, baseDir, "/default"
},
expected: func(t *testing.T, workDir, basePath, defaultDir string) string {
return filepath.Join(basePath, workDir)
},
expectedErr: nil,
},
{
name: "directory does not exist",
setup: func(t *testing.T) (string, string, string) {
return "/nonexistent/path/that/does/not/exist", "/base", "/default"
},
expected: nil,
expectedErr: errUtils.ErrWorkingDirNotFound,
},
{
name: "relative path to nonexistent directory",
setup: func(t *testing.T) (string, string, string) {
baseDir := t.TempDir()
return "nonexistent_subdir", baseDir, "/default"
},
expected: nil,
expectedErr: errUtils.ErrWorkingDirNotFound,
},
{
name: "path is a file not a directory",
setup: func(t *testing.T) (string, string, string) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "testfile.txt")
err := os.WriteFile(filePath, []byte("test content"), 0o644)
require.NoError(t, err)
return filePath, "/base", "/default"
},
expected: nil,
expectedErr: errUtils.ErrWorkingDirNotDirectory,
},
{
name: "relative path to file not directory",
setup: func(t *testing.T) (string, string, string) {
baseDir := t.TempDir()
fileName := "testfile.txt"
filePath := filepath.Join(baseDir, fileName)
err := os.WriteFile(filePath, []byte("test content"), 0o644)
require.NoError(t, err)
return fileName, baseDir, "/default"
},
expected: nil,
expectedErr: errUtils.ErrWorkingDirNotDirectory,
},
{
name: "empty basePath with relative workDir",
setup: func(t *testing.T) (string, string, string) {
// When basePath is empty, relative paths are resolved against current directory.
// Create a temp dir and change to it.
tmpDir := t.TempDir()
subDir := "subdir"
fullPath := filepath.Join(tmpDir, subDir)
err := os.MkdirAll(fullPath, 0o755)
require.NoError(t, err)
// Change to tmpDir so relative path works.
t.Chdir(tmpDir)
return subDir, "", "/default"
},
expected: func(t *testing.T, workDir, basePath, defaultDir string) string {
// With empty basePath, filepath.Join("", "subdir") = "subdir".
return workDir
},
expectedErr: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
workDir, basePath, defaultDir := tt.setup(t)

result, err := resolveWorkingDirectory(workDir, basePath, defaultDir)

if tt.expectedErr != nil {
require.Error(t, err)
assert.ErrorIs(t, err, tt.expectedErr)
assert.Empty(t, result)
} else {
require.NoError(t, err)
expectedPath := tt.expected(t, workDir, basePath, defaultDir)
assert.Equal(t, expectedPath, result)
}
})
}
}

// TestResolveWorkingDirectory_EdgeCases tests edge cases for resolveWorkingDirectory.
func TestResolveWorkingDirectory_EdgeCases(t *testing.T) {
t.Run("dot path resolves to basePath", func(t *testing.T) {
baseDir := t.TempDir()

result, err := resolveWorkingDirectory(".", baseDir, "/default")

require.NoError(t, err)
assert.Equal(t, baseDir, result)
})

t.Run("double dot path resolves to parent of basePath", func(t *testing.T) {
// Create nested structure.
tmpDir := t.TempDir()
childDir := filepath.Join(tmpDir, "child")
err := os.MkdirAll(childDir, 0o755)
require.NoError(t, err)

result, err := resolveWorkingDirectory("..", childDir, "/default")

require.NoError(t, err)
assert.Equal(t, tmpDir, result)
})

t.Run("whitespace in path is preserved", func(t *testing.T) {
tmpDir := t.TempDir()
dirWithSpaces := filepath.Join(tmpDir, "dir with spaces")
err := os.MkdirAll(dirWithSpaces, 0o755)
require.NoError(t, err)

result, err := resolveWorkingDirectory(dirWithSpaces, "/base", "/default")

require.NoError(t, err)
assert.Equal(t, dirWithSpaces, result)
})

t.Run("symlink to directory is valid", func(t *testing.T) {
tmpDir := t.TempDir()
realDir := filepath.Join(tmpDir, "realdir")
err := os.MkdirAll(realDir, 0o755)
require.NoError(t, err)

symlinkPath := filepath.Join(tmpDir, "symlink")
err = os.Symlink(realDir, symlinkPath)
if err != nil {
t.Skipf("Skipping symlink test: %v", err)
}

result, err := resolveWorkingDirectory(symlinkPath, "/base", "/default")

require.NoError(t, err)
assert.Equal(t, symlinkPath, result)
})

t.Run("symlink to file is invalid", func(t *testing.T) {
tmpDir := t.TempDir()
realFile := filepath.Join(tmpDir, "realfile.txt")
err := os.WriteFile(realFile, []byte("content"), 0o644)
require.NoError(t, err)

symlinkPath := filepath.Join(tmpDir, "symlink")
err = os.Symlink(realFile, symlinkPath)
if err != nil {
t.Skipf("Skipping symlink test: %v", err)
}

_, err = resolveWorkingDirectory(symlinkPath, "/base", "/default")

require.Error(t, err)
assert.ErrorIs(t, err, errUtils.ErrWorkingDirNotDirectory)
})
}

// TestResolveWorkingDirectory_AccessFailed tests the ErrWorkingDirAccessFailed error path.
// This test creates a directory with no permissions on Unix-like systems.
func TestResolveWorkingDirectory_AccessFailed(t *testing.T) {
// Skip on Windows where permission model differs significantly.
if os.Getenv("GOOS") == "windows" || filepath.Separator == '\\' {
t.Skip("Skipping permission test on Windows")
}

// Skip if running as root (root can access anything).
if os.Geteuid() == 0 {
t.Skip("Skipping permission test when running as root")
}

t.Run("directory with no permissions triggers access error", func(t *testing.T) {
tmpDir := t.TempDir()
noAccessDir := filepath.Join(tmpDir, "noaccess")

// Create directory with no permissions.
err := os.Mkdir(noAccessDir, 0o000)
require.NoError(t, err)

// Ensure cleanup even if test fails - restore permissions first.
t.Cleanup(func() {
_ = os.Chmod(noAccessDir, 0o755)
})

// Try to access the directory - should fail with permission denied.
// Note: os.Stat on the directory itself may succeed (just returns metadata),
// but accessing contents would fail. We test a subdirectory inside it
// which should fail to stat.
inaccessibleSubdir := filepath.Join(noAccessDir, "subdir")

_, err = resolveWorkingDirectory(inaccessibleSubdir, "/base", "/default")

require.Error(t, err)
// The error could be ErrWorkingDirNotFound (if stat fails with permission denied
// disguised as not exist) or ErrWorkingDirAccessFailed depending on OS behavior.
// We check that we get one of our sentinel errors.
isNotFound := errors.Is(err, errUtils.ErrWorkingDirNotFound)
isAccessFailed := errors.Is(err, errUtils.ErrWorkingDirAccessFailed)
assert.True(t, isNotFound || isAccessFailed,
"Expected ErrWorkingDirNotFound or ErrWorkingDirAccessFailed, got: %v", err)
})
}
3 changes: 3 additions & 0 deletions errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,9 @@ var (
ErrWorkflowNoWorkflow = errors.New("no workflow found")
ErrWorkflowFileNotFound = errors.New("workflow file not found")
ErrInvalidWorkflowManifest = errors.New("invalid workflow manifest")
ErrWorkingDirNotFound = errors.New("working directory does not exist")
ErrWorkingDirNotDirectory = errors.New("working directory path is not a directory")
ErrWorkingDirAccessFailed = errors.New("failed to access working directory")
ErrAuthProviderNotAvailable = errors.New("auth provider is not available")
ErrInvalidComponentArgument = errors.New("invalid arguments. The command requires one argument 'componentName'")
ErrValidation = errors.New("validation failed")
Expand Down
3 changes: 3 additions & 0 deletions pkg/config/config_import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ func TestMergeConfig_ImportOverrideBehavior(t *testing.T) {
// Test that the main config file's settings override imported settings.
tempDir := t.TempDir()

// Isolate from real git root's .atmos.d to prevent interference.
t.Setenv("TEST_GIT_ROOT", tempDir)

// Create an import file with a command.
importDir := filepath.Join(tempDir, "imports")
err := os.Mkdir(importDir, 0o755)
Expand Down
Loading
Loading