Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,12 @@ When a user runs `atmos terraform plan vpc -s dev` from a subdirectory (e.g.,

### Why Our Fixes Don't Break It

**Fix 1 (struct field resolution)**: `resolveSimpleRelativeBasePath()` only runs on
`configAndStacksInfo.AtmosBasePath` — the struct field set by `--base-path` flag or the
`atmos_base_path` provider parameter. It does NOT affect `atmosConfig.BasePath` loaded from
`atmos.yaml` or the default empty value. Normal Atmos CLI usage doesn't set `AtmosBasePath`.
**Fix 1 (source-aware resolution)**: `resolveAbsolutePath()` now accepts a `source` parameter.
When the base path comes from a runtime source (env var, CLI flag, provider parameter), the
`BasePathSource` field is set to `"runtime"`. This only affects dot-prefixed paths (`"."`,
`"./foo"`, `".."`), which resolve relative to CWD for runtime sources instead of config dir.
Bare paths (`"stacks"`, `"foo/bar"`) go through the same git root search regardless of source.
Normal Atmos CLI usage with `base_path` in atmos.yaml is unaffected.

**Fix 2 (`os.Stat` fallback in `tryResolveWithGitRoot`)**: The added `os.Stat` check validates
the git-root-joined path exists before returning it. For normal projects:
Expand Down
467 changes: 220 additions & 247 deletions docs/prd/base-path-resolution-semantics.md

Large diffs are not rendered by default.

425 changes: 325 additions & 100 deletions pkg/config/base_path_resolution_test.go

Large diffs are not rendered by default.

118 changes: 48 additions & 70 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ func InitCliConfig(configAndStacksInfo schema.ConfigAndStacksInfo, processStacks
}
// Process the base path specified by the Terraform provider `atmos_base_path` parameter
// or CLI `--base-path` flag. This overrides all other base path configs.
// Convert to absolute path immediately (relative to CWD) to prevent resolveAbsolutePath
// from routing it through git root discovery — explicitly provided paths are always
// CWD-relative, not git-root-relative.
// Mark as runtime source so resolveAbsolutePath uses CWD for dot-prefixed paths
// instead of config-dir. Bare paths still go through git root search.
if configAndStacksInfo.AtmosBasePath != "" {
atmosConfig.BasePath = resolveSimpleRelativeBasePath(configAndStacksInfo.AtmosBasePath)
atmosConfig.BasePath = configAndStacksInfo.AtmosBasePath
atmosConfig.BasePathSource = "runtime"
}

// After unmarshalling, ensure AppendUserAgent is set if still empty
Expand Down Expand Up @@ -208,69 +208,23 @@ func processAtmosConfigs(configAndStacksInfo *schema.ConfigAndStacksInfo) (schem
return atmosConfig, nil
}

// atmosConfigAbsolutePaths converts paths to absolute paths.
// AtmosConfigAbsolutePaths converts all base paths in the configuration to absolute paths.
// This function sets TerraformDirAbsolutePath, HelmfileDirAbsolutePath, PackerDirAbsolutePath,
// StacksBaseAbsolutePath, IncludeStackAbsolutePaths, and ExcludeStackAbsolutePaths.
// It converts a path to absolute form, resolving relative paths according to the semantics below.
// See docs/prd/base-path-resolution-semantics.md for the full convention.
//
// Resolution semantics (see docs/prd/base-path-resolution-semantics.md):
// Value categories (see PRD "Core Convention: Empty vs Dot vs Bare"):
// - Empty ("") → git root → config dir → CWD (smart default)
// - Dot (".", "./foo", "..", "../foo") → source-dependent anchor:
// config-file source → config dir; runtime source → CWD
// - Bare ("foo", "foo/bar") → git root search, source-independent
// - Absolute ("/abs/path") → pass through
//
// 1. Absolute paths → return as-is
// 2. Explicit relative paths → resolve relative to cliConfigPath (config-file-relative):
// - Exactly "." or ".."
// - Starts with "./" or "../" (Unix)
// - Starts with ".\" or "..\" (Windows)
// 3. "" (empty) or simple paths like "foo" → try git root, fallback to cliConfigPath
// The source parameter controls how dot-prefixed paths (".", "./foo", "..", "../foo") resolve:
// - "runtime" (env var, CLI flag, provider param): dot = CWD (shell convention)
// - "" or "config" (atmos.yaml): dot = config dir (config-file convention)
//
// Fallback order when primary resolution fails:
// 1. Git repository root
// 2. Config directory (cliConfigPath / dirname(atmos.yaml))
// 3. CWD (last resort)
//
// Key semantic distinctions:
// - "." means dirname(atmos.yaml) (config-file-relative)
// - "" means git repo root with fallback to dirname(atmos.yaml) (smart default)
// - "./foo" means dirname(atmos.yaml)/foo (config-file-relative)
// - "foo" means git-root/foo with fallback to dirname(atmos.yaml)/foo (search path)
// - ".." or "../foo" means dirname(atmos.yaml)/../foo (config-file-relative navigation)
//
// This follows the convention of tsconfig.json, package.json, .eslintrc - paths in
// resolveSimpleRelativeBasePath converts "simple relative" base paths to absolute (CWD-relative).
// Simple relative paths are those that don't start with ".", "..", "./", or "../" and are not
// absolute. These paths would otherwise be mis-routed through git root discovery by
// resolveAbsolutePath. Paths starting with "." or ".." are intentionally config-file-relative
// and are returned unchanged for resolveAbsolutePath to handle.
func resolveSimpleRelativeBasePath(basePath string) string {
if basePath == "" || filepath.IsAbs(basePath) {
return basePath
}

sep := string(filepath.Separator)

isConfigRelative := basePath == "." ||
basePath == ".." ||
strings.HasPrefix(basePath, "./") ||
strings.HasPrefix(basePath, "."+sep) ||
strings.HasPrefix(basePath, "../") ||
strings.HasPrefix(basePath, ".."+sep)

if isConfigRelative {
return basePath
}

// Simple relative path — resolve to CWD.
absPath, err := filepath.Abs(basePath)
if err != nil {
return basePath
}

return absPath
}

// config files are relative to the config file location, not where you run from.
// Use the !cwd YAML tag if you need paths relative to CWD.
func resolveAbsolutePath(path string, cliConfigPath string) (string, error) {
// Bare paths ("foo", "stacks") always go through git root search regardless of source.
// See docs/prd/base-path-resolution-semantics.md for the full convention.
func resolveAbsolutePath(path string, cliConfigPath string, source string) (string, error) {
// If already absolute, return as-is.
if filepath.IsAbs(path) {
return path, nil
Expand All @@ -288,9 +242,30 @@ func resolveAbsolutePath(path string, cliConfigPath string) (string, error) {
strings.HasPrefix(path, "../") ||
strings.HasPrefix(path, ".."+sep)

// For explicit relative paths (".", "./...", "..", "../..."):
// Resolve relative to config directory (cliConfigPath).
if isExplicitRelative && cliConfigPath != "" {
// For dot-prefixed paths: resolve based on source.
if isExplicitRelative {
return resolveDotPrefixPath(path, cliConfigPath, source)
}

// For empty path or simple relative paths (like "stacks", "components/terraform"):
// Try git root first.
return tryResolveWithGitRoot(path, isExplicitRelative, cliConfigPath)
}

// resolveDotPrefixPath resolves dot-prefixed paths (".", "./foo", "..", "../foo").
// The anchor depends on the source: runtime → CWD, config → config directory.
func resolveDotPrefixPath(path, cliConfigPath, source string) (string, error) {
if source == "runtime" {
// Runtime source: dot means CWD (shell convention).
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("resolving path %q relative to CWD: %w", path, err)
}
return absPath, nil
}

// Config source: dot means config directory (config-file convention).
if cliConfigPath != "" {
basePath := filepath.Join(cliConfigPath, path)
absPath, err := filepath.Abs(basePath)
if err != nil {
Expand All @@ -299,9 +274,12 @@ func resolveAbsolutePath(path string, cliConfigPath string) (string, error) {
return absPath, nil
}

// For empty path or simple relative paths (like "stacks", "components/terraform"):
// Try git root first.
return tryResolveWithGitRoot(path, isExplicitRelative, cliConfigPath)
// No config path: fall back to CWD (last resort).
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("resolving path %q: %w", path, err)
}
return absPath, nil
}

// tryResolveWithGitRoot attempts to resolve a path using git root as the base.
Expand Down Expand Up @@ -431,7 +409,7 @@ func AtmosConfigAbsolutePaths(atmosConfig *schema.AtmosConfiguration) error {
// Relative paths are resolved relative to atmos.yaml location (atmosConfig.CliConfigPath).
var atmosBasePathAbs string
var err error
atmosBasePathAbs, err = resolveAbsolutePath(atmosConfig.BasePath, atmosConfig.CliConfigPath)
atmosBasePathAbs, err = resolveAbsolutePath(atmosConfig.BasePath, atmosConfig.CliConfigPath, atmosConfig.BasePathSource)
if err != nil {
return err
}
Expand Down
28 changes: 13 additions & 15 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1114,7 +1114,7 @@ func TestResolveAbsolutePath(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := resolveAbsolutePath(tt.path, tt.cliConfigPath)
result, err := resolveAbsolutePath(tt.path, tt.cliConfigPath, "")
require.NoError(t, err)

if filepath.IsAbs(tt.path) {
Expand Down Expand Up @@ -1269,32 +1269,30 @@ func TestDotPathResolvesRelativeToConfigDir(t *testing.T) {
err = os.Unsetenv("ATMOS_BASE_PATH")
require.NoError(t, err)

t.Run("ATMOS_BASE_PATH=. should resolve to config dir (config-file-relative)", func(t *testing.T) {
// This test verifies that "." resolves relative to where atmos.yaml is located,
// NOT relative to CWD. This follows the convention of other config files.
//
// Users who need CWD-relative behavior should use the !cwd YAML tag:
// - base_path: !cwd
t.Run("ATMOS_BASE_PATH=. should resolve to CWD (shell convention)", func(t *testing.T) {
// This test verifies that "." from an env var (runtime source) resolves to CWD,
// NOT config dir. In shell context, "." means "here" = where the command runs.
// This follows the unified convention: dot-prefix anchors to context
// (config dir in yaml, CWD in shell).
changeWorkingDir(t, "../../tests/fixtures/scenarios/complete/components/terraform/top-level-component1")

cwd, err := os.Getwd()
require.NoError(t, err)

// Point to the repo root where atmos.yaml is located.
configPath := "../../.."
t.Setenv("ATMOS_CLI_CONFIG_PATH", configPath)
// Set base_path to "." - this should resolve to config dir (where atmos.yaml is).
// Set base_path to "." via env var — runtime source → CWD.
t.Setenv("ATMOS_BASE_PATH", ".")
// Disable git root discovery for this test.
t.Setenv("ATMOS_GIT_ROOT_BASEPATH", "false")

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

// Get the absolute path of the config directory.
configDir, err := filepath.Abs(configPath)
require.NoError(t, err)

// BasePathAbsolute should be the config directory, not CWD.
assert.Equal(t, configDir, cfg.BasePathAbsolute,
"Base path with '.' should resolve to config directory (config-file-relative)")
// BasePathAbsolute should be CWD (shell convention for runtime source).
assert.Equal(t, cwd, cfg.BasePathAbsolute,
"ATMOS_BASE_PATH=. should resolve to CWD (shell convention), not config dir")
})

t.Run("base_path=. when CWD equals config dir resolves to config dir", func(t *testing.T) {
Expand Down
2 changes: 2 additions & 0 deletions pkg/config/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ func processEnvVars(atmosConfig *schema.AtmosConfiguration) error {
if len(basePath) > 0 {
log.Debug(foundEnvVarMessage, "ATMOS_BASE_PATH", basePath)
atmosConfig.BasePath = basePath
atmosConfig.BasePathSource = "runtime"
}

vendorBasePath := os.Getenv("ATMOS_VENDOR_BASE_PATH")
Expand Down Expand Up @@ -573,6 +574,7 @@ func processCommandLineArgs(atmosConfig *schema.AtmosConfiguration, configAndSta
func setBasePaths(atmosConfig *schema.AtmosConfiguration, configAndStacksInfo *schema.ConfigAndStacksInfo) error {
if len(configAndStacksInfo.BasePath) > 0 {
atmosConfig.BasePath = configAndStacksInfo.BasePath
atmosConfig.BasePathSource = "runtime"
log.Debug(cmdLineArg, BasePathFlag, configAndStacksInfo.BasePath)
}
return nil
Expand Down
1 change: 1 addition & 0 deletions pkg/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type ConfigMetadata struct {
// AtmosConfiguration structure represents schema for `atmos.yaml` CLI config.
type AtmosConfiguration struct {
BasePath string `yaml:"base_path" json:"base_path" mapstructure:"base_path"`
BasePathSource string `yaml:"-" json:"-" mapstructure:"-"` // "runtime" if from env var/CLI/provider, "" if from config file.
Components Components `yaml:"components" json:"components" mapstructure:"components"`
Stacks Stacks `yaml:"stacks" json:"stacks" mapstructure:"stacks"`
Workflows Workflows `yaml:"workflows,omitempty" json:"workflows,omitempty" mapstructure:"workflows"`
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions tests/test-cases/component-path-resolution.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ tests:
command: "atmos"
env:
ATMOS_CLI_CONFIG_PATH: "../../.."
ATMOS_BASE_PATH: "."
ATMOS_BASE_PATH: "../../.."
args:
- "terraform"
- "plan"
Expand All @@ -30,7 +30,7 @@ tests:
command: "atmos"
env:
ATMOS_CLI_CONFIG_PATH: "../.."
ATMOS_BASE_PATH: "."
ATMOS_BASE_PATH: "../.."
args:
- "terraform"
- "plan"
Expand Down Expand Up @@ -111,7 +111,7 @@ tests:
command: "atmos"
env:
ATMOS_CLI_CONFIG_PATH: "../.."
ATMOS_BASE_PATH: "."
ATMOS_BASE_PATH: "../.."
args:
- "terraform"
- "plan"
Expand Down Expand Up @@ -210,7 +210,7 @@ tests:
command: "atmos"
env:
ATMOS_CLI_CONFIG_PATH: "../.."
ATMOS_BASE_PATH: "."
ATMOS_BASE_PATH: "../.."
args:
- "describe"
- "component"
Expand Down Expand Up @@ -309,7 +309,7 @@ tests:
command: "atmos"
env:
ATMOS_CLI_CONFIG_PATH: "../.."
ATMOS_BASE_PATH: "."
ATMOS_BASE_PATH: "../.."
args:
- "validate"
- "component"
Expand Down
Loading