diff --git a/cmd/cmd_utils.go b/cmd/cmd_utils.go index 349732c007..6af53061dc 100644 --- a/cmd/cmd_utils.go +++ b/cmd/cmd_utils.go @@ -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 @@ -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 { @@ -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, "", "") } } diff --git a/cmd/working_directory.go b/cmd/working_directory.go new file mode 100644 index 0000000000..15bf94f516 --- /dev/null +++ b/cmd/working_directory.go @@ -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 +} diff --git a/cmd/working_directory_test.go b/cmd/working_directory_test.go new file mode 100644 index 0000000000..aa357d7885 --- /dev/null +++ b/cmd/working_directory_test.go @@ -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) + }) +} diff --git a/errors/errors.go b/errors/errors.go index 90682f8c65..c49cf10c39 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -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") diff --git a/pkg/config/config_import_test.go b/pkg/config/config_import_test.go index 9cee94df86..0235fd7064 100644 --- a/pkg/config/config_import_test.go +++ b/pkg/config/config_import_test.go @@ -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) diff --git a/pkg/config/load.go b/pkg/config/load.go index 07fc46622c..6282b245e7 100644 --- a/pkg/config/load.go +++ b/pkg/config/load.go @@ -19,6 +19,7 @@ import ( "github.com/cloudposse/atmos/pkg/filesystem" log "github.com/cloudposse/atmos/pkg/logger" "github.com/cloudposse/atmos/pkg/schema" + u "github.com/cloudposse/atmos/pkg/utils" "github.com/cloudposse/atmos/pkg/version" "github.com/cloudposse/atmos/pkg/xdg" ) @@ -211,13 +212,25 @@ func LoadConfig(configAndStacksInfo *schema.ConfigAndStacksInfo) (schema.AtmosCo } // If no config file is used, fall back to the default CLI config. if v.ConfigFileUsed() == "" { - log.Debug("'atmos.yaml' CLI config was not found", "paths", "system dir, home dir, current dir, ENV vars") + log.Debug("'atmos.yaml' CLI config was not found", "paths", "system dir, home dir, current dir, parent dirs, ENV vars") log.Debug("Refer to https://atmos.tools/cli/configuration for details on how to configure 'atmos.yaml'") log.Debug("Using the default CLI config") if err := mergeDefaultConfig(v); err != nil { return atmosConfig, err } + + // Also search git root for .atmos.d even with default config. + // This enables custom commands defined in .atmos.d at the repo root + // to work when running from any subdirectory. + gitRoot, err := u.ProcessTagGitRoot("!repo-root .") + if err == nil && gitRoot != "" && gitRoot != "." { + log.Debug("Loading .atmos.d from git root", "path", gitRoot) + if err := mergeDefaultImports(gitRoot, v); err != nil { + log.Trace("Failed to load .atmos.d from git root", "path", gitRoot, "error", err) + // Non-fatal: continue with default config. + } + } } if v.ConfigFileUsed() != "" { // get dir of atmosConfigFilePath @@ -412,6 +425,7 @@ func readSystemConfig(v *viper.Viper) error { } if len(configFilePath) > 0 { + log.Trace("Checking for atmos.yaml in system config", "path", configFilePath) err := mergeConfig(v, configFilePath, CliConfigFileName, false) switch err.(type) { case viper.ConfigFileNotFoundError: @@ -435,6 +449,7 @@ func readHomeConfigWithProvider(v *viper.Viper, homeProvider filesystem.HomeDirP return err } configFilePath := filepath.Join(home, ".atmos") + log.Trace("Checking for atmos.yaml in home directory", "path", configFilePath) err = mergeConfig(v, configFilePath, CliConfigFileName, true) if err != nil { switch err.(type) { @@ -461,6 +476,7 @@ func readWorkDirConfig(v *viper.Viper) error { } // First try the current directory. + log.Trace("Checking for atmos.yaml in working directory", "path", wd) err = mergeConfig(v, wd, CliConfigFileName, true) if err == nil { return nil @@ -521,6 +537,7 @@ func findAtmosConfigInParentDirs(startDir string) string { } dir = parent + log.Trace("Checking for atmos.yaml in parent directory", "path", dir) // Check for atmos.yaml or .atmos.yaml in this directory. for _, configName := range []string{AtmosConfigFileName, DotAtmosConfigFileName} { @@ -537,6 +554,7 @@ func readEnvAmosConfigPath(v *viper.Viper) error { if atmosPath == "" { return nil } + log.Trace("Checking for atmos.yaml from ATMOS_CLI_CONFIG_PATH", "path", atmosPath) err := mergeConfig(v, atmosPath, CliConfigFileName, true) if err != nil { switch err.(type) { @@ -823,6 +841,7 @@ func loadAtmosConfigsFromDirectory(searchPattern string, dst *viper.Viper, sourc // mergeDefaultImports merges default imports (`atmos.d/`,`.atmos.d/`) // from a specified directory into the destination configuration. +// It also searches the git/worktree root for .atmos.d with lower priority. func mergeDefaultImports(dirPath string, dst *viper.Viper) error { isDir := false if stat, err := os.Stat(dirPath); err == nil && stat.IsDir() { @@ -838,10 +857,57 @@ func mergeDefaultImports(dirPath string, dst *viper.Viper) error { return nil } + // Search git/worktree root FIRST (lower priority - gets overridden by config dir). + // This enables .atmos.d to be discovered at the repo root even when running from subdirectories. + loadAtmosDFromGitRoot(dirPath, dst) + + // Search the config directory (higher priority - loaded second, overrides git root). + log.Trace("Checking for .atmos.d in config directory", "path", dirPath) + loadAtmosDFromDirectory(dirPath, dst) + + return nil +} + +// loadAtmosDFromGitRoot searches for .atmos.d/ at the git repository root +// and loads its configuration if different from the config directory. +func loadAtmosDFromGitRoot(dirPath string, dst *viper.Viper) { + gitRoot, err := u.ProcessTagGitRoot("!repo-root .") + if err != nil || gitRoot == "" || gitRoot == "." { + return + } + + absGitRoot, absErr := filepath.Abs(gitRoot) + absDirPath, dirErr := filepath.Abs(dirPath) + if absErr != nil || dirErr != nil { + return + } + + // Check if git root is the same as config directory. + // Use case-insensitive comparison on Windows where paths may differ only in casing. + pathsEqual := absGitRoot == absDirPath + if runtime.GOOS == "windows" { + pathsEqual = strings.EqualFold(absGitRoot, absDirPath) + } + if pathsEqual { + return + } + + // Skip if excluded for testing. + if shouldExcludePathForTesting(absGitRoot) { + return + } + + log.Trace("Checking for .atmos.d in git root", "path", absGitRoot) + loadAtmosDFromDirectory(absGitRoot, dst) +} + +// loadAtmosDFromDirectory searches for atmos.d/ and .atmos.d/ in the given directory +// and loads their configurations into the destination viper instance. +func loadAtmosDFromDirectory(dirPath string, dst *viper.Viper) { // Search for `atmos.d/` configurations. searchPattern := filepath.Join(filepath.FromSlash(dirPath), filepath.Join("atmos.d", "**", "*")) if err := loadAtmosConfigsFromDirectory(searchPattern, dst, "atmos.d"); err != nil { - log.Trace("Failed to load atmos.d configs", "error", err) + log.Trace("Failed to load atmos.d configs", "error", err, "path", dirPath) // Don't return error - just log and continue. // This maintains existing behavior where .atmos.d loading is optional. } @@ -849,12 +915,10 @@ func mergeDefaultImports(dirPath string, dst *viper.Viper) error { // Search for `.atmos.d` configurations. searchPattern = filepath.Join(filepath.FromSlash(dirPath), filepath.Join(".atmos.d", "**", "*")) if err := loadAtmosConfigsFromDirectory(searchPattern, dst, ".atmos.d"); err != nil { - log.Trace("Failed to load .atmos.d configs", "error", err) + log.Trace("Failed to load .atmos.d configs", "error", err, "path", dirPath) // Don't return error - just log and continue. // This maintains existing behavior where .atmos.d loading is optional. } - - return nil } // mergeImports processes imports from the atmos configuration and merges them into the destination configuration. diff --git a/pkg/config/load_test.go b/pkg/config/load_test.go index f9ec38822a..d9473be536 100644 --- a/pkg/config/load_test.go +++ b/pkg/config/load_test.go @@ -1035,3 +1035,155 @@ base_path: /test/parent-should-not-find }) } } + +// TestLoadConfig_DefaultConfigWithGitRootAtmosD tests that .atmos.d at git root is loaded +// even when no atmos.yaml config file is found (using default config). +func TestLoadConfig_DefaultConfigWithGitRootAtmosD(t *testing.T) { + tempDir := t.TempDir() + + // Create .atmos.d at "git root" with custom commands. + atmosDDir := filepath.Join(tempDir, ".atmos.d") + require.NoError(t, os.MkdirAll(atmosDDir, 0o755)) + + commandsContent := `commands: + - name: test-default-cmd + description: Test command from git root with default config + steps: + - echo "hello from git root default config" +` + require.NoError(t, os.WriteFile( + filepath.Join(atmosDDir, "commands.yaml"), + []byte(commandsContent), + 0o644, + )) + + // Create a subdirectory with NO atmos.yaml - this will force default config. + subDir := filepath.Join(tempDir, "no-config-subdir") + require.NoError(t, os.MkdirAll(subDir, 0o755)) + + // Mock git root to be tempDir. + t.Setenv("TEST_GIT_ROOT", tempDir) + + // Change to subdirectory. + t.Chdir(subDir) + + // Load config - should use default config but still find .atmos.d from git root. + config, err := LoadConfig(&schema.ConfigAndStacksInfo{}) + require.NoError(t, err) + + // Verify that the custom command from .atmos.d was loaded. + require.NotNil(t, config.Commands, "Commands should be loaded from .atmos.d at git root") + require.Len(t, config.Commands, 1, "Should have one custom command") + assert.Equal(t, "test-default-cmd", config.Commands[0].Name) +} + +// TestMergeDefaultImports_GitRoot tests that .atmos.d at git root is discovered +// even when running from a subdirectory. +func TestMergeDefaultImports_GitRoot(t *testing.T) { + tests := []struct { + name string + setupDirs func(t *testing.T, tempDir string) string + expectedKey string + expectedValue string + description string + }{ + { + name: "loads_atmos_d_from_git_root_when_in_subdirectory", + setupDirs: func(t *testing.T, tempDir string) string { + // Create .atmos.d at "git root" (tempDir). + atmosDDir := filepath.Join(tempDir, ".atmos.d") + require.NoError(t, os.MkdirAll(atmosDDir, 0o755)) + + commandsContent := `commands: + - name: test-cmd + description: Test command from git root + steps: + - echo "hello from git root" +` + require.NoError(t, os.WriteFile( + filepath.Join(atmosDDir, "commands.yaml"), + []byte(commandsContent), + 0o644, + )) + + // Create a subdirectory to run from. + subDir := filepath.Join(tempDir, "test") + require.NoError(t, os.MkdirAll(subDir, 0o755)) + + return subDir + }, + expectedKey: "commands", + expectedValue: "", // Just check that commands key exists + description: "Should load .atmos.d from git root when running from subdirectory", + }, + { + name: "git_root_atmos_d_has_lower_priority_than_config_dir", + setupDirs: func(t *testing.T, tempDir string) string { + // Create .atmos.d at "git root" (tempDir) with lower priority setting. + gitRootAtmosDDir := filepath.Join(tempDir, ".atmos.d") + require.NoError(t, os.MkdirAll(gitRootAtmosDDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(gitRootAtmosDDir, "settings.yaml"), + []byte(`settings: + test_priority: from_git_root +`), + 0o644, + )) + + // Create config directory with atmos.yaml. + configDir := filepath.Join(tempDir, "config") + require.NoError(t, os.MkdirAll(configDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(configDir, "atmos.yaml"), + []byte(`base_path: ./`), + 0o644, + )) + + // Create .atmos.d in config dir with higher priority setting. + configAtmosDDir := filepath.Join(configDir, ".atmos.d") + require.NoError(t, os.MkdirAll(configAtmosDDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(configAtmosDDir, "settings.yaml"), + []byte(`settings: + test_priority: from_config_dir +`), + 0o644, + )) + + return configDir + }, + expectedKey: "settings.test_priority", + expectedValue: "from_config_dir", + description: "Config dir .atmos.d should override git root .atmos.d", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + workDir := tt.setupDirs(t, tempDir) + + // Mock git root to be tempDir. + t.Setenv("TEST_GIT_ROOT", tempDir) + + // Change to working directory. + t.Chdir(workDir) + + v := viper.New() + v.SetConfigType("yaml") + + // Call mergeDefaultImports with the working directory. + err := mergeDefaultImports(workDir, v) + require.NoError(t, err, tt.description) + + // Verify the expected key is set. + assert.True(t, v.IsSet(tt.expectedKey), + "Expected key %q to be set. %s", tt.expectedKey, tt.description) + + if tt.expectedValue != "" { + assert.Equal(t, tt.expectedValue, v.GetString(tt.expectedKey), + "Expected value mismatch. %s", tt.description) + } + }) + } +} diff --git a/pkg/schema/command.go b/pkg/schema/command.go index 5d9eafb98a..b00be4a8fd 100644 --- a/pkg/schema/command.go +++ b/pkg/schema/command.go @@ -3,16 +3,17 @@ package schema // Custom CLI commands type Command struct { - Name string `yaml:"name" json:"name" mapstructure:"name"` - Description string `yaml:"description" json:"description" mapstructure:"description"` - Env []CommandEnv `yaml:"env" json:"env" mapstructure:"env"` - Arguments []CommandArgument `yaml:"arguments" json:"arguments" mapstructure:"arguments"` - Flags []CommandFlag `yaml:"flags" json:"flags" mapstructure:"flags"` - ComponentConfig CommandComponentConfig `yaml:"component_config" json:"component_config" mapstructure:"component_config"` - Steps []string `yaml:"steps" json:"steps" mapstructure:"steps"` - Commands []Command `yaml:"commands" json:"commands" mapstructure:"commands"` - Verbose bool `yaml:"verbose" json:"verbose" mapstructure:"verbose"` - Identity string `yaml:"identity,omitempty" json:"identity,omitempty" mapstructure:"identity"` + Name string `yaml:"name" json:"name" mapstructure:"name"` + Description string `yaml:"description" json:"description" mapstructure:"description"` + WorkingDirectory string `yaml:"working_directory,omitempty" json:"working_directory,omitempty" mapstructure:"working_directory"` + Env []CommandEnv `yaml:"env" json:"env" mapstructure:"env"` + Arguments []CommandArgument `yaml:"arguments" json:"arguments" mapstructure:"arguments"` + Flags []CommandFlag `yaml:"flags" json:"flags" mapstructure:"flags"` + ComponentConfig CommandComponentConfig `yaml:"component_config" json:"component_config" mapstructure:"component_config"` + Steps []string `yaml:"steps" json:"steps" mapstructure:"steps"` + Commands []Command `yaml:"commands" json:"commands" mapstructure:"commands"` + Verbose bool `yaml:"verbose" json:"verbose" mapstructure:"verbose"` + Identity string `yaml:"identity,omitempty" json:"identity,omitempty" mapstructure:"identity"` } type CommandArgument struct { diff --git a/pkg/schema/workflow.go b/pkg/schema/workflow.go index b75b6165b1..8416e13aba 100644 --- a/pkg/schema/workflow.go +++ b/pkg/schema/workflow.go @@ -5,18 +5,20 @@ type DescribeWorkflowsItem struct { Workflow string `yaml:"workflow" json:"workflow" mapstructure:"workflow"` } type WorkflowStep struct { - Name string `yaml:"name,omitempty" json:"name,omitempty" mapstructure:"name"` - Command string `yaml:"command" json:"command" mapstructure:"command"` - Stack string `yaml:"stack,omitempty" json:"stack,omitempty" mapstructure:"stack"` - Type string `yaml:"type,omitempty" json:"type,omitempty" mapstructure:"type"` - Retry *RetryConfig `yaml:"retry,omitempty" json:"retry,omitempty" mapstructure:"retry"` - Identity string `yaml:"identity,omitempty" json:"identity,omitempty" mapstructure:"identity"` + Name string `yaml:"name,omitempty" json:"name,omitempty" mapstructure:"name"` + Command string `yaml:"command" json:"command" mapstructure:"command"` + Stack string `yaml:"stack,omitempty" json:"stack,omitempty" mapstructure:"stack"` + Type string `yaml:"type,omitempty" json:"type,omitempty" mapstructure:"type"` + WorkingDirectory string `yaml:"working_directory,omitempty" json:"working_directory,omitempty" mapstructure:"working_directory"` + Retry *RetryConfig `yaml:"retry,omitempty" json:"retry,omitempty" mapstructure:"retry"` + Identity string `yaml:"identity,omitempty" json:"identity,omitempty" mapstructure:"identity"` } type WorkflowDefinition struct { - Description string `yaml:"description,omitempty" json:"description,omitempty" mapstructure:"description"` - Steps []WorkflowStep `yaml:"steps" json:"steps" mapstructure:"steps"` - Stack string `yaml:"stack,omitempty" json:"stack,omitempty" mapstructure:"stack"` + Description string `yaml:"description,omitempty" json:"description,omitempty" mapstructure:"description"` + WorkingDirectory string `yaml:"working_directory,omitempty" json:"working_directory,omitempty" mapstructure:"working_directory"` + Steps []WorkflowStep `yaml:"steps" json:"steps" mapstructure:"steps"` + Stack string `yaml:"stack,omitempty" json:"stack,omitempty" mapstructure:"stack"` } type WorkflowConfig map[string]WorkflowDefinition diff --git a/pkg/utils/git.go b/pkg/utils/git.go index 48e4590c13..d631ea4e3b 100644 --- a/pkg/utils/git.go +++ b/pkg/utils/git.go @@ -19,10 +19,10 @@ func ProcessTagGitRoot(input string) (string, error) { str := strings.TrimPrefix(input, AtmosYamlFuncGitRoot) defaultValue := strings.TrimSpace(str) - // Check if we're in test mode and should use a mock Git root + // Check if we're in test mode and should use a mock Git root. //nolint:forbidigo // TEST_GIT_ROOT is specifically for test isolation, not application configuration if testGitRoot := os.Getenv("TEST_GIT_ROOT"); testGitRoot != "" { - log.Debug("Using test Git root override", "path", testGitRoot) + log.Trace("Using test Git root override", "path", testGitRoot) return testGitRoot, nil } diff --git a/pkg/workflow/executor.go b/pkg/workflow/executor.go index 41b07ae37b..cff20c5b3e 100644 --- a/pkg/workflow/executor.go +++ b/pkg/workflow/executor.go @@ -177,13 +177,17 @@ func (e *Executor) executeStep(params *WorkflowParams, step *schema.WorkflowStep // Calculate final stack. finalStack := e.calculateFinalStack(params.WorkflowDefinition, step, params.Opts.CommandLineStack) + // Calculate working directory with inheritance from workflow-level. + workDir := e.calculateWorkingDirectory(params.WorkflowDefinition, step, params.AtmosConfig.BasePath) + // Execute the command based on type. cmdParams := &runCommandParams{ - command: command, - commandType: commandType, - stepIdx: stepIdx, - finalStack: finalStack, - stepEnv: stepEnv, + command: command, + commandType: commandType, + stepIdx: stepIdx, + finalStack: finalStack, + stepEnv: stepEnv, + workingDirectory: workDir, } err = e.runCommand(params, cmdParams) if err != nil { @@ -210,21 +214,28 @@ func (e *Executor) prepareStepEnvironment(ctx context.Context, stepIdentity, ste // runCommandParams holds parameters for command execution. type runCommandParams struct { - command string - commandType string - stepIdx int - finalStack string - stepEnv []string + command string + commandType string + stepIdx int + finalStack string + stepEnv []string + workingDirectory string } // runCommand executes the appropriate command type. func (e *Executor) runCommand(params *WorkflowParams, cmdParams *runCommandParams) error { + // Use working directory if set, otherwise default to current directory. + workDir := cmdParams.workingDirectory + if workDir == "" { + workDir = "." + } + switch cmdParams.commandType { case "shell": commandName := fmt.Sprintf("%s-step-%d", params.Workflow, cmdParams.stepIdx) - return e.runner.RunShell(cmdParams.command, commandName, ".", cmdParams.stepEnv, params.Opts.DryRun) + return e.runner.RunShell(cmdParams.command, commandName, workDir, cmdParams.stepEnv, params.Opts.DryRun) case "atmos": - return e.executeAtmosCommand(params, cmdParams.command, cmdParams.finalStack, cmdParams.stepEnv) + return e.executeAtmosCommand(params, cmdParams.command, cmdParams.finalStack, cmdParams.stepEnv, workDir) default: // Return error without printing - handleStepError will print it with resume context. return errUtils.Build(errUtils.ErrInvalidWorkflowStepType). @@ -317,7 +328,7 @@ func (e *Executor) prepareAuthenticatedEnvironment(ctx context.Context, identity } // executeAtmosCommand executes an atmos command with the given parameters. -func (e *Executor) executeAtmosCommand(params *WorkflowParams, command, finalStack string, stepEnv []string) error { +func (e *Executor) executeAtmosCommand(params *WorkflowParams, command, finalStack string, stepEnv []string, workDir string) error { // Parse command using shell.Fields for proper quote handling. args, parseErr := shell.Fields(command, nil) if parseErr != nil { @@ -340,7 +351,7 @@ func (e *Executor) executeAtmosCommand(params *WorkflowParams, command, finalSta Ctx: params.Ctx, AtmosConfig: params.AtmosConfig, Args: args, - Dir: ".", + Dir: workDir, Env: stepEnv, DryRun: params.Opts.DryRun, } @@ -367,6 +378,37 @@ func (e *Executor) calculateFinalStack(workflowDef *schema.WorkflowDefinition, s return finalStack } +// calculateWorkingDirectory determines the working directory for a workflow step. +// Step-level working_directory overrides workflow-level. +// Relative paths are resolved against base_path. +func (e *Executor) calculateWorkingDirectory(workflowDef *schema.WorkflowDefinition, step *schema.WorkflowStep, basePath string) string { + // Step-level overrides workflow-level. + workDir := strings.TrimSpace(workflowDef.WorkingDirectory) + if stepWorkDir := strings.TrimSpace(step.WorkingDirectory); stepWorkDir != "" { + workDir = stepWorkDir + } + + if workDir == "" { + return "" + } + + // Resolve relative paths against base_path. + // Guard against empty basePath to avoid accidentally relative paths. + if !filepath.IsAbs(workDir) { + resolvedBasePath := basePath + if strings.TrimSpace(resolvedBasePath) == "" { + resolvedBasePath = "." + } + workDir = filepath.Join(resolvedBasePath, workDir) + } + + // Note: Directory validation happens at execution time in the adapters. + // This allows YAML functions like !repo-root to be resolved first during config loading. + log.Debug("Using working directory for workflow step", "working_directory", workDir) + + return workDir +} + // buildResumeCommand builds a command to resume the workflow from a specific step. func (e *Executor) buildResumeCommand(workflow, workflowPath, stepName, finalStack string, atmosConfig *schema.AtmosConfiguration) string { workflowFileName := strings.TrimPrefix(filepath.ToSlash(workflowPath), filepath.ToSlash(atmosConfig.Workflows.BasePath)) diff --git a/pkg/workflow/executor_test.go b/pkg/workflow/executor_test.go index 5750a3de7c..cd085b4cbe 100644 --- a/pkg/workflow/executor_test.go +++ b/pkg/workflow/executor_test.go @@ -3,6 +3,8 @@ package workflow import ( "context" "errors" + "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -932,3 +934,223 @@ func TestExecutor_Execute_NilAtmosConfig(t *testing.T) { assert.Nil(t, result) assert.ErrorIs(t, err, errUtils.ErrNilParam) } + +// TestExecutor_Execute_WorkingDirectory tests working_directory support. +func TestExecutor_Execute_WorkingDirectory(t *testing.T) { + // Use OS-portable paths for cross-platform compatibility. + // On Windows, paths like "/base" become "\base" which is NOT absolute. + // We need to use proper absolute paths for each platform. + var base, tmp, workflowDir, stepDir string + if runtime.GOOS == "windows" { + base = `C:\base` + tmp = `C:\tmp` + workflowDir = `C:\workflow-dir` + stepDir = `C:\step-dir` + } else { + base = "/base" + tmp = "/tmp" + workflowDir = "/workflow-dir" + stepDir = "/step-dir" + } + + tests := []struct { + name string + workflowWorkDir string + stepWorkDir string + basePath string + expectedShellWorkDir string + expectedAtmosWorkDir string + }{ + { + name: "no working directory", + workflowWorkDir: "", + stepWorkDir: "", + basePath: base, + expectedShellWorkDir: ".", + expectedAtmosWorkDir: ".", + }, + { + name: "workflow level absolute path", + workflowWorkDir: tmp, + stepWorkDir: "", + basePath: base, + expectedShellWorkDir: tmp, + expectedAtmosWorkDir: tmp, + }, + { + name: "workflow level relative path", + workflowWorkDir: "subdir", + stepWorkDir: "", + basePath: base, + expectedShellWorkDir: filepath.Join(base, "subdir"), + expectedAtmosWorkDir: filepath.Join(base, "subdir"), + }, + { + name: "step overrides workflow", + workflowWorkDir: workflowDir, + stepWorkDir: stepDir, + basePath: base, + expectedShellWorkDir: stepDir, + expectedAtmosWorkDir: stepDir, + }, + { + name: "step relative overrides workflow", + workflowWorkDir: workflowDir, + stepWorkDir: "step-subdir", + basePath: base, + expectedShellWorkDir: filepath.Join(base, "step-subdir"), + expectedAtmosWorkDir: filepath.Join(base, "step-subdir"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // Create mocks. + mockRunner := NewMockCommandRunner(ctrl) + mockUI := NewMockUIProvider(ctrl) + + // Expect shell command with correct working directory. + mockRunner.EXPECT(). + RunShell("echo shell", "test-workflow-step-0", tt.expectedShellWorkDir, []string{}, false). + Return(nil) + + // Expect atmos command with correct working directory. + mockRunner.EXPECT(). + RunAtmos(gomock.Any()). + DoAndReturn(func(params *AtmosExecParams) error { + assert.Equal(t, tt.expectedAtmosWorkDir, params.Dir, "atmos command working directory mismatch") + return nil + }) + + mockUI.EXPECT().PrintMessage(gomock.Any(), gomock.Any()).AnyTimes() + + // Create executor. + executor := NewExecutor(mockRunner, nil, mockUI) + + // Define workflow with working_directory. + workflowDef := &schema.WorkflowDefinition{ + WorkingDirectory: tt.workflowWorkDir, + Steps: []schema.WorkflowStep{ + { + Name: "shell-step", + Command: "echo shell", + Type: "shell", + WorkingDirectory: tt.stepWorkDir, + }, + { + Name: "atmos-step", + Command: "version", + Type: "atmos", + WorkingDirectory: tt.stepWorkDir, + }, + }, + } + + params := &WorkflowParams{ + Ctx: context.Background(), + AtmosConfig: &schema.AtmosConfiguration{BasePath: tt.basePath}, + Workflow: "test-workflow", + WorkflowPath: "test.yaml", + WorkflowDefinition: workflowDef, + Opts: ExecuteOptions{}, + } + + // Execute. + result, err := executor.Execute(params) + + require.NoError(t, err) + assert.True(t, result.Success) + assert.Len(t, result.Steps, 2) + }) + } +} + +// TestCalculateWorkingDirectory tests the calculateWorkingDirectory function directly. +func TestCalculateWorkingDirectory(t *testing.T) { + // Use OS-portable paths for cross-platform compatibility. + // On Windows, paths like "/base" become "\base" which is NOT absolute. + // We need to use proper absolute paths for each platform. + var base, absolutePath, workflowDirPath, stepDirPath string + if runtime.GOOS == "windows" { + base = `C:\base` + absolutePath = `C:\absolute\path` + workflowDirPath = `C:\workflow\dir` + stepDirPath = `C:\step\dir` + } else { + base = "/base" + absolutePath = "/absolute/path" + workflowDirPath = "/workflow/dir" + stepDirPath = "/step/dir" + } + + tests := []struct { + name string + workflowDir string + stepDir string + basePath string + expected string + }{ + { + name: "empty returns empty", + workflowDir: "", + stepDir: "", + basePath: base, + expected: "", + }, + { + name: "workflow absolute path", + workflowDir: absolutePath, + stepDir: "", + basePath: base, + expected: absolutePath, + }, + { + name: "workflow relative path resolved against base", + workflowDir: filepath.FromSlash("relative/path"), + stepDir: "", + basePath: base, + expected: filepath.Join(base, filepath.FromSlash("relative/path")), + }, + { + name: "step overrides workflow", + workflowDir: workflowDirPath, + stepDir: stepDirPath, + basePath: base, + expected: stepDirPath, + }, + { + name: "step relative resolved against base", + workflowDir: "", + stepDir: filepath.FromSlash("step/relative"), + basePath: base, + expected: filepath.Join(base, filepath.FromSlash("step/relative")), + }, + { + name: "whitespace trimmed from absolute path", + workflowDir: " " + absolutePath + " ", + stepDir: "", + basePath: base, + expected: absolutePath, + }, + } + + executor := NewExecutor(nil, nil, nil) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workflowDef := &schema.WorkflowDefinition{ + WorkingDirectory: tt.workflowDir, + } + step := &schema.WorkflowStep{ + WorkingDirectory: tt.stepDir, + } + + result := executor.calculateWorkingDirectory(workflowDef, step, tt.basePath) + + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/tests/cli_sanitize_test.go b/tests/cli_sanitize_test.go index a84abfa16d..43bba58829 100644 --- a/tests/cli_sanitize_test.go +++ b/tests/cli_sanitize_test.go @@ -67,6 +67,66 @@ func TestSanitizeOutput(t *testing.T) { input: "token=phc_ABC123def456GHI789jkl012MNO345pqr678", expected: "token=phc_TEST_TOKEN_PLACEHOLDER", }, + { + name: "macOS temp directory path should be normalized", + input: "TRCE Using test Git root override path=/var/folders/_l/91ns3hs96sd11p4_lzxh1l140000gn/T/TestCLICommands2870082120/001/mock-git-root", + expected: "TRCE Using test Git root override path=/mock-git-root", + }, + { + name: "Linux temp directory path should be normalized", + input: "TRCE Using test Git root override path=/tmp/TestCLICommands2870082120/001/mock-git-root", + expected: "TRCE Using test Git root override path=/mock-git-root", + }, + { + name: "macOS temp home directory path should be normalized", + input: "TRCE Checking for atmos.yaml in home directory path=/var/folders/ly/sz00b8054kv_85k1m9_0dmfc0000gn/T/TestCLICommands123456789/001/.atmos", + expected: "TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos", + }, + { + name: "Linux temp home directory path should be normalized", + input: "TRCE Checking for atmos.yaml in home directory path=/tmp/TestCLICommands123456789/001/.atmos", + expected: "TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos", + }, + { + name: "GitHub Actions macOS runner path should be normalized for mock-git-root", + input: "TRCE Using test Git root override path=/Users/runner/work/atmos/atmos/mock-git-root", + expected: "TRCE Using test Git root override path=/mock-git-root", + }, + { + name: "GitHub Actions macOS runner path should be normalized for .atmos", + input: "TRCE Checking for atmos.yaml in home directory path=/Users/runner/.atmos", + expected: "TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos", + }, + { + name: "GitHub Actions Linux runner path should be normalized for mock-git-root", + input: "TRCE Using test Git root override path=/home/runner/work/atmos/atmos/mock-git-root", + expected: "TRCE Using test Git root override path=/mock-git-root", + }, + { + name: "GitHub Actions Linux runner path should be normalized for .atmos", + input: "TRCE Checking for atmos.yaml in home directory path=/home/runner/.atmos", + expected: "TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos", + }, + { + name: "Windows path should be normalized for mock-git-root", + input: "TRCE Using test Git root override path=D:/a/atmos/atmos/mock-git-root", + expected: "TRCE Using test Git root override path=/mock-git-root", + }, + { + name: "Windows path should be normalized for .atmos", + input: "TRCE Checking for atmos.yaml in home directory path=C:/Users/runner/.atmos", + expected: "TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos", + }, + { + name: "Already sanitized repo path with mock-git-root should be normalized", + input: "TRCE Using test Git root override path=/absolute/path/to/repo/mock-git-root", + expected: "TRCE Using test Git root override path=/mock-git-root", + }, + { + name: "Already sanitized repo path with .atmos should be normalized", + input: "TRCE Checking for atmos.yaml in home directory path=/absolute/path/to/repo/some/subdir/.atmos", + expected: "TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos", + }, } for _, tt := range tests { diff --git a/tests/cli_test.go b/tests/cli_test.go index 68beb5cff0..c24f434b45 100644 --- a/tests/cli_test.go +++ b/tests/cli_test.go @@ -446,7 +446,20 @@ func sanitizeOutput(output string, opts ...sanitizeOption) (string, error) { result = customRegex.ReplaceAllString(result, replacement) } - // 12. Normalize external absolute paths to avoid environment-specific paths in snapshots. + // 12a. Normalize temporary directory paths from TEST_GIT_ROOT and other test fixtures. + // These appear in trace logs as path=/var/folders/.../mock-git-root or path=/absolute/path/to/repo/mock-git-root. + // Replace with a stable placeholder since these are test-specific paths. + // Matches both raw paths and already-sanitized repo paths. + tempGitRootRegex := regexp.MustCompile(`path=(/var/folders/[^\s]+/mock-git-root|/tmp/[^\s]+/mock-git-root|/Users/[^\s]+/mock-git-root|/home/[^\s]+/mock-git-root|[A-Z]:/[^\s]+/mock-git-root|/absolute/path/to/repo/mock-git-root)`) + result = tempGitRootRegex.ReplaceAllString(result, "path=/mock-git-root") + + // 12b. Normalize temp home directory paths in trace logs (e.g., path=/var/folders/.../T/TestCLI.../.atmos). + // These are used for home directory mocking in tests. + // Matches both raw paths and already-sanitized repo paths. + tempHomeDirRegex := regexp.MustCompile(`path=(/var/folders/[^\s]+/\.atmos|/tmp/[^\s]+/\.atmos|/Users/[^\s]+/\.atmos|/home/[^\s]+/\.atmos|[A-Z]:/[^\s]+/\.atmos|/absolute/path/to/repo/[^\s]+/\.atmos)`) + result = tempHomeDirRegex.ReplaceAllString(result, "path=/mock-home/.atmos") + + // 12c. Normalize external absolute paths to avoid environment-specific paths in snapshots. // Replace common absolute path prefixes with generic placeholders. // This handles paths outside the repo (e.g., /Users/username/other-projects/). // Match Unix-style absolute paths (/Users/, /home/, /opt/, etc.) and Windows paths (C:/Users/, etc.). diff --git a/tests/snapshots/TestCLICommands_Valid_Log_Level_in_Config_File.stderr.golden b/tests/snapshots/TestCLICommands_Valid_Log_Level_in_Config_File.stderr.golden index 199efdaa5c..72b0253541 100644 --- a/tests/snapshots/TestCLICommands_Valid_Log_Level_in_Config_File.stderr.golden +++ b/tests/snapshots/TestCLICommands_Valid_Log_Level_in_Config_File.stderr.golden @@ -1,22 +1,64 @@ DEBU Set logs-level=trace logs-file=/dev/stderr - TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" - TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" - TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" - TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" + TRCE Checking for atmos.yaml in system config path=/usr/local/etc/atmos + TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos + TRCE Checking for atmos.yaml in working directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Using test Git root override path=/mock-git-root + TRCE Checking for .atmos.d in git root path=/mock-git-root + TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root + TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root + TRCE Checking for .atmos.d in config directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Checking for atmos.yaml from ATMOS_CLI_CONFIG_PATH path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Using test Git root override path=/mock-git-root + TRCE Checking for .atmos.d in git root path=/mock-git-root + TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root + TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root + TRCE Checking for .atmos.d in config directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level TRCE Git root base path disabled via ATMOS_GIT_ROOT_BASEPATH=false DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false - TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" - TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" - TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" - TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" + TRCE Checking for atmos.yaml in system config path=/usr/local/etc/atmos + TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos + TRCE Checking for atmos.yaml in working directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Using test Git root override path=/mock-git-root + TRCE Checking for .atmos.d in git root path=/mock-git-root + TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root + TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root + TRCE Checking for .atmos.d in config directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Checking for atmos.yaml from ATMOS_CLI_CONFIG_PATH path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Using test Git root override path=/mock-git-root + TRCE Checking for .atmos.d in git root path=/mock-git-root + TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root + TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root + TRCE Checking for .atmos.d in config directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level TRCE Git root base path disabled via ATMOS_GIT_ROOT_BASEPATH=false DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false - TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" - TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" - TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" - TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" + TRCE Checking for atmos.yaml in system config path=/usr/local/etc/atmos + TRCE Checking for atmos.yaml in home directory path=/mock-home/.atmos + TRCE Checking for atmos.yaml in working directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Using test Git root override path=/mock-git-root + TRCE Checking for .atmos.d in git root path=/mock-git-root + TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root + TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root + TRCE Checking for .atmos.d in config directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Checking for atmos.yaml from ATMOS_CLI_CONFIG_PATH path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Using test Git root override path=/mock-git-root + TRCE Checking for .atmos.d in git root path=/mock-git-root + TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root + TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/mock-git-root + TRCE Checking for .atmos.d in config directory path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Failed to load atmos.d configs error="failed to parse file: failed to search for configuration files in atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level + TRCE Failed to load .atmos.d configs error="failed to parse file: failed to search for configuration files in .atmos.d: failed to find matching files: no files matching patterns found" path=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level TRCE Git root base path disabled via ATMOS_GIT_ROOT_BASEPATH=false DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false diff --git a/tests/test-cases/log-level-validation.yaml b/tests/test-cases/log-level-validation.yaml index 7c9a2905ef..631b415cd6 100644 --- a/tests/test-cases/log-level-validation.yaml +++ b/tests/test-cases/log-level-validation.yaml @@ -54,6 +54,8 @@ tests: expect: diff: - 'šŸ‘½ Atmos (\d+\.\d+\.\d+|test) on [a-z]+/[a-z0-9]+' + - 'TRCE.*Checking for atmos.yaml in system config' + - 'TRCE.*Checking for atmos.yaml in home directory' stdout: - '^\nšŸ‘½ Atmos (\d+\.\d+\.\d+|test) on [a-z]+/[a-z0-9_]+\n\n$' exit_code: 0 diff --git a/website/blog/2025-11-11-parent-directory-search-and-git-root-discovery.mdx b/website/blog/2025-11-11-parent-directory-search-and-git-root-discovery.mdx new file mode 100644 index 0000000000..ad10f61c17 --- /dev/null +++ b/website/blog/2025-11-11-parent-directory-search-and-git-root-discovery.mdx @@ -0,0 +1,38 @@ +--- +slug: parent-directory-search-and-git-root-discovery +title: "Parent Directory Search and Git Root Discovery" +authors: [aknysh] +tags: [feature, dx] +date: 2025-11-11 +release: v1.198.0 +--- + +Atmos now searches parent directories for `atmos.yaml` and discovers `.atmos.d/` at the git repository root, making it easier to run commands from anywhere in your project. + + + +## Parent Directory Search + +Atmos now automatically searches parent directories for `atmos.yaml` when not found in the current directory. Run Atmos from any subdirectory without specifying `--config-path`: + +```bash +cd /repo/components/terraform/vpc +atmos terraform plan vpc -s prod # Finds /repo/atmos.yaml automatically +``` + +## Git Root Discovery for `.atmos.d/` + +Atmos automatically discovers `.atmos.d/` at your git repository root, even when running from subdirectories. Define shared custom commands once at the repo root and use them from anywhere. + +``` +repo/ +ā”œā”€ā”€ .atmos.d/ +│ └── commands.yaml # Available repo-wide +ā”œā”€ā”€ atmos.yaml +└── components/terraform/vpc/ + └── main.tf # Run atmos from here +``` + +## Documentation + +- [CLI Configuration](/cli/configuration) — Parent directory search, git root discovery, and `.atmos.d/` auto-imports diff --git a/website/blog/2025-12-09-working-directory-support.mdx b/website/blog/2025-12-09-working-directory-support.mdx new file mode 100644 index 0000000000..20de9fc464 --- /dev/null +++ b/website/blog/2025-12-09-working-directory-support.mdx @@ -0,0 +1,47 @@ +--- +slug: working-directory-support +title: "Working Directory Support for Commands and Workflows" +authors: [osterman] +tags: [feature, dx] +date: 2025-12-09 +--- + +Custom commands and workflow steps can now specify a `working_directory` to control where they execute. + + + +## Working Directory for Commands and Workflows + +Custom commands and workflow steps can now specify a `working_directory` to control where they execute: + +```yaml +# .atmos.d/commands.yaml +commands: + - name: localstack + description: Start LocalStack for local development + working_directory: docker/localstack + steps: + - docker compose up -d +``` + +```yaml +# stacks/workflows/deploy.yaml +workflows: + build-and-deploy: + steps: + - command: make build + working_directory: !repo-root + type: shell + - command: docker compose up -d + working_directory: docker/app + type: shell +``` + +- **Absolute paths** are used as-is +- **Relative paths** resolve against `base_path` +- **Step-level** overrides workflow-level settings + +## Documentation + +- [Custom Commands](/cli/configuration/commands#working-directory) +- [Workflows](/cli/configuration/workflows#working-directory) diff --git a/website/docs/cli/configuration/commands.mdx b/website/docs/cli/configuration/commands.mdx index 825d1f5065..9a4ddb8fef 100644 --- a/website/docs/cli/configuration/commands.mdx +++ b/website/docs/cli/configuration/commands.mdx @@ -454,6 +454,60 @@ commands: {{ end }} ``` +## Working Directory + +Custom commands can specify a `working_directory` field to control where the command steps execute. This is useful when commands need to run from a specific location, regardless of where `atmos` was invoked. + +### Path Resolution + +- **Absolute paths** are used as-is (e.g., `/tmp`, `/home/user/scripts`) +- **Relative paths** are resolved against the Atmos `base_path` +- The `!repo-root` YAML function can be used to reference the git repository root + +### Example: Run from Repository Root + +```yaml +commands: + - name: build + description: Build the project from repository root + working_directory: !repo-root . + steps: + - make build + - make test +``` + +This ensures the build commands run from the repository root, even if you invoke `atmos build` from a subdirectory. + +### Example: Run in Temp Directory + +```yaml +commands: + - name: download-tools + description: Download and extract tools in /tmp + working_directory: /tmp + steps: + - wget https://example.com/tools.tar.gz + - tar -xzf tools.tar.gz +``` + +### Example: Run in Component Directory + +```yaml +commands: + - name: component-init + description: Initialize a component + working_directory: components/terraform/vpc + steps: + - terraform init + - terraform validate +``` + +Since `components/terraform/vpc` is a relative path, it will be resolved against `base_path`. + +:::tip +Use `working_directory: !repo-root .` when defining commands in `.atmos.d/` at the repository root. This ensures commands work correctly when invoked from any subdirectory in your project. +::: + ## Using Authentication with Custom Commands Custom commands can specify an `identity` field to authenticate before execution. This is useful when commands need to interact with cloud resources that require specific credentials or elevated permissions. diff --git a/website/docs/cli/configuration/configuration.mdx b/website/docs/cli/configuration/configuration.mdx index ba322dee14..285b8c4c23 100644 --- a/website/docs/cli/configuration/configuration.mdx +++ b/website/docs/cli/configuration/configuration.mdx @@ -31,11 +31,37 @@ Atmos discovers configuration from multiple sources in the following precedence 1. **Command-line flags** (`--config`, `--config-path`) 2. **Environment variable** (`ATMOS_CLI_CONFIG_PATH`) -3. **Current directory** (`./atmos.yaml`) -4. **Home directory** (`~/.atmos/atmos.yaml`) -5. **System directory** (`/usr/local/etc/atmos/atmos.yaml` on Linux, `%LOCALAPPDATA%/atmos/atmos.yaml` on Windows) +3. **Profiles** (`--profile` or `ATMOS_PROFILE`) — Named configuration overrides applied on top of base config +4. **Current directory** (`./atmos.yaml`) +5. **Parent directories** (walks up to the filesystem root) +6. **Git repository root** (`.atmos.d/` auto-imports) +7. **Home directory** (`~/.atmos/atmos.yaml`) +8. **System directory** (`/usr/local/etc/atmos/atmos.yaml` on Linux, `%LOCALAPPDATA%/atmos/atmos.yaml` on Windows) -Each configuration file discovered is deep-merged with the preceding configurations. +Each configuration file discovered is deep-merged with the preceding configurations. Separately, Atmos may also auto-import configuration fragments from `.atmos.d/`. The git repository root is special: even when `atmos.yaml` is found elsewhere, Atmos always checks for `.atmos.d/` at the repo root to load shared fragments like custom commands. + +Profiles are a first-class concept that allow environment-specific configuration overrides. When activated via `--profile` flag or `ATMOS_PROFILE` environment variable, profile settings are merged on top of the base configuration. Multiple profiles can be activated and merged left-to-right. See [Profiles](/cli/configuration/profiles) for complete documentation. + +### Parent Directory Search + +When no `atmos.yaml` is found in the current directory, Atmos automatically searches parent directories up to the filesystem root. This enables running Atmos from any subdirectory without specifying `--config-path`. + +```bash +# Repository structure: +# /repo/ +# atmos.yaml # ← Found via parent search +# components/ +# terraform/ +# vpc/ # ← Running from here +# main.tf + +cd /repo/components/terraform/vpc +atmos terraform plan vpc -s prod # Automatically finds /repo/atmos.yaml +``` + +**Disabling parent directory search:** + +Set `ATMOS_CLI_CONFIG_PATH` to any value (including `.`) to disable parent directory searching and use only the specified path. ### Loading Multiple Configurations @@ -48,6 +74,32 @@ atmos --config /path/to/config1.yaml --config /path/to/config2.yaml \ Configurations are deep-merged in the order provided, with later configurations overriding earlier ones. +### Profiles + +Profiles provide named sets of configuration overrides that can be activated at runtime. This is the recommended way to manage environment-specific settings (development, CI/CD, production) without modifying your base configuration. + +```bash +# Activate a profile via flag +atmos --profile developer terraform plan vpc -s prod + +# Activate via environment variable +export ATMOS_PROFILE=ci +atmos terraform apply --auto-approve + +# Activate multiple profiles (merged left-to-right) +atmos --profile base,developer,debug describe config +``` + +Profiles are discovered from multiple locations: +1. Custom path configured in `profiles.base_path` +2. `.atmos/profiles/` (project-local) +3. `~/.config/atmos/profiles/` (XDG user profiles) +4. `profiles/` (project-local) + +When multiple profiles are activated, they are merged in order. Later profiles override earlier ones. + +For complete documentation on creating and using profiles, see [Profiles](/cli/configuration/profiles). + ### Glob Patterns Atmos supports [POSIX-style Glob patterns](https://en.wikipedia.org/wiki/Glob_(programming)) for file discovery: @@ -194,34 +246,43 @@ Atmos automatically imports configuration fragments from special directories, en - `.atmos.d/` (preferred) - `atmos.d/` (alternate) +**Discovery locations (in precedence order, lowest to highest):** +1. **Git repository root** — `.atmos.d/` at the repo root is always discovered, even when running from subdirectories +2. **Configuration directory** — `.atmos.d/` next to `atmos.yaml` (overrides git root) + +This means you can place shared custom commands at the repository root and they'll be available from any subdirectory, while still allowing project-specific overrides. + **How it works:** 1. Atmos recursively discovers all `.yaml` and `.yml` files in these directories 2. Files are sorted by directory depth (shallower first), then alphabetically 3. Each file is automatically merged into the configuration -4. Processing occurs after main config merges but before explicit `import:` entries +4. Git root `.atmos.d` is loaded first (lower priority), then config directory `.atmos.d` (higher priority) +5. Processing occurs after main config merges but before explicit `import:` entries **Example structure:** ``` -project/ -ā”œā”€ā”€ atmos.yaml # Main configuration -└── .atmos.d/ - ā”œā”€ā”€ commands/ - │ └── custom.yaml # Custom commands - ā”œā”€ā”€ profiles/ - │ └── ci.yaml # CI profile - └── settings/ - └── team.yaml # Team-specific settings +repo/ # Git repository root +ā”œā”€ā”€ .atmos.d/ +│ └── commands.yaml # Shared commands available repo-wide +ā”œā”€ā”€ atmos.yaml # Main configuration +ā”œā”€ā”€ components/ +│ └── terraform/ +│ └── vpc/ +│ └── main.tf # ← Can run atmos from here ``` +Running from `repo/components/terraform/vpc/`: +- Commands from `repo/.atmos.d/commands.yaml` are automatically discovered +- No need to specify `--config-path` or change directories + **Use cases:** - **Custom commands** — Drop in command definitions without modifying `atmos.yaml` -- **Profiles** — Add environment-specific profiles for CI/CD -- **Team settings** — Share team-specific defaults across projects -- **Local overrides** — Git-ignored local configuration fragments +- **Repo-wide commands** — Define commands at repo root, use from any subdirectory +- **Local overrides** — Git-ignored local configuration fragments (add `.atmos.d/local/` to `.gitignore`) :::tip -The `.atmos.d/` directory is ideal for configuration that shouldn't be in the main `atmos.yaml`, such as local developer settings (add `.atmos.d/local/` to `.gitignore`) or dynamically generated configuration. +The `.atmos.d/` directory is ideal for configuration that shouldn't be in the main `atmos.yaml`, such as local developer settings or dynamically generated configuration. For environment-specific configuration overrides, use [Profiles](/cli/configuration/profiles) instead. ::: ### Windows Path Handling diff --git a/website/docs/cli/configuration/workflows.mdx b/website/docs/cli/configuration/workflows.mdx index 90f19b2cf9..f46dc15a6a 100644 --- a/website/docs/cli/configuration/workflows.mdx +++ b/website/docs/cli/configuration/workflows.mdx @@ -72,6 +72,75 @@ workflows: ``` +## Working Directory + +Workflows support a `working_directory` setting that controls where steps execute. This can be set at both the workflow level (applies to all steps) and individual step level (overrides workflow setting). + +### Path Resolution + +- **Absolute paths** are used as-is (e.g., `/tmp`) +- **Relative paths** are resolved against the Atmos `base_path` +- The `!repo-root` YAML function can dynamically resolve to the git repository root + +### Example: Workflow-Level Working Directory + + +```yaml +workflows: + build-all: + description: Build from repository root + working_directory: !repo-root + steps: + - command: make build + type: shell + - command: make test + type: shell +``` + + +All steps inherit the workflow's `working_directory` setting. + +### Example: Step-Level Override + + +```yaml +workflows: + download-and-install: + description: Download files to /tmp, then install from repo root + working_directory: !repo-root + steps: + - command: wget https://example.com/archive.tar.gz + working_directory: /tmp + type: shell + - command: tar -xzf /tmp/archive.tar.gz + type: shell + - command: make install + type: shell +``` + + +The first step overrides the workflow setting to run in `/tmp`, while subsequent steps use the workflow-level `working_directory` (repo root). + +### Example: Mixed Atmos and Shell Commands + + +```yaml +workflows: + deploy-and-verify: + description: Deploy infrastructure and run verification scripts + steps: + - command: terraform apply vpc + stack: plat-ue2-dev + - command: ./scripts/verify-vpc.sh + working_directory: !repo-root + type: shell +``` + + +:::tip +Use `working_directory` when your workflow includes shell commands that depend on relative file paths, or when you need to run commands like `terraform import` directly in a component directory. +::: + ## Environment Variables