diff --git a/NOTICE b/NOTICE index 105df21b8f..83ff011e85 100644 --- a/NOTICE +++ b/NOTICE @@ -43,7 +43,7 @@ APACHE 2.0 LICENSED DEPENDENCIES - cloud.google.com/go/monitoring License: Apache-2.0 - URL: https://github.com/googleapis/google-cloud-go/blob/monitoring/v1.24.2/monitoring/LICENSE + URL: https://github.com/googleapis/google-cloud-go/blob/monitoring/v1.24.3/monitoring/LICENSE - cloud.google.com/go/secretmanager License: Apache-2.0 @@ -495,11 +495,11 @@ APACHE 2.0 LICENSED DEPENDENCIES - google.golang.org/genproto/googleapis License: Apache-2.0 - URL: https://github.com/googleapis/go-genproto/blob/9219d122eba9/LICENSE + URL: https://github.com/googleapis/go-genproto/blob/ff82c1b0f217/LICENSE - google.golang.org/genproto/googleapis/api License: Apache-2.0 - URL: https://github.com/googleapis/go-genproto/blob/95abcf5c77ba/googleapis/api/LICENSE + URL: https://github.com/googleapis/go-genproto/blob/ff82c1b0f217/googleapis/api/LICENSE - google.golang.org/genproto/googleapis/rpc License: Apache-2.0 @@ -652,7 +652,7 @@ BSD LICENSED DEPENDENCIES - github.com/googleapis/gax-go/v2 License: BSD-3-Clause - URL: https://github.com/googleapis/gax-go/blob/v2.15.0/v2/LICENSE + URL: https://github.com/googleapis/gax-go/blob/v2.16.0/v2/LICENSE - github.com/gorilla/css/scanner License: BSD-3-Clause @@ -1026,7 +1026,7 @@ MIT LICENSED DEPENDENCIES - github.com/alecthomas/chroma/v2 License: MIT - URL: https://github.com/alecthomas/chroma/blob/v2.21.0/COPYING + URL: https://github.com/alecthomas/chroma/blob/v2.21.1/COPYING - github.com/alecthomas/participle/v2/lexer License: MIT @@ -1250,7 +1250,7 @@ MIT LICENSED DEPENDENCIES - github.com/goccy/go-yaml License: MIT - URL: https://github.com/goccy/go-yaml/blob/v1.19.0/LICENSE + URL: https://github.com/goccy/go-yaml/blob/v1.19.1/LICENSE - github.com/golang-jwt/jwt/v4 License: MIT diff --git a/cmd/auth_workflows_test.go b/cmd/auth_workflows_test.go index 032e4e52a5..169c49c1fd 100644 --- a/cmd/auth_workflows_test.go +++ b/cmd/auth_workflows_test.go @@ -2,6 +2,7 @@ package cmd import ( "os" + "runtime" "strings" "testing" "time" @@ -103,6 +104,10 @@ func TestAuth_EnvCommand_E2E(t *testing.T) { // TestAuth_ExecCommand_E2E tests the complete workflow of logging in and // using the auth exec command to run commands with authenticated environment. func TestAuth_ExecCommand_E2E(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skipf("Skipping test on Windows: uses Unix commands (echo, sh -c)") + } + tk := NewTestKit(t) _ = setupMockAuthDir(t, tk) diff --git a/docs/fixes/path-resolution-regression.md b/docs/fixes/path-resolution-regression.md new file mode 100644 index 0000000000..b0379481a4 --- /dev/null +++ b/docs/fixes/path-resolution-regression.md @@ -0,0 +1,244 @@ +# Issue #1858: Path Resolution Regression Fix + +## Summary + +Fixed a regression introduced in v1.201.0 where relative paths in `atmos.yaml` were resolved incorrectly. The fix +implements correct base path resolution semantics that allow users to run `atmos` from anywhere inside a repository. + +## Issue Description + +**GitHub Issue**: [#1858](https://github.com/cloudposse/atmos/issues/1858) + +**Symptoms**: + +- After upgrading from `v1.200.0` to `v1.201.0`, `atmos validate stacks` fails with: + ```text + The atmos.yaml CLI config file specifies the directory for Atmos stacks as stacks, but the directory does not exist. + ``` + +- This occurs when `ATMOS_CLI_CONFIG_PATH` points to a subdirectory (e.g., `./rootfs/usr/local/etc/atmos`) while the + `stacks/` and `components/` directories are at the repository root. + +**Example Configuration**: + +```yaml +# Located at ./rootfs/usr/local/etc/atmos/atmos.yaml +base_path: "" +stacks: + base_path: "stacks" +components: + terraform: + base_path: "components/terraform" +``` + +## Root Cause + +Commit `2aad133f6` ("fix: resolve ATMOS_BASE_PATH relative to CLI config directory") introduced a new +`resolveAbsolutePath()` function that resolves **all** relative paths relative to `CliConfigPath` (the directory +containing `atmos.yaml`). + +This was intended to fix a legitimate issue where `ATMOS_BASE_PATH="../../.."` wasn't working correctly when running +from component subdirectories. However, the change was too broad and affected all relative paths, not just those +explicitly referencing the config directory. + +## Solution + +Implemented correct base path resolution semantics as defined in the PRD (`docs/prd/base-path-resolution-semantics.md`): + +### Base Path Resolution Rules + +| `base_path` value | Resolves to | Rationale | +| ---------------------- | -------------------------------------------------------- | -------------------------------------------------- | +| `""` (empty/unset) | Git repo root, fallback to dirname(atmos.yaml) | Smart default - most users want repo root | +| `"."` | dirname(atmos.yaml) | Explicit config-dir-relative | +| `"./foo"` | dirname(atmos.yaml)/foo | Explicit config-dir-relative path | +| `".."` | Parent of dirname(atmos.yaml) | Config-dir-relative parent traversal | +| `"../foo"` | dirname(atmos.yaml)/../foo | Config-dir-relative parent traversal | +| `"foo"` or `"foo/bar"` | Git repo root/foo, fallback to dirname(atmos.yaml)/foo | Simple relative paths anchor to repo root | +| `"/absolute/path"` | /absolute/path (unchanged) | Absolute paths are explicit | +| `!repo-root` | Git repository root | Explicit git root tag | +| `!cwd` | Current working directory | Explicit CWD tag | + +### Key Semantic Distinctions + +1. **`.` vs `""`**: These are NOT the same + - `"."` = explicit config directory (dirname of atmos.yaml) + - `""` = smart default (git repo root with fallback to config dir) + +2. **`./foo` vs `foo`**: These are NOT the same + - `"./foo"` = config-dir/foo (explicit config-dir-relative) + - `"foo"` = git-root/foo with fallback to config-dir/foo + +3. **`../foo`**: Always relative to atmos.yaml location + - Used for navigating from config location to elsewhere in repo + - Common pattern: config in subdirectory, `base_path: ".."` to reach parent dir + +4. **`!cwd` vs `"."`**: These are NOT the same + - `!cwd` = current working directory (where you run atmos from) + - `"."` = config directory (where atmos.yaml is located) + +### Code Change + +The core logic is in `resolveAbsolutePath()` in `pkg/config/config.go`: + +```go +func resolveAbsolutePath(path string, cliConfigPath string) (string, error) { + // Absolute paths unchanged. + if filepath.IsAbs(path) { + return path, nil + } + + // Check for explicit relative paths: ".", "./...", "..", or "../..." + // These resolve relative to atmos.yaml location (config-file-relative). + isExplicitRelative := path == "." || + path == ".." || + strings.HasPrefix(path, "./") || + strings.HasPrefix(path, "../") + + // For explicit relative paths (".", "./...", "..", "../..."): + // Resolve relative to config directory (cliConfigPath). + if isExplicitRelative && cliConfigPath != "" { + basePath := filepath.Join(cliConfigPath, path) + return filepath.Abs(basePath) + } + + // For empty path or simple relative paths (like "stacks", "components/terraform"): + // Try git root first, fallback to config dir, then CWD. + return tryResolveWithGitRoot(path, isExplicitRelative, cliConfigPath) +} + +func tryResolveWithGitRoot(path string, isExplicitRelative bool, cliConfigPath string) (string, error) { + gitRoot := getGitRootOrEmpty() + if gitRoot == "" { + return tryResolveWithConfigPath(path, cliConfigPath) + } + + // Git root available - resolve relative to it. + if path == "" { + return gitRoot, nil + } + return filepath.Join(gitRoot, path), nil +} +``` + +## Test Coverage + +Added comprehensive tests: + +1. **`pkg/config/config_test.go`**: Unit tests for path resolution logic covering: + - Absolute paths remain unchanged + - Empty path resolves to git repo root (or atmos.yaml dir if not in git repo) + - Dot path (`.`) resolves to config directory (dirname of atmos.yaml) + - Paths starting with `./` resolve to config directory + - Paths starting with `../` resolve relative to config directory + - Simple relative paths resolve to git repo root (with fallback to config dir) + +2. **`pkg/config/config_path_comprehensive_edge_cases_test.go`**: Comprehensive edge case tests + +3. **`pkg/utils/git_test.go`**: Tests for `!cwd` and `!repo-root` YAML tag processing: + - `TestProcessTagCwd`: Tests `!cwd` tag with various path arguments + - `TestProcessTagGitRoot`: Tests `!repo-root` tag with fallback behavior + +## Test Fixtures + +Added test fixture in `tests/fixtures/scenarios/`: + +- **`cli-config-path/`**: Tests the `base_path: ..` scenario where atmos.yaml is in a subdirectory and uses parent + directory traversal to reference the repo root + +## Manual Testing + +You can manually verify the fix using the test fixture at `tests/fixtures/scenarios/cli-config-path/`. + +### Fixture Structure + +```text +tests/fixtures/scenarios/cli-config-path/ +├── components/ +│ └── terraform/ +│ └── test-component/ +│ └── main.tf +├── config/ +│ └── atmos.yaml # Config in subdirectory with base_path: ".." +└── stacks/ + └── dev.yaml +``` + +The `config/atmos.yaml` uses: +- `base_path: ".."` - resolves to parent of config dir (the fixture root) because `..` is an explicit + relative path that resolves relative to the config directory +- `stacks: { base_path: "stacks" }` - simple relative path, resolves to git-root/stacks +- `components: { terraform: { base_path: "components/terraform" } }` - simple relative path, resolves to + git-root/components/terraform + +### Test Commands + +Run these commands from the **repository root** (not the fixture directory): + +```bash +# Build atmos first +make build + +# Navigate to the test fixture +cd tests/fixtures/scenarios/cli-config-path + +# Set ATMOS_CLI_CONFIG_PATH to point to the config subdirectory +export ATMOS_CLI_CONFIG_PATH=./config + +# Test 1: Validate stacks (this was failing in v1.201.0) +../../../../build/atmos validate stacks +# Expected: "✓ All stacks validated successfully" + +# Test 2: List stacks +../../../../build/atmos list stacks +# Expected: "dev" + +# Test 3: List components +../../../../build/atmos list components +# Expected: "test-component" + +# Test 4: Describe component +../../../../build/atmos describe component test-component -s dev +# Expected: Component configuration output (not an error) + +# Test 5: Describe config (verify path resolution) +../../../../build/atmos describe config | grep -E "(basePathAbsolute|stacksBaseAbsolutePath|terraformDirAbsolutePath)" +# Expected: Paths should point to fixture root, not inside config/ +# - basePathAbsolute should end with "cli-config-path" (the fixture root) +# - stacksBaseAbsolutePath should end with "cli-config-path/stacks" +# - terraformDirAbsolutePath should end with "cli-config-path/components/terraform" +``` + +### Understanding the Path Resolution + +Given this fixture structure: +``` +cli-config-path/ <- This is the "fixture root" and git root for path resolution +├── config/ +│ └── atmos.yaml <- ATMOS_CLI_CONFIG_PATH points here +├── stacks/ +│ └── dev.yaml +└── components/terraform/ + └── test-component/ +``` + +With `ATMOS_CLI_CONFIG_PATH=./config`: +1. `base_path: ".."` → resolves to `config/..` = `cli-config-path/` (config-dir-relative) +2. `stacks.base_path: "stacks"` → resolves to `cli-config-path/stacks/` (anchored to base_path) +3. `components.terraform.base_path: "components/terraform"` → resolves to `cli-config-path/components/terraform/` + +### Expected vs. Broken Behavior + +| Command | v1.200.0 (Working) | v1.201.0 (Broken) | This Fix | +| ----------------------- | ------------------ | ------------------------------------ | ------------------ | +| `atmos validate stacks` | ✅ Success | ❌ "stacks directory does not exist" | ✅ Success | +| `atmos list stacks` | ✅ Shows "dev" | ❌ Error | ✅ Shows "dev" | +| `atmos list components` | ✅ Shows component | ❌ Error | ✅ Shows component | + +## Related PRs/Commits + +- **Commit `2aad133f6`**: "fix: resolve ATMOS_BASE_PATH relative to CLI config directory" (introduced the regression) +- **PR #1774**: "Path-based component resolution for all commands" (contained the breaking commit) +- **PR #1868**: This fix - implements correct base path resolution semantics +- **PR #1872**: Enhanced path resolution semantics (merged into #1868) +- **PRD**: `docs/prd/base-path-resolution-semantics.md` (defines correct semantics) diff --git a/docs/prd/base-path-resolution-semantics.md b/docs/prd/base-path-resolution-semantics.md new file mode 100644 index 0000000000..b17b5c361f --- /dev/null +++ b/docs/prd/base-path-resolution-semantics.md @@ -0,0 +1,215 @@ +# PRD: Base Path Resolution Semantics + +## Overview + +This PRD formalizes the resolution semantics for the `base_path` configuration option in `atmos.yaml`. It defines how different `base_path` values are resolved to absolute paths, ensuring predictable behavior regardless of where atmos is executed from. + +## Motivation + +Issue #1858 revealed that the resolution behavior for empty `base_path` was undefined when using `ATMOS_CLI_CONFIG_PATH`. This PRD formalizes the correct semantics: + +- Empty `base_path` should trigger git root discovery, not resolve to the config directory +- Relative paths (`.`, `..`, `./foo`, `../foo`) should be config-file-relative, following the convention of other config files +- Simple relative paths (`foo`) should search git root first, then config directory + +## Requirements + +### Functional Requirements + +#### FR1: Base Path Resolution + +| `base_path` value | Resolves to | Rationale | +|-------------------|-------------|-----------| +| `""`, `~`, `null`, or unset | Search: git root → dirname(atmos.yaml) | Smart default | +| `"."` | dirname(atmos.yaml) | Config-file-relative | +| `".."` | Parent of dirname(atmos.yaml) | Config-file-relative | +| `"./foo"` | dirname(atmos.yaml)/foo | Config-file-relative | +| `"../foo"` | Parent of dirname(atmos.yaml)/foo | Config-file-relative | +| `"foo"` | Search: git root/foo → dirname(atmos.yaml)/foo | Search path | +| `"/absolute/path"` | /absolute/path | Explicit absolute | +| `!repo-root` | Git repository root | Explicit git root tag | +| `!cwd` | Current working directory | Explicit CWD tag | + +**Search order for empty and simple relative paths:** +1. Git repo root (most common - standard repo structure) +2. dirname(atmos.yaml) (fallback when not in git repo) + +#### FR2: Explicit Path Tags + +- `!repo-root` - Resolves to git repository root, with optional default value +- `!cwd` - Resolves to current working directory, with optional relative path + +#### FR3: Git Root Discovery + +Git root discovery applies: +- When `base_path` is empty/unset +- When `base_path` is a simple relative path (no `./` or `../` prefix) +- Regardless of whether config is found via default discovery or `ATMOS_CLI_CONFIG_PATH` + +Git root discovery does NOT apply: +- When `base_path` starts with `.` or `./` (explicit config-relative) +- When `base_path` starts with `../` (explicit config-relative navigation) +- When `base_path` is absolute +- When `ATMOS_GIT_ROOT_BASEPATH=false` + +#### FR4: Environment Variable Interaction + +- `ATMOS_BASE_PATH` - Overrides `base_path` in config file +- `ATMOS_CLI_CONFIG_PATH` - Specifies config file location +- `ATMOS_GIT_ROOT_BASEPATH=false` - Disables git root discovery + +#### FR5: Config File Search Order + +Atmos searches for `atmos.yaml` in the following order (highest to lowest priority): + +| Priority | Source | Description | +|----------|--------|-------------| +| 1 | CLI flags | `--config`, `--config-path` | +| 2 | Environment variable | `ATMOS_CLI_CONFIG_PATH` | +| 3 | Current directory | `./atmos.yaml` (CWD only, no parent search) | +| 4 | Git repository root | `repo-root/atmos.yaml` | +| 5 | Parent directory search | Walks up from CWD looking for `atmos.yaml` | +| 6 | Home directory | `~/.atmos/atmos.yaml` | +| 7 | System directory | `/usr/local/etc/atmos/atmos.yaml` | + +**Note:** Viper deep-merges configurations, so settings from higher-priority sources override those from lower-priority sources. + +### Non-Functional Requirements + +#### NFR1: Testability + +- `TEST_GIT_ROOT` environment variable for test isolation (mocks git root path) + +--- + +## Design Rationale + +### Why `.` and `..` are config-file-relative + +This follows the convention of other configuration files: +- `tsconfig.json` - paths relative to tsconfig location +- `package.json` - paths relative to package.json location +- `.eslintrc` - paths relative to config location +- `Makefile` - includes relative to Makefile location + +**Paths in configuration files are relative to where the config is defined, not where you run the command from.** + +This is the behavior introduced in v1.201.0 (PR #1774) and is intentional. The commit message states: +> "This is the key fix: when ATMOS_BASE_PATH is relative (e.g., "../../.."), we need to resolve it relative to where atmos.yaml is, not relative to CWD." + +Pre-v1.201.0 used CWD-relative (via `filepath.Abs`) which was the bug being fixed. + +### Why empty `base_path` triggers git root discovery + +Empty/unset values conventionally mean "use sensible defaults": +- Git commands work from anywhere in a repository +- Most repos have `atmos.yaml` at root alongside `stacks/` and `components/` +- Empty = "I don't want to specify, figure it out" + +This enables the "run from anywhere" behavior that users expect. + +### Why `!cwd` tag exists + +Users who need CWD-relative behavior can use `!cwd`: + +```yaml +base_path: !cwd +# or with a relative path +base_path: !cwd ./relative/path +``` + +This provides an explicit escape hatch for users who genuinely need paths relative to where atmos is executed from. + +--- + +## Specification + +### Detection Logic + +``` +if path == "" or path is unset: + return git_repo_root() or dirname(atmos.yaml) + +if path is absolute: + return path + +if path == "." or path starts with "./" or path == ".." or path starts with "../": + return dirname(atmos.yaml) / path # Config-file-relative + +# Simple relative path (e.g., "foo", "foo/bar") +return git_repo_root() / path or dirname(atmos.yaml) / path +``` + +### Key Semantic Distinctions + +1. **`""` (empty) vs `"."`**: + - `""` = smart default (git root with fallback to config dir) + - `"."` = explicit config directory (where atmos.yaml lives) + +2. **`"./foo"` vs `"foo"`**: + - `"./foo"` = explicit config-dir-relative (config-dir/foo) + - `"foo"` = search path (git-root/foo → config-dir/foo) + +3. **`!cwd` vs `"."`**: + - `!cwd` = current working directory (where command is run from) + - `"."` = config directory (where atmos.yaml is located) + +--- + +## Test Cases + +``` +# Scenario: atmos.yaml at /repo/config/atmos.yaml, CWD is /repo/src, git root is /repo + +base_path: "" → /repo (git root) +base_path: "." → /repo/config (config dir) +base_path: ".." → /repo (parent of config dir) +base_path: "./foo" → /repo/config/foo (config-dir-relative) +base_path: "../foo" → /repo/foo (parent of config dir) +base_path: "foo" → /repo/foo (git root + foo) +base_path: "foo/bar" → /repo/foo/bar (git root + foo/bar) +base_path: "/abs/path" → /abs/path (absolute) +base_path: !repo-root → /repo (explicit git root) +base_path: !cwd → /repo/src (explicit CWD) + +# Scenario: Same setup but NOT in a git repo + +base_path: "" → /repo/config (fallback to config dir) +base_path: "." → /repo/config (config dir) +base_path: ".." → /repo (parent of config dir) +base_path: "./foo" → /repo/config/foo (config-dir-relative) +base_path: "../foo" → /repo/foo (parent of config dir) +base_path: "foo" → /repo/config/foo (fallback to config-dir + foo) +base_path: "foo/bar" → /repo/config/foo/bar (fallback to config-dir + foo/bar) +base_path: "/abs/path" → /abs/path (absolute) +base_path: !cwd → /repo/src (explicit CWD) +``` + +--- + +## Issue #1858 Resolution + +**User's setup:** +- `ATMOS_CLI_CONFIG_PATH=./rootfs/usr/local/etc/atmos` +- `base_path: ""` in their atmos.yaml +- `stacks/` and `components/` at repo root + +**Before fix (broken):** +- Empty `base_path` resolved to config directory (`/repo/rootfs/usr/local/etc/atmos/`) +- Atmos looked for `stacks/` at `/repo/rootfs/usr/local/etc/atmos/stacks/` +- Directory doesn't exist → error + +**After fix (working):** +- Empty `base_path` triggers git root discovery +- Resolves to `/repo` (git root) +- `stacks/` found at `/repo/stacks/` + +--- + +## References + +- Issue #1858: Path resolution regression report +- PR #1774: Path-based component resolution (introduced config-relative behavior) +- PR #1773: Git root discovery for default base path +- Related PRD: `docs/prd/git-root-discovery-default-behavior.md` +- Related PRD: `docs/prd/component-path-resolution.md` diff --git a/errors/error_funcs_test.go b/errors/error_funcs_test.go index c039fba698..ad32a3d882 100644 --- a/errors/error_funcs_test.go +++ b/errors/error_funcs_test.go @@ -6,6 +6,7 @@ import ( "io" "os" "os/exec" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -124,8 +125,13 @@ func TestCheckErrorPrintAndExit_ExitCodeError(t *testing.T) { func TestCheckErrorPrintAndExit_ExecExitError(t *testing.T) { if os.Getenv("TEST_EXEC_EXIT") == "1" { - // Create an exec.ExitError - cmd := exec.Command("sh", "-c", "exit 3") + // Create an exec.ExitError using platform-appropriate command. + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/C", "exit 3") + } else { + cmd = exec.Command("sh", "-c", "exit 3") + } err := cmd.Run() CheckErrorPrintAndExit(err, "Exec Error", "") return diff --git a/examples/demo-helmfile/atmos.yaml b/examples/demo-helmfile/atmos.yaml index bf0ad28c2f..e55b988d0e 100644 --- a/examples/demo-helmfile/atmos.yaml +++ b/examples/demo-helmfile/atmos.yaml @@ -17,6 +17,9 @@ components: helmfile: base_path: "components/helmfile" use_eks: false + # Explicitly unset kubeconfig_path to prevent inheriting /dev/shm from repo root atmos.yaml + # The CI workflow sets KUBECONFIG env var directly + kubeconfig_path: "" stacks: base_path: "stacks" diff --git a/examples/quick-start-advanced/Dockerfile b/examples/quick-start-advanced/Dockerfile index fbbfbb60a3..1367778ce8 100644 --- a/examples/quick-start-advanced/Dockerfile +++ b/examples/quick-start-advanced/Dockerfile @@ -6,7 +6,7 @@ ARG GEODESIC_OS=debian # https://atmos.tools/ # https://github.com/cloudposse/atmos # https://github.com/cloudposse/atmos/releases -ARG ATMOS_VERSION=1.201.0 +ARG ATMOS_VERSION=1.202.0 # Terraform: https://github.com/hashicorp/terraform/releases ARG TF_VERSION=1.5.7 diff --git a/go.mod b/go.mod index 2b5a9ed88e..4a8b0cd318 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 github.com/adrg/xdg v0.5.3 github.com/agiledragon/gomonkey/v2 v2.13.0 - github.com/alecthomas/chroma/v2 v2.21.0 + github.com/alecthomas/chroma/v2 v2.21.1 github.com/alicebob/miniredis/v2 v2.35.0 github.com/arsham/figurine v1.3.0 github.com/atotto/clipboard v0.1.4 @@ -50,14 +50,14 @@ require ( github.com/go-git/go-git/v5 v5.16.4 github.com/go-viper/mapstructure/v2 v2.4.0 github.com/gobwas/glob v0.2.3 - github.com/goccy/go-yaml v1.19.0 + github.com/goccy/go-yaml v1.19.1 github.com/gofrs/flock v0.13.0 github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.20.7 github.com/google/go-github/v59 v59.0.0 github.com/google/renameio/v2 v2.0.1 github.com/google/uuid v1.6.0 - github.com/googleapis/gax-go/v2 v2.15.0 + github.com/googleapis/gax-go/v2 v2.16.0 github.com/hairyhenderson/gomplate/v3 v3.11.8 github.com/hairyhenderson/gomplate/v4 v4.3.3 github.com/hashicorp/go-getter v1.8.3 @@ -120,7 +120,7 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect - cloud.google.com/go/monitoring v1.24.2 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect cuelang.org/go v0.13.2 // indirect github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect @@ -401,8 +401,8 @@ require ( golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.40.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect + google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index 42726f2102..4c50546dfd 100644 --- a/go.sum +++ b/go.sum @@ -14,12 +14,12 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= -cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= -cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= +cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= +cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E= cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= -cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= -cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/pubsub v1.50.1 h1:fzbXpPyJnSGvWXF1jabhQeXyxdbCIkXTpjXHy7xviBM= cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk= cloud.google.com/go/pubsub/v2 v2.0.0 h1:0qS6mRJ41gD1lNmM/vdm6bR7DQu6coQcVwD+VPf0Bz0= @@ -28,8 +28,8 @@ cloud.google.com/go/secretmanager v1.16.0 h1:19QT7ZsLJ8FSP1k+4esQvuCD7npMJml6hYz cloud.google.com/go/secretmanager v1.16.0/go.mod h1://C/e4I8D26SDTz1f3TQcddhcmiC3rMEl0S1Cakvs3Q= cloud.google.com/go/storage v1.58.0 h1:PflFXlmFJjG/nBeR9B7pKddLQWaFaRWx4uUi/LyNxxo= cloud.google.com/go/storage v1.58.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= -cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= -cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= cuelabs.dev/go/oci/ociregistry v0.0.0-20250304105642-27e071d2c9b1 h1:Dmbd5Q+ENb2C6carvwrMsrOUwJ9X9qfL5JdW32gYAHo= cuelabs.dev/go/oci/ociregistry v0.0.0-20250304105642-27e071d2c9b1/go.mod h1:dqrnoZx62xbOZr11giMPrWbhlaV8euHwciXZEy3baT8= cuelang.org/go v0.13.2 h1:SagzeEASX4E2FQnRbItsqa33sSelrJjQByLqH9uZCE8= @@ -124,8 +124,8 @@ github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KO github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.21.0 h1:YVW9qQAFnQm2OFPPFQg6G/TpMxKSsUr/KUPDi/BEqtY= -github.com/alecthomas/chroma/v2 v2.21.0/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA= +github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U= github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= @@ -479,8 +479,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= -github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE= +github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4= github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -561,8 +561,8 @@ github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= -github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= +github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= @@ -1348,10 +1348,10 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 h1:LvZVVaPE0JSqL+ZWb6ErZfnEOKIqqFWUJE2D0fObSmc= -google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9/go.mod h1:QFOrLhdAe2PsTp3vQY4quuLKTi9j3XG3r6JPPaw7MSc= -google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba h1:B14OtaXuMaCQsl2deSvNkyPKIzq3BjfxQp8d00QyWx4= -google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= diff --git a/internal/exec/component_path_resolution_test.go b/internal/exec/component_path_resolution_test.go index c6c76eba92..68979b334c 100644 --- a/internal/exec/component_path_resolution_test.go +++ b/internal/exec/component_path_resolution_test.go @@ -39,6 +39,10 @@ func initAtmosConfigForFixture(t *testing.T, fixturePath string) schema.AtmosCon // Change to fixture directory using t.Chdir for automatic cleanup. t.Chdir(fixturePath) + // Set ATMOS_CLI_CONFIG_PATH to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + // Initialize config with processStacks=true to enable stack loading. atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) require.NoError(t, err, "Failed to initialize atmos config for fixture") @@ -295,6 +299,10 @@ func TestComponentPathResolution_CurrentDirectory(t *testing.T) { componentDir := filepath.Join(fixturePath, "components", "terraform", "simple-component") t.Chdir(componentDir) + // Set ATMOS_CLI_CONFIG_PATH to the fixture root to isolate from repo's atmos.yaml + // (this also disables parent directory search and git root discovery). + t.Setenv("ATMOS_CLI_CONFIG_PATH", fixturePath) + // Initialize config from component directory. atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) require.NoError(t, err) @@ -314,6 +322,10 @@ func TestComponentPathResolution_RelativePath(t *testing.T) { terraformDir := filepath.Join(fixturePath, "components", "terraform") t.Chdir(terraformDir) + // Set ATMOS_CLI_CONFIG_PATH to the fixture root to isolate from repo's atmos.yaml + // (this also disables parent directory search and git root discovery). + t.Setenv("ATMOS_CLI_CONFIG_PATH", fixturePath) + // Initialize config from terraform base directory. atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) require.NoError(t, err) diff --git a/internal/exec/describe_affected_authmanager_test.go b/internal/exec/describe_affected_authmanager_test.go index 0611e9c692..5fb6e22b87 100644 --- a/internal/exec/describe_affected_authmanager_test.go +++ b/internal/exec/describe_affected_authmanager_test.go @@ -52,6 +52,10 @@ func TestDescribeAffectedAuthManagerArgument(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) require.NoError(t, err, "Should load Atmos config") @@ -99,6 +103,10 @@ func TestDescribeAffectedNilAuthManagerHandling(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) require.NoError(t, err) diff --git a/internal/exec/describe_component_auth_override_test.go b/internal/exec/describe_component_auth_override_test.go index bdd7e0fa6a..f301ed8c1b 100644 --- a/internal/exec/describe_component_auth_override_test.go +++ b/internal/exec/describe_component_auth_override_test.go @@ -52,6 +52,10 @@ func TestComponentLevelAuthOverride(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-nested-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + // Test that ExecuteDescribeComponent works with AuthManager. // The resolver will check if component has auth config and either: // - Create component-specific AuthManager (if auth config exists) @@ -103,6 +107,10 @@ func TestResolveAuthManagerForNestedComponent(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-nested-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + // Get Atmos configuration. atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false) require.NoError(t, err, "Should load atmos config") @@ -178,6 +186,10 @@ func TestAuthOverrideInNestedChain(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-nested-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + // Process Level 1 component which references Level 2. // Since Level 1 has no auth config, it should use global AuthManager. level1Section, err := ExecuteDescribeComponent(&ExecuteDescribeComponentParams{ @@ -207,6 +219,10 @@ func TestAuthOverrideErrorHandling(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-nested-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + // Get Atmos configuration. atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false) require.NoError(t, err, "Should load atmos config") diff --git a/internal/exec/describe_component_authmanager_test.go b/internal/exec/describe_component_authmanager_test.go index 3186da0c76..83de355414 100644 --- a/internal/exec/describe_component_authmanager_test.go +++ b/internal/exec/describe_component_authmanager_test.go @@ -48,6 +48,10 @@ func TestExecuteDescribeComponentWithAuthManager(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + // Load atmos config. configAndStacksInfo := schema.ConfigAndStacksInfo{} _, err := cfg.InitCliConfig(configAndStacksInfo, true) @@ -83,6 +87,10 @@ func TestExecuteDescribeComponentWithoutAuthManager(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + configAndStacksInfo := schema.ConfigAndStacksInfo{} _, err := cfg.InitCliConfig(configAndStacksInfo, true) require.NoError(t, err) @@ -121,6 +129,10 @@ func TestExecuteDescribeComponentAuthManagerWithNilStackInfo(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + configAndStacksInfo := schema.ConfigAndStacksInfo{} _, err := cfg.InitCliConfig(configAndStacksInfo, true) require.NoError(t, err) @@ -164,6 +176,10 @@ func TestExecuteDescribeComponentAuthManagerWithNilAuthContext(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + configAndStacksInfo := schema.ConfigAndStacksInfo{} _, err := cfg.InitCliConfig(configAndStacksInfo, true) require.NoError(t, err) diff --git a/internal/exec/describe_component_provenance_test.go b/internal/exec/describe_component_provenance_test.go index efa6ef8807..55a6f575df 100644 --- a/internal/exec/describe_component_provenance_test.go +++ b/internal/exec/describe_component_provenance_test.go @@ -20,16 +20,20 @@ func TestDescribeComponent_NestedImportProvenance(t *testing.T) { ClearLastMergeContext() ClearFileContentCache() - // Skip if not in repo root or examples directory doesn't exist + // Skip if not in repo root or examples directory doesn't exist. examplesPath := "../../examples/quick-start-advanced" if _, err := os.Stat(examplesPath); os.IsNotExist(err) { t.Skipf("Skipping test: examples/quick-start-advanced directory not found") } - // Change to the quick-start-advanced directory + // Change to the quick-start-advanced directory. t.Chdir(examplesPath) - // Initialize config + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + + // Initialize config. var configAndStacksInfo schema.ConfigAndStacksInfo configAndStacksInfo.ComponentFromArg = "vpc-flow-logs-bucket" configAndStacksInfo.Stack = "plat-ue2-dev" @@ -185,16 +189,20 @@ func TestDescribeComponent_DirectImportProvenance(t *testing.T) { ClearLastMergeContext() ClearFileContentCache() - // Skip if not in repo root or examples directory doesn't exist + // Skip if not in repo root or examples directory doesn't exist. examplesPath := "../../examples/quick-start-advanced" if _, err := os.Stat(examplesPath); os.IsNotExist(err) { t.Skipf("Skipping test: examples/quick-start-advanced directory not found") } - // Change to the quick-start-advanced directory + // Change to the quick-start-advanced directory. t.Chdir(examplesPath) - // Initialize config + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + + // Initialize config. var configAndStacksInfo schema.ConfigAndStacksInfo configAndStacksInfo.ComponentFromArg = "vpc" configAndStacksInfo.Stack = "plat-ue2-dev" diff --git a/internal/exec/describe_component_test.go b/internal/exec/describe_component_test.go index 328b5e2fbe..fcbde1c6fc 100644 --- a/internal/exec/describe_component_test.go +++ b/internal/exec/describe_component_test.go @@ -199,10 +199,14 @@ func TestDescribeComponentWithOverridesSection(t *testing.T) { assert.NoError(t, err) }() - // Define the working directory + // Define the working directory. workDir := "../../tests/fixtures/scenarios/atmos-overrides-section" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + component := "c1" // `dev` @@ -333,23 +337,17 @@ func TestDescribeComponentWithOverridesSection(t *testing.T) { } func TestDescribeComponent_Packer(t *testing.T) { - err := os.Unsetenv("ATMOS_CLI_CONFIG_PATH") - if err != nil { - t.Fatalf("Failed to unset 'ATMOS_CLI_CONFIG_PATH': %v", err) - } - - err = os.Unsetenv("ATMOS_BASE_PATH") - if err != nil { - t.Fatalf("Failed to unset 'ATMOS_BASE_PATH': %v", err) - } - log.SetLevel(log.InfoLevel) log.SetOutput(os.Stdout) - // Define the working directory + // Define the working directory. workDir := "../../tests/fixtures/scenarios/packer" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + atmosConfig := schema.AtmosConfiguration{ Logs: schema.Logs{ Level: "Info", @@ -401,10 +399,14 @@ func TestDescribeComponentWithProvenance(t *testing.T) { log.SetLevel(log.InfoLevel) log.SetOutput(os.Stdout) - // Define the working directory - using quick-start-advanced as it has a good mix of configs + // Define the working directory - using quick-start-advanced as it has a good mix of configs. workDir := "../../examples/quick-start-advanced" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + component := "vpc-flow-logs-bucket" stack := "plat-ue2-dev" diff --git a/internal/exec/describe_dependents_authmanager_propagation_test.go b/internal/exec/describe_dependents_authmanager_propagation_test.go index ce33d41cbc..46dcfeab3c 100644 --- a/internal/exec/describe_dependents_authmanager_propagation_test.go +++ b/internal/exec/describe_dependents_authmanager_propagation_test.go @@ -48,6 +48,10 @@ func TestAuthManagerPropagationToDescribeDependents(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + // Load atmos config. configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) @@ -82,6 +86,10 @@ func TestDescribeDependentsAuthManagerNilHandling(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) require.NoError(t, err) @@ -120,6 +128,10 @@ func TestDescribeDependentsAuthManagerWithNilStackInfo(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) require.NoError(t, err) @@ -163,6 +175,10 @@ func TestDescribeDependentsAuthManagerWithNilAuthContext(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) require.NoError(t, err) diff --git a/internal/exec/describe_dependents_test.go b/internal/exec/describe_dependents_test.go index 6c05e21c5f..f21930db41 100644 --- a/internal/exec/describe_dependents_test.go +++ b/internal/exec/describe_dependents_test.go @@ -288,14 +288,16 @@ func TestDescribeDependentsExec_Execute_DifferentFormatsAndFiles(t *testing.T) { func TestDescribeDependents_WithStacksNameTemplate(t *testing.T) { // Environment isolation - t.Setenv("ATMOS_CLI_CONFIG_PATH", "") - t.Setenv("ATMOS_BASE_PATH", "") - - // Working directory isolation + // Working directory isolation. workDir := "../../tests/fixtures/scenarios/depends-on-with-stacks-name-template" t.Chdir(workDir) - // Init Atmos config + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + t.Setenv("ATMOS_BASE_PATH", "") + + // Init Atmos config. configInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configInfo, true) require.NoError(t, err, "InitCliConfig failed") @@ -468,14 +470,15 @@ func TestDescribeDependents_WithStacksNameTemplate(t *testing.T) { } func TestDescribeDependents_WithStacksNamePattern(t *testing.T) { - // Environment isolation - t.Setenv("ATMOS_CLI_CONFIG_PATH", "") - t.Setenv("ATMOS_BASE_PATH", "") - - // Working directory isolation + // Working directory isolation. workDir := "../../tests/fixtures/scenarios/depends-on-with-stacks-name-pattern" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + t.Setenv("ATMOS_BASE_PATH", "") + // Init Atmos config configInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configInfo, true) diff --git a/internal/exec/describe_stacks_authmanager_propagation_test.go b/internal/exec/describe_stacks_authmanager_propagation_test.go index ce12f777fe..e9a94df4a6 100644 --- a/internal/exec/describe_stacks_authmanager_propagation_test.go +++ b/internal/exec/describe_stacks_authmanager_propagation_test.go @@ -48,6 +48,10 @@ func TestAuthManagerPropagationToDescribeStacks(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + // Load atmos config. configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) @@ -79,6 +83,10 @@ func TestDescribeStacksAuthManagerNilHandling(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) require.NoError(t, err) @@ -118,6 +126,10 @@ func TestDescribeStacksAuthManagerWithNilStackInfo(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) require.NoError(t, err) @@ -161,6 +173,10 @@ func TestDescribeStacksAuthManagerWithNilAuthContext(t *testing.T) { workDir := "../../tests/fixtures/scenarios/authmanager-propagation" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) require.NoError(t, err) diff --git a/internal/exec/describe_stacks_test.go b/internal/exec/describe_stacks_test.go index 9aa9304b12..bf0bc2a687 100644 --- a/internal/exec/describe_stacks_test.go +++ b/internal/exec/describe_stacks_test.go @@ -145,10 +145,14 @@ func TestExecuteDescribeStacks_Packer(t *testing.T) { log.SetLevel(log.InfoLevel) log.SetOutput(os.Stdout) - // Define the working directory + // Define the working directory. workDir := "../../tests/fixtures/scenarios/packer" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) assert.Nil(t, err) diff --git a/internal/exec/shell_utils_test.go b/internal/exec/shell_utils_test.go index 4813db60c1..9c7b5dfa81 100644 --- a/internal/exec/shell_utils_test.go +++ b/internal/exec/shell_utils_test.go @@ -18,12 +18,16 @@ import ( ) func TestMergeEnvVars(t *testing.T) { - // Set up test environment variables + if runtime.GOOS == "windows" { + t.Skipf("Skipping test on Windows: PATH case-sensitivity and HOME behavior differ") + } + + // Set up test environment variables. t.Setenv("PATH", "/usr/bin") t.Setenv("TF_CLI_ARGS_plan", "-lock=false") t.Setenv("HOME", "/home/test") - // Atmos environment variables to merge + // Atmos environment variables to merge. componentEnv := []string{ "TF_CLI_ARGS_plan=-compact-warnings", "ATMOS_VAR=value", @@ -33,7 +37,7 @@ func TestMergeEnvVars(t *testing.T) { merged := envpkg.MergeSystemEnv(componentEnv) - // Convert the merged list back to a map for easier assertions + // Convert the merged list back to a map for easier assertions. mergedMap := make(map[string]string) for _, env := range merged { parts := strings.SplitN(env, "=", 2) @@ -478,6 +482,10 @@ func TestExecAuthShellCommand_ExitCodePropagation(t *testing.T) { } func TestExecuteShellCommand(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skipf("Skipping test on Windows: uses Unix commands (echo) and paths (/dev/stderr, /dev/stdout)") + } + atmosConfig := schema.AtmosConfiguration{} t.Run("dry run mode", func(t *testing.T) { @@ -584,6 +592,10 @@ func TestExecuteShellCommand(t *testing.T) { } func TestExecuteShell(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skipf("Skipping test on Windows: uses Unix commands (echo, ls, env, grep)") + } + t.Run("simple echo command", func(t *testing.T) { err := ExecuteShell( "echo 'test'", diff --git a/internal/exec/stack_manifest_name_test.go b/internal/exec/stack_manifest_name_test.go index 6fc507818f..9cf3284c0f 100644 --- a/internal/exec/stack_manifest_name_test.go +++ b/internal/exec/stack_manifest_name_test.go @@ -17,6 +17,10 @@ func TestStackManifestNameInStacksMap(t *testing.T) { testDir := "../../tests/fixtures/scenarios/stack-manifest-name" t.Chdir(testDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + // Initialize the CLI config. configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) @@ -103,6 +107,10 @@ func TestStackManifestName(t *testing.T) { testDir := "../../tests/fixtures/scenarios/stack-manifest-name" t.Chdir(testDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + // Initialize the CLI config. configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) @@ -130,6 +138,10 @@ func TestStackManifestNameWorkspace(t *testing.T) { testDir := "../../tests/fixtures/scenarios/stack-manifest-name" t.Chdir(testDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + // Initialize the CLI config. configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) @@ -272,6 +284,10 @@ func TestDescribeStacks_NameTemplate(t *testing.T) { testDir := "../../tests/fixtures/scenarios/stack-manifest-name-template" t.Chdir(testDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + // Initialize the CLI config. configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) @@ -305,6 +321,10 @@ func TestDescribeStacks_NameTemplateWorkspace(t *testing.T) { testDir := "../../tests/fixtures/scenarios/stack-manifest-name-template" t.Chdir(testDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + // Initialize the CLI config. configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) @@ -328,6 +348,10 @@ func TestDescribeStacks_NamePattern(t *testing.T) { testDir := "../../tests/fixtures/scenarios/stack-manifest-name-pattern" t.Chdir(testDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + // Initialize the CLI config. configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) @@ -362,6 +386,10 @@ func TestDescribeStacks_NamePatternWorkspace(t *testing.T) { testDir := "../../tests/fixtures/scenarios/stack-manifest-name-pattern" t.Chdir(testDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + // Initialize the CLI config. configAndStacksInfo := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true) diff --git a/internal/exec/terraform_test.go b/internal/exec/terraform_test.go index 45190ed136..5630c19a9a 100644 --- a/internal/exec/terraform_test.go +++ b/internal/exec/terraform_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "testing" @@ -78,21 +79,26 @@ func TestExecuteTerraform_ExportEnvVar(t *testing.T) { oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w + + // Read from pipe concurrently to avoid deadlock when output exceeds pipe buffer. + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = buf.ReadFrom(r) + close(done) + }() + err = ExecuteTerraform(info) if err != nil { t.Fatalf("Failed to execute 'ExecuteTerraform': %v", err) } - // Restore stdout + // Restore stdout and close writer to signal EOF to the reader goroutine err = w.Close() assert.NoError(t, err) os.Stdout = oldStdout - // Read the captured output - var buf bytes.Buffer - _, err = buf.ReadFrom(r) - if err != nil { - t.Fatalf("Failed to read from pipe: %v", err) - } + // Wait for the reader goroutine to finish + <-done output := buf.String() // Check the output ATMOS_CLI_CONFIG_PATH ATMOS_BASE_PATH exists @@ -158,21 +164,26 @@ func TestExecuteTerraform_TerraformPlanWithProcessingTemplates(t *testing.T) { oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w + + // Read from pipe concurrently to avoid deadlock when output exceeds pipe buffer. + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = buf.ReadFrom(r) + close(done) + }() + err := ExecuteTerraform(info) if err != nil { t.Fatalf("Failed to execute 'ExecuteTerraform': %v", err) } - // Restore stdout + // Restore stdout and close writer to signal EOF to the reader goroutine err = w.Close() assert.NoError(t, err) os.Stdout = oldStdout - // Read the captured output - var buf bytes.Buffer - _, err = buf.ReadFrom(r) - if err != nil { - t.Fatalf("Failed to read from pipe: %v", err) - } + // Wait for the reader goroutine to finish + <-done output := buf.String() // Check the output @@ -210,21 +221,26 @@ func TestExecuteTerraform_TerraformPlanWithoutProcessingTemplates(t *testing.T) oldStdout := os.Stdout r, w, _ := os.Pipe() os.Stdout = w + + // Read from pipe concurrently to avoid deadlock when output exceeds pipe buffer. + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = buf.ReadFrom(r) + close(done) + }() + err := ExecuteTerraform(info) if err != nil { t.Fatalf("Failed to execute 'ExecuteTerraform': %v", err) } - // Restore stdout + // Restore stdout and close writer to signal EOF to the reader goroutine err = w.Close() assert.NoError(t, err) os.Stdout = oldStdout - // Read the captured output - var buf bytes.Buffer - _, err = buf.ReadFrom(r) - if err != nil { - t.Fatalf("Failed to read from pipe: %v", err) - } + // Wait for the reader goroutine to finish + <-done output := buf.String() t.Cleanup(func() { @@ -343,21 +359,28 @@ func TestExecuteTerraform_TerraformInitWithVarfile(t *testing.T) { log.SetLevel(log.DebugLevel) log.SetOutput(w) + // Read from pipe concurrently to avoid deadlock when output exceeds pipe buffer. + // The pipe buffer is limited (typically 64KB), and if ExecuteTerraform produces + // more output than the buffer can hold, it will block waiting for a reader. + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = buf.ReadFrom(r) + close(done) + }() + err := ExecuteTerraform(info) if err != nil { t.Fatalf("Failed to execute 'ExecuteTerraform': %v", err) } - // Restore stderr + // Restore stderr and close writer to signal EOF to the reader goroutine err = w.Close() assert.NoError(t, err) os.Stderr = oldStderr - // Read the captured output - var buf bytes.Buffer - _, err = buf.ReadFrom(r) - if err != nil { - t.Fatalf("Failed to read from pipe: %v", err) - } + // Wait for the reader goroutine to finish + <-done + output := buf.String() // Check the output @@ -369,6 +392,9 @@ func TestExecuteTerraform_TerraformInitWithVarfile(t *testing.T) { } func TestExecuteTerraform_OpaValidation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping test on Windows: OPA validation uses Unix-specific commands") + } // Skip if terraform is not installed tests.RequireTerraform(t) @@ -699,6 +725,9 @@ func extractKeyValuePairs(input string) map[string]string { // TestExecuteTerraform_OpaValidationFunctionality tests the OPA validation functionality by using validate component directly. func TestExecuteTerraform_OpaValidationFunctionality(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping test on Windows: OPA validation uses Unix-specific commands") + } // Define the working directory. workDir := "../../tests/fixtures/scenarios/atmos-stacks-validation" t.Chdir(workDir) diff --git a/internal/exec/validate_component_test.go b/internal/exec/validate_component_test.go index 02c56445bc..07559d8095 100644 --- a/internal/exec/validate_component_test.go +++ b/internal/exec/validate_component_test.go @@ -3,6 +3,7 @@ package exec import ( "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -285,6 +286,9 @@ errors["process_env section is empty"] { } func TestValidateComponentInternal_ProcessEnvSectionContent(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping test on Windows: uses Unix-specific shell commands") + } // Test specifically that the process environment section contains expected content. // Create a temporary directory for testing. @@ -986,6 +990,10 @@ func TestExecuteValidateComponent_WithComponentValidationSettings(t *testing.T) fixturesDir := "../../tests/fixtures/scenarios/complete" t.Chdir(fixturesDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + info := schema.ConfigAndStacksInfo{} atmosConfig, err := cfg.InitCliConfig(info, true) require.NoError(t, err) diff --git a/internal/exec/yaml_func_template_test.go b/internal/exec/yaml_func_template_test.go index 32d1a7099e..73260d277f 100644 --- a/internal/exec/yaml_func_template_test.go +++ b/internal/exec/yaml_func_template_test.go @@ -308,6 +308,10 @@ func TestYamlFuncTemplate_Integration(t *testing.T) { workDir := "../../tests/fixtures/scenarios/atmos-template-yaml-function" t.Chdir(workDir) + // Set ATMOS_CLI_CONFIG_PATH to CWD to isolate from repo's atmos.yaml. + // This also disables parent directory search and git root discovery. + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + info := schema.ConfigAndStacksInfo{ StackFromArg: "", Stack: stack, diff --git a/pkg/config/config.go b/pkg/config/config.go index 870a149690..5e09002dfd 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -198,24 +198,104 @@ func processAtmosConfigs(configAndStacksInfo *schema.ConfigAndStacksInfo) (schem // AtmosConfigAbsolutePaths converts all base paths in the configuration to absolute paths. // This function sets TerraformDirAbsolutePath, HelmfileDirAbsolutePath, PackerDirAbsolutePath, // StacksBaseAbsolutePath, IncludeStackAbsolutePaths, and ExcludeStackAbsolutePaths. -// ResolveAbsolutePath converts a path to absolute form, resolving relative paths. -// Relative to the CLI config directory (where atmos.yaml is located) instead of CWD. -// This allows ATMOS_BASE_PATH to work correctly when set to a relative path. +// It converts a path to absolute form, resolving relative paths according to the semantics below. // -// Resolution order: -// 1. If path is already absolute → return as-is -// 2. If path is relative and cliConfigPath is set → resolve relative to cliConfigPath -// 3. Otherwise → resolve relative to current working directory (fallback for backwards compatibility). +// Resolution semantics (see docs/prd/base-path-resolution-semantics.md): +// +// 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 +// +// 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 +// 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) { // If already absolute, return as-is. if filepath.IsAbs(path) { return path, nil } - // If we have a CLI config path, resolve relative to it. - // This is the key fix: when ATMOS_BASE_PATH is relative (e.g., "../../.."), - // we need to resolve it relative to where atmos.yaml is, not relative to CWD. + sep := string(filepath.Separator) + + // Check for explicit relative paths: ".", "./...", "..", or "../..." + // These resolve relative to atmos.yaml location (config-file-relative). + // This follows the convention of tsconfig.json, package.json, .eslintrc. + isExplicitRelative := path == "." || + path == ".." || + strings.HasPrefix(path, "./") || + strings.HasPrefix(path, "."+sep) || + strings.HasPrefix(path, "../") || + strings.HasPrefix(path, ".."+sep) + + // For explicit relative paths (".", "./...", "..", "../..."): + // Resolve relative to config directory (cliConfigPath). + if isExplicitRelative && cliConfigPath != "" { + basePath := filepath.Join(cliConfigPath, path) + absPath, err := filepath.Abs(basePath) + if err != nil { + return "", fmt.Errorf("resolving path %q relative to config %q: %w", path, cliConfigPath, err) + } + return absPath, nil + } + + // For empty path or simple relative paths (like "stacks", "components/terraform"): + // Try git root first. + return tryResolveWithGitRoot(path, isExplicitRelative, cliConfigPath) +} + +// tryResolveWithGitRoot attempts to resolve a path using git root as the base. +// If git root is unavailable, falls back to cliConfigPath, then CWD. +func tryResolveWithGitRoot(path string, isExplicitRelative bool, cliConfigPath string) (string, error) { + gitRoot := getGitRootOrEmpty() + if gitRoot == "" { + return tryResolveWithConfigPath(path, cliConfigPath) + } + + // Git root available - resolve relative to it. + if path == "" { + return gitRoot, nil + } + + // For explicit relative paths without cliConfigPath, resolve relative to git root. + if isExplicitRelative { + basePath := filepath.Join(gitRoot, path) + absPath, err := filepath.Abs(basePath) + if err != nil { + return "", fmt.Errorf("resolving path %q relative to git root %q: %w", path, gitRoot, err) + } + return absPath, nil + } + + return filepath.Join(gitRoot, path), nil +} + +// tryResolveWithConfigPath resolves a path using cliConfigPath as the base, +// falling back to CWD if cliConfigPath is unavailable. +func tryResolveWithConfigPath(path string, cliConfigPath string) (string, error) { + // Fallback: resolve relative to atmos.yaml dir (cliConfigPath). if cliConfigPath != "" { + if path == "" { + absPath, err := filepath.Abs(cliConfigPath) + if err != nil { + return "", fmt.Errorf("resolving config path %q: %w", cliConfigPath, err) + } + return absPath, nil + } basePath := filepath.Join(cliConfigPath, path) absPath, err := filepath.Abs(basePath) if err != nil { @@ -224,7 +304,7 @@ func resolveAbsolutePath(path string, cliConfigPath string) (string, error) { return absPath, nil } - // Fallback: resolve relative to CWD (for backwards compatibility when cliConfigPath is empty). + // Last resort (3rd fallback): resolve relative to CWD. absPath, err := filepath.Abs(path) if err != nil { return "", fmt.Errorf("resolving path %q: %w", path, err) @@ -232,6 +312,39 @@ func resolveAbsolutePath(path string, cliConfigPath string) (string, error) { return absPath, nil } +// getGitRootOrEmpty returns the git repository root path, or empty string if not in a git repo. +// This is used for base path resolution to anchor simple relative paths to the repo root. +func getGitRootOrEmpty() string { + // Check if git root discovery is disabled. + //nolint:forbidigo // ATMOS_GIT_ROOT_BASEPATH is bootstrap config, not application configuration. + if os.Getenv("ATMOS_GIT_ROOT_BASEPATH") == "false" { + return "" + } + + gitRoot, err := u.ProcessTagGitRoot("!repo-root") + if err != nil { + log.Trace("Git root detection failed", "error", err) + return "" + } + + // ProcessTagGitRoot returns "." when called with just "!repo-root" and no default. + // We need to convert it to an absolute path. + if gitRoot == "" || gitRoot == "." { + // Get absolute path of current directory as fallback. + cwd, err := os.Getwd() + if err != nil { + return "" + } + // Check if we're at git root by looking for .git. + if _, err := os.Stat(filepath.Join(cwd, ".git")); err == nil { + return cwd + } + return "" + } + + return gitRoot +} + func AtmosConfigAbsolutePaths(atmosConfig *schema.AtmosConfiguration) error { // First, resolve the base path itself to an absolute path. // Relative paths are resolved relative to atmos.yaml location (atmosConfig.CliConfigPath). diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index b21a913794..70cae5ec74 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -863,3 +863,547 @@ func TestEnvironmentVariableHandling(t *testing.T) { }) } } + +// TestResolveAbsolutePath tests the path resolution logic for different scenarios. +// This tests the fallback behavior (when git root discovery is disabled). +// See docs/prd/base-path-resolution-semantics.md for the full specification. +func TestResolveAbsolutePath(t *testing.T) { + // Disable git root discovery so we test the fallback behavior. + t.Setenv("ATMOS_GIT_ROOT_BASEPATH", "false") + + // Create a temp directory structure for testing. + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, "config", "subdir") + err := os.MkdirAll(configDir, 0o755) + require.NoError(t, err) + + // Create platform-neutral absolute paths for testing. + absPath1 := filepath.Join(tmpDir, "absolute", "path") + absPath2 := filepath.Join(tmpDir, "another", "absolute") + + tests := []struct { + name string + path string + cliConfigPath string + expectedBase string // Expected base directory for resolution + }{ + // Absolute paths - should always remain unchanged. + { + name: "absolute path remains unchanged", + path: absPath1, + cliConfigPath: configDir, + expectedBase: "", // N/A - absolute paths don't need base + }, + { + name: "absolute path with empty config path remains unchanged", + path: absPath2, + cliConfigPath: "", + expectedBase: "", // N/A - absolute paths don't need base + }, + + // "." and "./" paths - resolve to config dir (config-file-relative). + // This follows the convention of tsconfig.json, package.json, .eslintrc. + { + name: "dot path resolves to config dir (config-file-relative)", + path: ".", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "dot path with empty config path resolves to CWD (3rd fallback, git root disabled)", + path: ".", + cliConfigPath: "", + expectedBase: "cwd", // Git root is disabled in this test, so falls back to CWD + }, + { + name: "path starting with ./ resolves to config dir", + path: "./subpath", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "path ./ alone resolves to config dir", + path: "./", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "path starting with ./ and nested dirs resolves to config dir", + path: "./a/b/c", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "path starting with ./ with empty config path resolves to CWD (3rd fallback, git root disabled)", + path: "./subpath", + cliConfigPath: "", + expectedBase: "cwd", // Git root is disabled in this test, so falls back to CWD + }, + + // ".." and "../" paths - resolve to config dir (navigate from atmos.yaml location). + { + name: "path with parent dir (..) resolves to config dir", + path: "..", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "path starting with ../ resolves to config dir", + path: "../sibling", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "path with multiple parent traversals resolves to config dir", + path: "../../grandparent", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "path starting with .. with empty config path resolves to CWD (3rd fallback, git root disabled)", + path: "../sibling", + cliConfigPath: "", + expectedBase: "cwd", // Git root is disabled in this test, so falls back to CWD + }, + + // Empty path - fallback to config dir (git root disabled). + { + name: "empty path resolves to config dir (git root disabled)", + path: "", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "empty path with empty config path resolves to CWD", + path: "", + cliConfigPath: "", + expectedBase: "cwd", + }, + + // Simple relative paths - fallback to config dir (git root disabled). + { + name: "simple relative path resolves to config dir (git root disabled)", + path: "stacks", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "complex relative path without ./ prefix resolves to config dir", + path: "components/terraform", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "deeply nested relative path resolves to config dir", + path: "a/b/c/d/e", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "simple relative path with empty config path resolves to CWD", + path: "stacks", + cliConfigPath: "", + expectedBase: "cwd", + }, + + // Edge cases - paths that look like they might start with . or .. but don't. + { + name: "path starting with dot but not ./ or .. resolves to config dir", + path: ".hidden", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "path starting with ..foo resolves to config dir (not parent traversal)", + path: "..foo", + cliConfigPath: configDir, + expectedBase: "config", // "..foo" is NOT ".." or "../" so it's a simple relative path + }, + { + name: "path with dots in middle resolves to config dir", + path: "foo/bar/../baz", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "path starting with ... resolves to config dir", + path: ".../something", + cliConfigPath: configDir, + expectedBase: "config", // ".../something" is NOT "../" so it's a simple relative path + }, + } + + // Add platform-specific test cases for Windows-style paths. + // On Windows, backslash is the path separator so .\\ and ..\\ are explicit relative paths. + // On Unix, backslash is a literal character so .\\ paths are just regular paths. + if filepath.Separator == '\\' { + // Windows: .\\ and ..\\ paths resolve to config dir (config-file-relative). + windowsTests := []struct { + name string + path string + cliConfigPath string + expectedBase string + }{ + { + name: "Windows-style .\\subpath resolves to config dir", + path: ".\\subpath", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "Windows-style ..\\sibling resolves to config dir", + path: "..\\sibling", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "Windows-style .\\subpath with empty config path resolves to CWD (3rd fallback)", + path: ".\\subpath", + cliConfigPath: "", + expectedBase: "cwd", // Git root is disabled in this test, so falls back to CWD + }, + { + name: "Windows-style ..\\sibling with empty config path resolves to CWD (3rd fallback)", + path: "..\\sibling", + cliConfigPath: "", + expectedBase: "cwd", // Git root is disabled in this test, so falls back to CWD + }, + { + name: "Windows-style .\\nested\\path resolves to config dir", + path: ".\\nested\\path", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "Windows-style ..\\..\\parent resolves to config dir", + path: "..\\..\\parent", + cliConfigPath: configDir, + expectedBase: "config", + }, + } + tests = append(tests, windowsTests...) + } else { + // Unix: backslash is a literal character, so these paths are simple relative paths. + // With git root disabled, they resolve to config dir. + unixBackslashTests := []struct { + name string + path string + cliConfigPath string + expectedBase string + }{ + { + name: "Unix treats .\\subpath as literal (not path separator), resolves to config dir", + path: ".\\subpath", + cliConfigPath: configDir, + expectedBase: "config", + }, + { + name: "Unix treats ..\\sibling as literal (not path separator), resolves to config dir", + path: "..\\sibling", + cliConfigPath: configDir, + expectedBase: "config", + }, + } + tests = append(tests, unixBackslashTests...) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := resolveAbsolutePath(tt.path, tt.cliConfigPath) + require.NoError(t, err) + + if filepath.IsAbs(tt.path) { + // Absolute paths should remain unchanged. + assert.Equal(t, tt.path, result) + } else { + cwd, err := os.Getwd() + require.NoError(t, err) + + switch tt.expectedBase { + case "cwd": + // Path should be resolved relative to CWD. + expected := filepath.Join(cwd, tt.path) + expectedAbs, err := filepath.Abs(expected) + require.NoError(t, err) + assert.Equal(t, expectedAbs, result, + "Path %q should resolve relative to CWD", tt.path) + case "config": + // Path should be resolved relative to config dir. + expected := filepath.Join(tt.cliConfigPath, tt.path) + expectedAbs, err := filepath.Abs(expected) + require.NoError(t, err) + assert.Equal(t, expectedAbs, result, + "Path %q should resolve relative to config dir", tt.path) + } + } + }) + } +} + +// TestParentTraversalResolvesRelativeToConfigDir tests that ".." base_path resolves +// relative to the atmos.yaml location, allowing config in a subdirectory to reference +// directories at the parent level. +// See: https://github.com/cloudposse/atmos/issues/1858 +func TestParentTraversalResolvesRelativeToConfigDir(t *testing.T) { + // Clear environment variables that might interfere (empty value effectively unsets). + t.Setenv("ATMOS_CLI_CONFIG_PATH", "") + t.Setenv("ATMOS_BASE_PATH", "") + + t.Run("config in subdirectory with base_path pointing to parent", func(t *testing.T) { + // Change to the fixture directory. + changeWorkingDir(t, "../../tests/fixtures/scenarios/cli-config-path") + + // Load config using the config subdirectory. + t.Setenv("ATMOS_CLI_CONFIG_PATH", "./config") + + cfg, err := InitCliConfig(schema.ConfigAndStacksInfo{}, false) + require.NoError(t, err, "InitCliConfig should succeed") + + // The stacks directory should be found at the repo root, not inside config/. + // The config has base_path: ".." which should resolve relative to atmos.yaml location, + // and stacks.base_path: "stacks" should then be relative to that base_path. + cwd, err := os.Getwd() + require.NoError(t, err) + + expectedStacksPath := filepath.Join(cwd, "stacks") + assert.Equal(t, expectedStacksPath, cfg.StacksBaseAbsolutePath, + "Stacks path should be at repo root (CWD), not inside config/") + + // TerraformDirAbsolutePath should be the absolute path of components/terraform. + expectedComponentsPath := filepath.Join(cwd, "components", "terraform") + assert.Equal(t, expectedComponentsPath, cfg.TerraformDirAbsolutePath, + "Terraform components path should be at repo root (CWD), not inside config/") + }) + + t.Run("base_path with .. should resolve relative to atmos.yaml location", func(t *testing.T) { + // This test verifies the intended behavior of PR #1774: + // When base_path is "..", it should resolve relative to atmos.yaml location. + changeWorkingDir(t, "../../tests/fixtures/scenarios/cli-config-path") + + t.Setenv("ATMOS_CLI_CONFIG_PATH", "./config") + + cfg, err := InitCliConfig(schema.ConfigAndStacksInfo{}, false) + require.NoError(t, err) + + // base_path: ".." in config/atmos.yaml should resolve to the parent of config/, + // which is the repo root. + cwd, err := os.Getwd() + require.NoError(t, err) + + // BasePathAbsolute should be the repo root (parent of config/). + assert.Equal(t, cwd, cfg.BasePathAbsolute, + "Base path should resolve to repo root (parent of config/)") + }) +} + +// TestEmptyBasePathWithNestedConfigResolvesToGitRoot tests that an empty base_path +// triggers git root discovery even when atmos.yaml is in a deeply nested subdirectory. +// +// Scenario: +// - ATMOS_CLI_CONFIG_PATH=./rootfs/usr/local/etc/atmos (config in deeply nested subdirectory) +// - base_path: "" (empty - expects repo root) +// - stacks/ and components/ are at the repo root +// +// The expected behavior is that empty base_path should resolve to git repo root, +// NOT to the directory containing atmos.yaml. +// See: https://github.com/cloudposse/atmos/issues/1858 +func TestEmptyBasePathWithNestedConfigResolvesToGitRoot(t *testing.T) { + // Clear environment variables that might interfere. + err := os.Unsetenv("ATMOS_CLI_CONFIG_PATH") + require.NoError(t, err) + err = os.Unsetenv("ATMOS_BASE_PATH") + require.NoError(t, err) + + t.Run("empty base_path with nested config resolves to git root", func(t *testing.T) { + // Scenario: + // - atmos.yaml is in a deeply nested subdirectory + // - base_path: "" (empty) in that config + // - stacks/ and components/ are at repo root + // - Git root discovery should find the repo root + changeWorkingDir(t, "../../tests/fixtures/scenarios/nested-config-empty-base-path") + + cwd, err := os.Getwd() + require.NoError(t, err) + + // Mock the git root to be the fixture directory (simulates repo root). + // This isolates the test from the actual git repo structure. + t.Setenv("TEST_GIT_ROOT", cwd) + + // Set ATMOS_CLI_CONFIG_PATH to the deeply nested config directory. + // This matches the user's setup: ATMOS_CLI_CONFIG_PATH=./rootfs/usr/local/etc/atmos + t.Setenv("ATMOS_CLI_CONFIG_PATH", "./rootfs/usr/local/etc/atmos") + + cfg, err := InitCliConfig(schema.ConfigAndStacksInfo{}, false) + require.NoError(t, err, "InitCliConfig should succeed with empty base_path and nested config") + + // The key assertion: empty base_path should resolve to git repo root (or CWD as fallback), + // NOT to the atmos.yaml directory. + // In v1.201.0, this incorrectly resolved to ./rootfs/usr/local/etc/atmos/ + assert.Equal(t, cwd, cfg.BasePathAbsolute, + "Empty base_path should resolve to git repo root, not config directory") + + // Stacks should be found at repo root, not inside the config directory. + expectedStacksPath := filepath.Join(cwd, "stacks") + assert.Equal(t, expectedStacksPath, cfg.StacksBaseAbsolutePath, + "Stacks path should be at repo root, not inside rootfs/usr/local/etc/atmos/") + + // Components should be found at repo root, not inside the config directory. + expectedComponentsPath := filepath.Join(cwd, "components", "terraform") + assert.Equal(t, expectedComponentsPath, cfg.TerraformDirAbsolutePath, + "Components path should be at repo root, not inside rootfs/usr/local/etc/atmos/") + }) +} + +// TestDotPathResolvesRelativeToConfigDir tests that "." and "./" paths resolve +// relative to the config directory (where atmos.yaml is located), following the +// convention of tsconfig.json, package.json, and other config files. +func TestDotPathResolvesRelativeToConfigDir(t *testing.T) { + // Clear environment variables that might interfere. + err := os.Unsetenv("ATMOS_CLI_CONFIG_PATH") + require.NoError(t, err) + 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 + changeWorkingDir(t, "../../tests/fixtures/scenarios/complete/components/terraform/top-level-component1") + + // 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). + 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)") + }) + + t.Run("base_path=. when CWD equals config dir resolves to config dir", func(t *testing.T) { + // This test verifies that when running from the same directory as atmos.yaml, + // base_path: "." resolves to the config directory. + // Since CWD == config dir in this case, the result is the same. + changeWorkingDir(t, "../../tests/fixtures/scenarios/complete") + + // Don't set ATMOS_CLI_CONFIG_PATH - use default discovery. + // Don't set ATMOS_BASE_PATH - use the value from atmos.yaml (base_path: "."). + + cfg, err := InitCliConfig(schema.ConfigAndStacksInfo{}, false) + require.NoError(t, err, "InitCliConfig should succeed") + + cwd, err := os.Getwd() + require.NoError(t, err) + + // BasePathAbsolute should be the config directory. + // Since CWD == config dir, the result equals CWD. + assert.Equal(t, cwd, cfg.BasePathAbsolute, + "Base path with '.' should resolve to config directory") + }) +} + +func TestGetGitRootOrEmpty(t *testing.T) { + t.Run("returns empty when ATMOS_GIT_ROOT_BASEPATH=false", func(t *testing.T) { + t.Setenv("ATMOS_GIT_ROOT_BASEPATH", "false") + result := getGitRootOrEmpty() + assert.Empty(t, result, "should return empty when git root discovery is disabled") + }) + + t.Run("returns git root when in a git repository", func(t *testing.T) { + // Ensure git root discovery is enabled. + t.Setenv("ATMOS_GIT_ROOT_BASEPATH", "") + + result := getGitRootOrEmpty() + // We're running tests inside the atmos repo, so we should get a valid git root. + assert.NotEmpty(t, result, "should return git root when in a git repository") + // Verify it's an absolute path. + assert.True(t, filepath.IsAbs(result), "git root should be an absolute path") + }) +} + +func TestTryResolveWithGitRoot(t *testing.T) { + t.Run("returns git root when path is empty and git available", func(t *testing.T) { + t.Setenv("ATMOS_GIT_ROOT_BASEPATH", "") + + result, err := tryResolveWithGitRoot("", false, "") + require.NoError(t, err) + // We're in a git repo, so should get the git root. + assert.NotEmpty(t, result) + assert.True(t, filepath.IsAbs(result)) + }) + + t.Run("falls back to config path when git root unavailable", func(t *testing.T) { + t.Setenv("ATMOS_GIT_ROOT_BASEPATH", "false") + + // Use t.TempDir() for cross-platform compatibility. + configPath := t.TempDir() + result, err := tryResolveWithGitRoot("", false, configPath) + require.NoError(t, err) + assert.Equal(t, configPath, result) + }) + + t.Run("resolves explicit relative path with git root", func(t *testing.T) { + t.Setenv("ATMOS_GIT_ROOT_BASEPATH", "") + + result, err := tryResolveWithGitRoot("./subdir", true, "") + require.NoError(t, err) + assert.True(t, filepath.IsAbs(result)) + assert.Contains(t, result, "subdir") + }) + + t.Run("joins simple relative path with git root", func(t *testing.T) { + t.Setenv("ATMOS_GIT_ROOT_BASEPATH", "") + + result, err := tryResolveWithGitRoot("stacks", false, "") + require.NoError(t, err) + assert.Contains(t, result, "stacks") + }) +} + +func TestTryResolveWithConfigPath(t *testing.T) { + t.Run("returns config path when path is empty", func(t *testing.T) { + // Use t.TempDir() for cross-platform compatibility. + configPath := t.TempDir() + result, err := tryResolveWithConfigPath("", configPath) + require.NoError(t, err) + assert.Equal(t, configPath, result) + }) + + t.Run("joins path with config path", func(t *testing.T) { + // Use t.TempDir() for cross-platform compatibility. + configPath := t.TempDir() + result, err := tryResolveWithConfigPath("subdir", configPath) + require.NoError(t, err) + expected := filepath.Join(configPath, "subdir") + assert.Equal(t, expected, result) + }) + + t.Run("resolves relative to CWD when no config path", func(t *testing.T) { + result, err := tryResolveWithConfigPath("subdir", "") + require.NoError(t, err) + + cwd, _ := os.Getwd() + expected := filepath.Join(cwd, "subdir") + assert.Equal(t, expected, result) + }) + + t.Run("handles empty path and empty config path", func(t *testing.T) { + result, err := tryResolveWithConfigPath("", "") + require.NoError(t, err) + + cwd, _ := os.Getwd() + assert.Equal(t, cwd, result) + }) +} diff --git a/pkg/config/git_root.go b/pkg/config/git_root.go index d602d79bc1..ed0ccd48a7 100644 --- a/pkg/config/git_root.go +++ b/pkg/config/git_root.go @@ -10,16 +10,18 @@ import ( ) // applyGitRootBasePath automatically sets the base path to the Git repository root -// when using the default configuration and no local Atmos config exists. +// when base_path is empty or set to the default value. // // This function implements smart defaults: -// - Only applies to default config (no explicit atmos.yaml path provided). -// - Skips if local Atmos configuration exists (atmos.yaml, .atmos/, etc.). +// - Skips if local Atmos configuration exists in CWD (atmos.yaml, .atmos/, etc.). // - Skips if base_path is explicitly set to a non-default value. // - Can be disabled via ATMOS_GIT_ROOT_BASEPATH=false. // // This allows users to run `atmos` from anywhere in a Git repository // without needing to specify --config-dir or base_path. +// +// Note: Git root discovery now also happens in resolveAbsolutePath() for path resolution. +// This function primarily handles the case where we want to set BasePath before path resolution. func applyGitRootBasePath(atmosConfig *schema.AtmosConfiguration) error { // Bootstrap configuration: Read directly because this controls git root discovery during // config loading itself, before processEnvVars() populates the Settings struct. @@ -46,12 +48,8 @@ func applyGitRootBasePath(atmosConfig *schema.AtmosConfiguration) error { return nil } - // Only apply to default config with default base path. - if !atmosConfig.Default { - log.Trace("Skipping git root base path (not default config)") - return nil - } - + // Skip if base_path is explicitly set to a non-default value. + // Empty string and "." are considered default values. if atmosConfig.BasePath != "." && atmosConfig.BasePath != "" { log.Trace("Skipping git root base path (explicit base_path set)", "base_path", atmosConfig.BasePath) return nil diff --git a/pkg/config/load.go b/pkg/config/load.go index d54ea22685..618d746bb8 100644 --- a/pkg/config/load.go +++ b/pkg/config/load.go @@ -64,6 +64,8 @@ func trackMergedConfigFile(path string) { const ( profileKey = "profile" profileDelimiter = "," + // AtmosCliConfigPathEnvVar is the environment variable name for CLI config path. + AtmosCliConfigPathEnvVar = "ATMOS_CLI_CONFIG_PATH" ) // parseProfilesFromOsArgs parses --profile flags from os.Args using pflag. @@ -425,25 +427,53 @@ func setDefaultConfiguration(v *viper.Viper) { v.SetDefault("settings.pro.endpoint", AtmosProDefaultEndpoint) } -// loadConfigSources delegates reading configs from each source, -// returning early if any step in the chain fails. +// loadConfigSources loads configuration from multiple sources in priority order, +// delegating reading configs from each source and returning early if any step fails. +// +// Config loading order (lowest to highest priority, later wins): +// 1. System dir (/usr/local/etc/atmos) - lowest priority +// 2. Home dir (~/.atmos) +// 3. Parent directory search (fallback for unusual structures) +// 4. Git root (repo-root/atmos.yaml) +// 5. CWD only (./atmos.yaml, NO parent search) +// 6. Env var (ATMOS_CLI_CONFIG_PATH) - overrides discovery +// 7. CLI arg (--config-path) - highest priority +// +// Note: Viper merges configs, so later sources override earlier ones. func loadConfigSources(v *viper.Viper, configAndStacksInfo *schema.ConfigAndStacksInfo) error { + // Load in order from lowest to highest priority (Viper merges, later wins). + + // 1. System dir (lowest priority). if err := readSystemConfig(v); err != nil { return err } + // 2. Home dir. if err := readHomeConfig(v); err != nil { return err } - if err := readWorkDirConfig(v); err != nil { + // 3. Parent directory search (fallback). + if err := readParentDirConfig(v); err != nil { + return err + } + + // 4. Git root. + if err := readGitRootConfig(v); err != nil { + return err + } + + // 5. CWD only. + if err := readWorkDirConfigOnly(v); err != nil { return err } + // 6. Env var (ATMOS_CLI_CONFIG_PATH) - overrides discovery. if err := readEnvAmosConfigPath(v); err != nil { return err } + // 7. CLI arg (highest priority). return readAtmosConfigCli(v, configAndStacksInfo.AtmosCliConfigPath) } @@ -498,13 +528,8 @@ func readHomeConfigWithProvider(v *viper.Viper, homeProvider filesystem.HomeDirP return nil } -// readWorkDirConfig loads config from current working directory or any parent directory. -// It searches upward through the directory tree until it finds an atmos.yaml file -// or reaches the filesystem root. This enables running atmos commands from any -// subdirectory (e.g., component directories) without specifying --config-path. -// Parent directory search can be disabled by setting ATMOS_CLI_CONFIG_PATH to "." or -// any explicit path. -func readWorkDirConfig(v *viper.Viper) error { +// readWorkDirConfigOnly tries to load atmos.yaml from CWD only (no parent search). +func readWorkDirConfigOnly(v *viper.Viper) error { wd, err := os.Getwd() if err != nil { return err @@ -513,26 +538,78 @@ 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 { + if err != nil { + var configFileNotFoundError viper.ConfigFileNotFoundError + if errors.As(err, &configFileNotFoundError) { + return nil + } + return err + } + log.Trace("Found atmos.yaml in current directory", "path", wd) + return nil +} + +// readGitRootConfig tries to load atmos.yaml from the git repository root. +func readGitRootConfig(v *viper.Viper) error { + // Check if git root discovery is disabled. + //nolint:forbidigo // ATMOS_GIT_ROOT_BASEPATH is bootstrap config, not application configuration. + if os.Getenv("ATMOS_GIT_ROOT_BASEPATH") == "false" { return nil } - // If not a ConfigFileNotFoundError, return the error. - var configFileNotFoundError viper.ConfigFileNotFoundError - if !errors.As(err, &configFileNotFoundError) { + // If ATMOS_CLI_CONFIG_PATH is set, skip git root discovery. + // The env var is an explicit override. + //nolint:forbidigo // ATMOS_CLI_CONFIG_PATH controls config loading behavior itself. + if os.Getenv(AtmosCliConfigPathEnvVar) != "" { + return nil + } + + gitRoot, err := u.ProcessTagGitRoot("!repo-root") + if err != nil { + log.Trace("Git root detection failed", "error", err) + return nil + } + + // Skip if git root is empty or same as CWD (already handled by readWorkDirConfigOnly). + if gitRoot == "" || gitRoot == "." { + return nil + } + + // Convert relative path to absolute. + if !filepath.IsAbs(gitRoot) { + cwd, err := os.Getwd() + if err != nil { + return err + } + gitRoot = filepath.Join(cwd, gitRoot) + } + + err = mergeConfig(v, gitRoot, CliConfigFileName, true) + if err != nil { + var configFileNotFoundError viper.ConfigFileNotFoundError + if errors.As(err, &configFileNotFoundError) { + return nil + } return err } + log.Trace("Found atmos.yaml at git root", "path", gitRoot) + return nil +} +// readParentDirConfig searches parent directories for atmos.yaml (fallback). +func readParentDirConfig(v *viper.Viper) error { // If ATMOS_CLI_CONFIG_PATH is set, don't search parent directories. // This allows tests and users to explicitly control config discovery. - //nolint:forbidigo // ATMOS_CLI_CONFIG_PATH controls config loading behavior itself, - // it must be checked before viper loads config files. Using os.Getenv is necessary - // because this check happens during the config loading phase, before any viper - // bindings are established. - if os.Getenv("ATMOS_CLI_CONFIG_PATH") != "" { + //nolint:forbidigo // ATMOS_CLI_CONFIG_PATH controls config loading behavior itself. + if os.Getenv(AtmosCliConfigPathEnvVar) != "" { return nil } + wd, err := os.Getwd() + if err != nil { + return err + } + // Search parent directories for atmos.yaml. configDir := findAtmosConfigInParentDirs(wd) if configDir == "" { @@ -543,18 +620,29 @@ func readWorkDirConfig(v *viper.Viper) error { // Found config in a parent directory, merge it. err = mergeConfig(v, configDir, CliConfigFileName, true) if err != nil { - switch err.(type) { - case viper.ConfigFileNotFoundError: + var configFileNotFoundError viper.ConfigFileNotFoundError + if errors.As(err, &configFileNotFoundError) { return nil - default: - return err } + return err } - log.Debug("Found atmos.yaml in parent directory", "path", configDir) + log.Trace("Found atmos.yaml in parent directory", "path", configDir) return nil } +// Deprecated: readWorkDirConfig is kept for backward compatibility. +// Use readWorkDirConfigOnly, readGitRootConfig, and readParentDirConfig instead. +func readWorkDirConfig(v *viper.Viper) error { + if err := readWorkDirConfigOnly(v); err != nil { + return err + } + if err := readGitRootConfig(v); err != nil { + return err + } + return readParentDirConfig(v) +} + // findAtmosConfigInParentDirs searches for atmos.yaml in parent directories. // It walks up the directory tree from the given starting directory until // it finds an atmos.yaml file or reaches the filesystem root. @@ -585,7 +673,8 @@ func findAtmosConfigInParentDirs(startDir string) string { } func readEnvAmosConfigPath(v *viper.Viper) error { - atmosPath := os.Getenv("ATMOS_CLI_CONFIG_PATH") + //nolint:forbidigo // ATMOS_CLI_CONFIG_PATH controls config loading behavior itself. + atmosPath := os.Getenv(AtmosCliConfigPathEnvVar) if atmosPath == "" { return nil } @@ -594,13 +683,13 @@ func readEnvAmosConfigPath(v *viper.Viper) error { if err != nil { switch err.(type) { case viper.ConfigFileNotFoundError: - log.Debug("config not found ENV var ATMOS_CLI_CONFIG_PATH", "file", atmosPath) + log.Debug("config not found ENV var "+AtmosCliConfigPathEnvVar, "file", atmosPath) return nil default: return err } } - log.Debug("Found config ENV", "ATMOS_CLI_CONFIG_PATH", atmosPath) + log.Trace("Found config ENV", AtmosCliConfigPathEnvVar, atmosPath) return nil } diff --git a/pkg/config/load_test.go b/pkg/config/load_test.go index 14ee60c315..7473fa81e2 100644 --- a/pkg/config/load_test.go +++ b/pkg/config/load_test.go @@ -1460,3 +1460,166 @@ auth: assert.Equal(t, "ImportedDev", atmosConfig.Auth.IdentityCaseMap["importeddev"]) }) } + +func TestParseProfilesFromOsArgs(t *testing.T) { + tests := []struct { + name string + args []string + expected []string + }{ + { + name: "no profile flag", + args: []string{"atmos", "describe", "stacks"}, + expected: nil, + }, + { + name: "single profile with equals syntax", + args: []string{"atmos", "--profile=dev", "describe", "stacks"}, + expected: []string{"dev"}, + }, + { + name: "single profile with space syntax", + args: []string{"atmos", "--profile", "dev", "describe", "stacks"}, + expected: []string{"dev"}, + }, + { + name: "multiple profiles comma-separated", + args: []string{"atmos", "--profile=dev,staging", "describe", "stacks"}, + expected: []string{"dev", "staging"}, + }, + { + name: "multiple profile flags", + args: []string{"atmos", "--profile=dev", "--profile=staging", "describe", "stacks"}, + expected: []string{"dev", "staging"}, + }, + { + name: "profile with whitespace", + args: []string{"atmos", "--profile= dev ", "describe", "stacks"}, + expected: []string{"dev"}, + }, + { + name: "empty profile value", + args: []string{"atmos", "--profile=", "describe", "stacks"}, + expected: nil, + }, + { + name: "profile with other flags", + args: []string{"atmos", "--stack=mystack", "--profile=dev", "--format=json", "describe", "stacks"}, + expected: []string{"dev"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseProfilesFromOsArgs(tt.args) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseViperProfilesFromEnv(t *testing.T) { + tests := []struct { + name string + profiles []string + expected []string + }{ + { + name: "empty slice", + profiles: []string{}, + expected: nil, + }, + { + name: "single profile", + profiles: []string{"dev"}, + expected: []string{"dev"}, + }, + { + name: "comma-separated as single string (Viper quirk)", + profiles: []string{"dev,staging,prod"}, + expected: []string{"dev", "staging", "prod"}, + }, + { + name: "whitespace-separated by Viper", + profiles: []string{"dev", "staging", "prod"}, + expected: []string{"dev", "staging", "prod"}, + }, + { + name: "mixed comma and whitespace (Viper quirk)", + profiles: []string{"dev", ",", "staging"}, + expected: []string{"dev", "staging"}, + }, + { + name: "profiles with whitespace", + profiles: []string{" dev ", " staging "}, + expected: []string{"dev", "staging"}, + }, + { + name: "empty strings and commas", + profiles: []string{"", ",", " ", "dev"}, + expected: []string{"dev"}, + }, + { + name: "nil input", + profiles: nil, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseViperProfilesFromEnv(tt.profiles) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseProfilesFromEnvString(t *testing.T) { + tests := []struct { + name string + envValue string + expected []string + }{ + { + name: "empty string", + envValue: "", + expected: nil, + }, + { + name: "single profile", + envValue: "dev", + expected: []string{"dev"}, + }, + { + name: "comma-separated profiles", + envValue: "dev,staging,prod", + expected: []string{"dev", "staging", "prod"}, + }, + { + name: "profiles with whitespace", + envValue: " dev , staging , prod ", + expected: []string{"dev", "staging", "prod"}, + }, + { + name: "empty entries filtered", + envValue: "dev,,staging, ,prod", + expected: []string{"dev", "staging", "prod"}, + }, + { + name: "only commas", + envValue: ",,,", + expected: nil, + }, + { + name: "only whitespace", + envValue: " ", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseProfilesFromEnvString(tt.envValue) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/config/process_yaml.go b/pkg/config/process_yaml.go index db60f16354..834c8ed86b 100644 --- a/pkg/config/process_yaml.go +++ b/pkg/config/process_yaml.go @@ -195,12 +195,13 @@ func processSequenceElement(child *yaml.Node, v *viper.Viper, elementPath string } } -// hasCustomTag reports whether the YAML tag starts with any Atmos custom function prefix (env, exec, include, repo-root, random). +// hasCustomTag reports whether the YAML tag starts with any Atmos custom function prefix (env, exec, include, repo-root, cwd, random). func hasCustomTag(tag string) bool { return strings.HasPrefix(tag, u.AtmosYamlFuncEnv) || strings.HasPrefix(tag, u.AtmosYamlFuncExec) || strings.HasPrefix(tag, u.AtmosYamlFuncInclude) || strings.HasPrefix(tag, u.AtmosYamlFuncGitRoot) || + strings.HasPrefix(tag, u.AtmosYamlFuncCwd) || strings.HasPrefix(tag, u.AtmosYamlFuncRandom) } @@ -271,6 +272,16 @@ func processGitRootTag(strFunc, nodeValue string) (any, error) { return strings.TrimSpace(gitRootValue), nil } +// processCwdTag processes the !cwd tag. +func processCwdTag(strFunc, nodeValue string) (any, error) { + cwdValue, err := u.ProcessTagCwd(strFunc) + if err != nil { + log.Debug(failedToProcess, functionKey, strFunc, "error", err) + return nil, fmt.Errorf(errorFormat, ErrExecuteYamlFunctions, u.AtmosYamlFuncCwd, nodeValue, err) + } + return strings.TrimSpace(cwdValue), nil +} + // processRandomTag processes the !random tag. func processRandomTag(strFunc, nodeValue string) (any, error) { randomValue, err := u.ProcessTagRandom(strFunc) @@ -282,7 +293,7 @@ func processRandomTag(strFunc, nodeValue string) (any, error) { } // processScalarNodeValue evaluates a YAML scalar node's custom Atmos tag and returns the resolved value. -// It supports the !env, !exec, !include, !repo-root, and !random tags; failures during evaluation return an error wrapped with ErrExecuteYamlFunctions, and unknown/unsupported tags are decoded and returned as their YAML value. +// It supports the !env, !exec, !include, !repo-root, !cwd, and !random tags; failures during evaluation return an error wrapped with ErrExecuteYamlFunctions, and unknown/unsupported tags are decoded and returned as their YAML value. func processScalarNodeValue(node *yaml.Node) (any, error) { strFunc := fmt.Sprintf(tagValueFormat, node.Tag, node.Value) @@ -295,6 +306,8 @@ func processScalarNodeValue(node *yaml.Node) (any, error) { return processIncludeTag(node.Tag, node.Value, strFunc) case strings.HasPrefix(node.Tag, u.AtmosYamlFuncGitRoot): return processGitRootTag(strFunc, node.Value) + case strings.HasPrefix(node.Tag, u.AtmosYamlFuncCwd): + return processCwdTag(strFunc, node.Value) case strings.HasPrefix(node.Tag, u.AtmosYamlFuncRandom): return processRandomTag(strFunc, node.Value) default: @@ -307,7 +320,7 @@ func processScalarNodeValue(node *yaml.Node) (any, error) { } // processScalarNode processes a YAML scalar node tagged with an Atmos custom function and stores the resolved value in v. -// It dispatches handling for !env, !exec, !include, !repo-root and !random tags to their respective handlers. +// It dispatches handling for !env, !exec, !include, !repo-root, !cwd, and !random tags to their respective handlers. // If the node has no tag or the tag is not one of the recognized Atmos functions, the function is a no-op. // It returns any error produced by the invoked handler. func processScalarNode(node *yaml.Node, v *viper.Viper, currentPath string) error { @@ -324,6 +337,8 @@ func processScalarNode(node *yaml.Node, v *viper.Viper, currentPath string) erro return handleInclude(node, v, currentPath) case strings.HasPrefix(node.Tag, u.AtmosYamlFuncGitRoot): return handleGitRoot(node, v, currentPath) + case strings.HasPrefix(node.Tag, u.AtmosYamlFuncCwd): + return handleCwd(node, v, currentPath) case strings.HasPrefix(node.Tag, u.AtmosYamlFuncRandom): return handleRandom(node, v, currentPath) } @@ -413,6 +428,26 @@ func handleGitRoot(node *yaml.Node, v *viper.Viper, currentPath string) error { return nil } +// handleCwd evaluates a `!cwd` YAML tag and stores the current working directory string into Viper at the given path. +// If a path argument is provided, it is joined with CWD. +// If evaluation fails, it returns an error wrapped with ErrExecuteYamlFunctions. +func handleCwd(node *yaml.Node, v *viper.Viper, currentPath string) error { + strFunc := fmt.Sprintf(tagValueFormat, node.Tag, node.Value) + cwdValue, err := u.ProcessTagCwd(strFunc) + if err != nil { + log.Debug(failedToProcess, functionKey, strFunc, "error", err) + return fmt.Errorf(errorFormat, ErrExecuteYamlFunctions, u.AtmosYamlFuncCwd, node.Value, err) + } + cwdValue = strings.TrimSpace(cwdValue) + if cwdValue == "" { + log.Debug(emptyValueWarning, functionKey, strFunc) + } + // Set the value in Viper. + v.Set(currentPath, cwdValue) + node.Tag = "" // Avoid re-processing. + return nil +} + // handleRandom evaluates a YAML scalar tagged with !random and stores the result in the provided Viper instance. // // If evaluation succeeds, the resulting value is stored at the given Viper key path (`currentPath`) and the node's diff --git a/pkg/config/process_yaml_test.go b/pkg/config/process_yaml_test.go index b19759c87d..b72862320f 100644 --- a/pkg/config/process_yaml_test.go +++ b/pkg/config/process_yaml_test.go @@ -3,11 +3,14 @@ package config import ( "fmt" "os" + "path/filepath" "reflect" "strings" "testing" "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.yaml.in/yaml/v3" ) @@ -260,6 +263,11 @@ func TestHasCustomTag(t *testing.T) { tag: "!repo-root", expected: true, }, + { + name: "cwd tag", + tag: "!cwd", + expected: true, + }, { name: "random tag", tag: "!random", @@ -579,3 +587,334 @@ func TestProcessScalarNodeValue(t *testing.T) { }) } } + +func TestProcessCwdTag(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current working directory: %v", err) + } + + tests := []struct { + name string + yamlStr string + expected string + }{ + { + name: "cwd tag without path", + yamlStr: "key: !cwd", + expected: cwd, + }, + { + name: "cwd tag with relative path", + yamlStr: "key: !cwd ./subdir", + expected: strings.ReplaceAll(cwd+"/subdir", "/", string(os.PathSeparator)), + }, + { + name: "cwd tag with nested path", + yamlStr: "key: !cwd ./a/b/c", + expected: strings.ReplaceAll(cwd+"/a/b/c", "/", string(os.PathSeparator)), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := viper.New() + err := preprocessAtmosYamlFunc([]byte(tt.yamlStr), v) + if err != nil { + t.Fatalf("preprocessAtmosYamlFunc() error = %v", err) + } + + result := v.GetString("key") + // Normalize path separators for comparison. + expected := strings.ReplaceAll(tt.expected, "/", string(os.PathSeparator)) + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } + }) + } +} + +func TestHandleCwd(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + tests := []struct { + name string + nodeTag string + nodeValue string + expectedKey string + checkValue func(t *testing.T, value string) + }{ + { + name: "cwd tag without path argument", + nodeTag: "!cwd", + nodeValue: "", + expectedKey: "test.path", + checkValue: func(t *testing.T, value string) { + assert.Equal(t, cwd, value) + }, + }, + { + name: "cwd tag with relative path", + nodeTag: "!cwd", + nodeValue: "./subdir", + expectedKey: "test.path", + checkValue: func(t *testing.T, value string) { + expected := strings.ReplaceAll(filepath.Join(cwd, "subdir"), "/", string(os.PathSeparator)) + assert.Equal(t, expected, value) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := viper.New() + node := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: tt.nodeTag, + Value: tt.nodeValue, + } + + err := handleCwd(node, v, tt.expectedKey) + require.NoError(t, err) + + result := v.GetString(tt.expectedKey) + tt.checkValue(t, result) + + // Verify tag is cleared after processing. + assert.Empty(t, node.Tag, "tag should be cleared after processing") + }) + } +} + +func TestHandleGitRoot(t *testing.T) { + tests := []struct { + name string + nodeTag string + nodeValue string + expectedKey string + checkValue func(t *testing.T, value string) + }{ + { + name: "repo-root tag without default", + nodeTag: "!repo-root", + nodeValue: "", + expectedKey: "test.path", + checkValue: func(t *testing.T, value string) { + // We're in a git repo, so we should get a valid path. + assert.NotEmpty(t, value) + assert.True(t, filepath.IsAbs(value)) + }, + }, + { + name: "repo-root tag with default value", + nodeTag: "!repo-root", + nodeValue: "/default/path", + expectedKey: "test.path", + checkValue: func(t *testing.T, value string) { + // Should return the git root, not the default. + assert.NotEmpty(t, value) + assert.True(t, filepath.IsAbs(value)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := viper.New() + node := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: tt.nodeTag, + Value: tt.nodeValue, + } + + err := handleGitRoot(node, v, tt.expectedKey) + require.NoError(t, err) + + result := v.GetString(tt.expectedKey) + tt.checkValue(t, result) + + // Verify tag is cleared after processing. + assert.Empty(t, node.Tag, "tag should be cleared after processing") + }) + } +} + +func TestProcessGitRootTag(t *testing.T) { + tests := []struct { + name string + strFunc string + nodeValue string + checkVal func(t *testing.T, result any, err error) + }{ + { + name: "repo-root tag returns valid path", + strFunc: "!repo-root", + nodeValue: "", + checkVal: func(t *testing.T, result any, err error) { + require.NoError(t, err) + path, ok := result.(string) + require.True(t, ok) + assert.NotEmpty(t, path) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := processGitRootTag(tt.strFunc, tt.nodeValue) + tt.checkVal(t, result, err) + }) + } +} + +func TestSequenceNeedsProcessing(t *testing.T) { + tests := []struct { + name string + node *yaml.Node + expected bool + }{ + { + name: "sequence with custom tag needs processing", + node: &yaml.Node{ + Kind: yaml.SequenceNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Tag: "!env", Value: "MY_VAR"}, + }, + }, + expected: true, + }, + { + name: "sequence without custom tags", + node: &yaml.Node{ + Kind: yaml.SequenceNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Tag: "!!str", Value: "plain"}, + }, + }, + expected: false, + }, + { + name: "sequence with nested mapping containing custom tag", + node: &yaml.Node{ + Kind: yaml.SequenceNode, + Content: []*yaml.Node{ + { + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "key"}, + {Kind: yaml.ScalarNode, Tag: "!cwd", Value: ""}, + }, + }, + }, + }, + expected: true, + }, + { + name: "empty sequence", + node: &yaml.Node{ + Kind: yaml.SequenceNode, + Content: []*yaml.Node{}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sequenceNeedsProcessing(tt.node) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestProcessMappingNode(t *testing.T) { + t.Run("processes mapping node with custom tags", func(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + v := viper.New() + node := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "path"}, + {Kind: yaml.ScalarNode, Tag: "!cwd", Value: ""}, + }, + } + + err = processMappingNode(node, v, "config") + require.NoError(t, err) + + result := v.GetString("config.path") + assert.Equal(t, cwd, result) + }) + + t.Run("handles nested mapping nodes", func(t *testing.T) { + v := viper.New() + node := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "outer"}, + { + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "inner"}, + {Kind: yaml.ScalarNode, Tag: "!!str", Value: "value"}, + }, + }, + }, + } + + err := processMappingNode(node, v, "config") + require.NoError(t, err) + // The nested mapping is processed recursively. + }) +} + +func TestProcessSequenceElement(t *testing.T) { + t.Run("processes scalar element with custom tag", func(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + v := viper.New() + child := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!cwd", + Value: "", + } + + result, err := processSequenceElement(child, v, "items.0") + require.NoError(t, err) + assert.Equal(t, cwd, result) + }) + + t.Run("processes plain scalar element", func(t *testing.T) { + v := viper.New() + child := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: "plain-value", + } + + result, err := processSequenceElement(child, v, "items.0") + require.NoError(t, err) + assert.Equal(t, "plain-value", result) + }) + + t.Run("processes mapping element", func(t *testing.T) { + v := viper.New() + child := &yaml.Node{ + Kind: yaml.MappingNode, + Content: []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "key"}, + {Kind: yaml.ScalarNode, Value: "value"}, + }, + } + + result, err := processSequenceElement(child, v, "items.0") + require.NoError(t, err) + resultMap, ok := result.(map[string]any) + require.True(t, ok) + assert.Equal(t, "value", resultMap["key"]) + }) +} diff --git a/pkg/devcontainer/lifecycle_rebuild_test.go b/pkg/devcontainer/lifecycle_rebuild_test.go index 3222b43ed6..fb0b2ee12a 100644 --- a/pkg/devcontainer/lifecycle_rebuild_test.go +++ b/pkg/devcontainer/lifecycle_rebuild_test.go @@ -409,7 +409,7 @@ func TestManager_Rebuild(t *testing.T) { Context: ".", Dockerfile: "Dockerfile", Args: map[string]string{ - "ATMOS_VERSION": "1.201.0", + "ATMOS_VERSION": "1.202.0", }, }, } @@ -431,7 +431,7 @@ func TestManager_Rebuild(t *testing.T) { assert.Equal(t, ".", buildConfig.Context) assert.Equal(t, "Dockerfile", buildConfig.Dockerfile) assert.Equal(t, []string{"atmos-devcontainer-geodesic"}, buildConfig.Tags) - assert.Equal(t, map[string]string{"ATMOS_VERSION": "1.201.0"}, buildConfig.Args) + assert.Equal(t, map[string]string{"ATMOS_VERSION": "1.202.0"}, buildConfig.Args) return nil }) // Pull is NOT called for locally built images since they don't exist diff --git a/pkg/utils/doc_utils_test.go b/pkg/utils/doc_utils_test.go index 73bc062887..3d843d6302 100644 --- a/pkg/utils/doc_utils_test.go +++ b/pkg/utils/doc_utils_test.go @@ -1,6 +1,7 @@ package utils import ( + "runtime" "testing" "github.com/spf13/viper" @@ -11,6 +12,10 @@ import ( ) func TestDisplayDocs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skipf("Skipping test on Windows: uses Unix commands (cat, less)") + } + // viper.Reset() is called in each subtest t.Run("no pager - prints to stdout", func(t *testing.T) { diff --git a/pkg/utils/git.go b/pkg/utils/git.go index d631ea4e3b..d706dd1c5a 100644 --- a/pkg/utils/git.go +++ b/pkg/utils/git.go @@ -12,6 +12,29 @@ import ( "github.com/cloudposse/atmos/pkg/perf" ) +// ProcessTagCwd returns the current working directory. +// If a path argument is provided after the tag, it is joined with CWD. +// Format: "!cwd" or "!cwd ". +func ProcessTagCwd(input string) (string, error) { + defer perf.Track(nil, "utils.ProcessTagCwd")() + + str := strings.TrimPrefix(input, AtmosYamlFuncCwd) + pathArg := strings.TrimSpace(str) + + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current working directory: %w", err) + } + + // If no path argument, return CWD. + if pathArg == "" { + return cwd, nil + } + + // Join CWD with the provided path. + return filepath.Join(cwd, pathArg), nil +} + // ProcessTagGitRoot returns the root directory of the Git repository using go-git. func ProcessTagGitRoot(input string) (string, error) { defer perf.Track(nil, "utils.ProcessTagGitRoot")() diff --git a/pkg/utils/git_test.go b/pkg/utils/git_test.go index be3304553f..637f3c9bd5 100644 --- a/pkg/utils/git_test.go +++ b/pkg/utils/git_test.go @@ -1,11 +1,112 @@ package utils import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func TestProcessTagCwd(t *testing.T) { + // Save and restore original working directory. + originalDir, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { + err := os.Chdir(originalDir) + if err != nil { + t.Errorf("failed to restore original directory: %v", err) + } + }) + + tests := []struct { + name string + input string + expected func(cwd string) string + }{ + { + name: "Basic !cwd tag returns current working directory", + input: "!cwd", + expected: func(cwd string) string { + return cwd + }, + }, + { + name: "!cwd with trailing space", + input: "!cwd ", + expected: func(cwd string) string { + return cwd + }, + }, + { + name: "!cwd with relative path", + input: "!cwd ./relative/path", + expected: func(cwd string) string { + return filepath.Join(cwd, ".", "relative", "path") + }, + }, + { + name: "!cwd with path without dot prefix", + input: "!cwd subdir", + expected: func(cwd string) string { + return filepath.Join(cwd, "subdir") + }, + }, + { + name: "!cwd with parent directory path", + input: "!cwd ../parent", + expected: func(cwd string) string { + return filepath.Join(cwd, "..", "parent") + }, + }, + { + name: "!cwd with multiple spaces before path", + input: "!cwd multiple/spaces", + expected: func(cwd string) string { + return filepath.Join(cwd, "multiple", "spaces") + }, + }, + { + name: "!cwd with complex relative path", + input: "!cwd ./components/terraform/vpc", + expected: func(cwd string) string { + return filepath.Join(cwd, ".", "components", "terraform", "vpc") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Get current working directory. + cwd, err := os.Getwd() + require.NoError(t, err) + + result, err := ProcessTagCwd(tt.input) + assert.NoError(t, err) + assert.Equal(t, tt.expected(cwd), result) + }) + } +} + +func TestProcessTagCwd_DifferentWorkingDirectory(t *testing.T) { + // Create a temp directory and change to it. + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + t.Run("Returns temp directory as CWD", func(t *testing.T) { + result, err := ProcessTagCwd("!cwd") + assert.NoError(t, err) + assert.Equal(t, tmpDir, result) + }) + + t.Run("Joins temp directory with relative path", func(t *testing.T) { + result, err := ProcessTagCwd("!cwd ./mypath") + assert.NoError(t, err) + assert.Equal(t, filepath.Join(tmpDir, ".", "mypath"), result) + }) +} + func TestProcessTagGitRoot(t *testing.T) { tests := []struct { name string @@ -92,10 +193,10 @@ func TestProcessTagGitRoot(t *testing.T) { } func TestProcessTagGitRoot_Integration(t *testing.T) { - // This test verifies that TEST_GIT_ROOT properly overrides Git detection + // This test verifies that TEST_GIT_ROOT properly overrides Git detection. t.Run("TEST_GIT_ROOT overrides Git detection", func(t *testing.T) { - // Test 1: TEST_GIT_ROOT overrides any Git detection + // Test 1: TEST_GIT_ROOT overrides any Git detection. mockRoot := "/mock/override/path" t.Setenv("TEST_GIT_ROOT", mockRoot) @@ -103,16 +204,16 @@ func TestProcessTagGitRoot_Integration(t *testing.T) { assert.NoError(t, err) assert.Equal(t, mockRoot, result) - // Test 2: TEST_GIT_ROOT works with a path suffix + // Test 2: TEST_GIT_ROOT works with a path suffix. result, err = ProcessTagGitRoot("!repo-root /some/path") assert.NoError(t, err) assert.Equal(t, mockRoot, result, "TEST_GIT_ROOT should override even when input has a suffix") }) t.Run("Empty TEST_GIT_ROOT falls back to default value", func(t *testing.T) { - // Don't set TEST_GIT_ROOT - it will be unset + // Don't set TEST_GIT_ROOT - it will be unset. - // Create a temp directory without a Git repo + // Create a temp directory without a Git repo. tmpDir := t.TempDir() t.Chdir(tmpDir) @@ -121,3 +222,44 @@ func TestProcessTagGitRoot_Integration(t *testing.T) { assert.Equal(t, "/default/path", result, "Should return default value when not in Git repo and no TEST_GIT_ROOT") }) } + +func TestProcessTagGitRoot_RealGitRepo(t *testing.T) { + // This test runs in the actual atmos repo to test real Git detection. + // Skip if TEST_GIT_ROOT is already set (test isolation mode). + + t.Run("Detects real Git root without TEST_GIT_ROOT override", func(t *testing.T) { + // Save and check if we're in a git repo. + originalDir, err := os.Getwd() + require.NoError(t, err) + t.Cleanup(func() { + err := os.Chdir(originalDir) + if err != nil { + t.Errorf("failed to restore original directory: %v", err) + } + }) + + // The atmos repo root should be detected. + result, err := ProcessTagGitRoot("!repo-root") + require.NoError(t, err) + + // The result should be an absolute path containing "atmos". + assert.True(t, filepath.IsAbs(result), "Git root should be an absolute path") + assert.Contains(t, result, "atmos", "Should be in the atmos repository") + + // Verify .git exists at the detected root. + gitDir := filepath.Join(result, ".git") + _, statErr := os.Stat(gitDir) + assert.NoError(t, statErr, ".git directory should exist at detected root") + }) + + t.Run("Returns error without default value when not in Git repo", func(t *testing.T) { + // Create a temp directory that is not a Git repo. + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + // Without a default value, this should return an error. + _, err := ProcessTagGitRoot("!repo-root") + assert.Error(t, err, "Should return error when not in Git repo and no default value") + assert.Contains(t, err.Error(), "failed to open Git repository") + }) +} diff --git a/pkg/utils/shell_utils_test.go b/pkg/utils/shell_utils_test.go index 8a3b168956..22b28f2e1c 100644 --- a/pkg/utils/shell_utils_test.go +++ b/pkg/utils/shell_utils_test.go @@ -3,6 +3,7 @@ package utils import ( "bytes" "errors" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -12,6 +13,10 @@ import ( ) func TestShellRunner_ExitCodePreservation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skipf("Skipping test on Windows: uses Unix shell commands") + } + tests := []struct { name string command string @@ -76,6 +81,10 @@ func TestShellRunner_ExitCodePreservation(t *testing.T) { } func TestShellRunner_OutputCapture(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skipf("Skipping test on Windows: uses Unix shell commands") + } + var buf bytes.Buffer err := ShellRunner("echo 'hello world'", "test", ".", []string{}, &buf) @@ -84,6 +93,10 @@ func TestShellRunner_OutputCapture(t *testing.T) { } func TestShellRunner_Environment(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skipf("Skipping test on Windows: uses Unix shell commands") + } + var buf bytes.Buffer env := []string{"TEST_VAR=test_value"} err := ShellRunner("echo $TEST_VAR", "test", ".", env, &buf) @@ -93,6 +106,10 @@ func TestShellRunner_Environment(t *testing.T) { } func TestShellRunner_WorkingDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skipf("Skipping test on Windows: uses Unix shell commands and paths") + } + var buf bytes.Buffer // Use a simple command that works in any directory err := ShellRunner("pwd", "test", "/tmp", []string{}, &buf) @@ -104,6 +121,10 @@ func TestShellRunner_WorkingDirectory(t *testing.T) { } func TestShellRunner_ParseError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skipf("Skipping test on Windows: uses Unix shell syntax") + } + var buf bytes.Buffer // Invalid shell syntax err := ShellRunner("if then fi", "test", ".", []string{}, &buf) diff --git a/pkg/utils/yaml_utils.go b/pkg/utils/yaml_utils.go index f08f4d50a0..89cc335032 100644 --- a/pkg/utils/yaml_utils.go +++ b/pkg/utils/yaml_utils.go @@ -29,6 +29,7 @@ const ( AtmosYamlFuncInclude = "!include" AtmosYamlFuncIncludeRaw = "!include.raw" AtmosYamlFuncGitRoot = "!repo-root" + AtmosYamlFuncCwd = "!cwd" AtmosYamlFuncRandom = "!random" AtmosYamlFuncLiteral = "!literal" AtmosYamlFuncAwsAccountID = "!aws.account_id" @@ -52,6 +53,7 @@ var ( AtmosYamlFuncTerraformOutput, AtmosYamlFuncTerraformState, AtmosYamlFuncEnv, + AtmosYamlFuncCwd, AtmosYamlFuncRandom, AtmosYamlFuncLiteral, AtmosYamlFuncAwsAccountID, @@ -71,6 +73,7 @@ var ( AtmosYamlFuncTerraformOutput: true, AtmosYamlFuncTerraformState: true, AtmosYamlFuncEnv: true, + AtmosYamlFuncCwd: true, AtmosYamlFuncRandom: true, AtmosYamlFuncLiteral: true, AtmosYamlFuncAwsAccountID: true, diff --git a/pkg/utils/yaml_utils_test.go b/pkg/utils/yaml_utils_test.go index bb8218e35c..29af6386a9 100644 --- a/pkg/utils/yaml_utils_test.go +++ b/pkg/utils/yaml_utils_test.go @@ -762,6 +762,7 @@ func TestAtmosYamlTagsMap_ContainsAllTags(t *testing.T) { AtmosYamlFuncTerraformOutput, AtmosYamlFuncTerraformState, AtmosYamlFuncEnv, + AtmosYamlFuncCwd, AtmosYamlFuncRandom, AtmosYamlFuncLiteral, AtmosYamlFuncAwsAccountID, diff --git a/tests/fixtures/scenarios/cli-config-path/components/terraform/test-component/main.tf b/tests/fixtures/scenarios/cli-config-path/components/terraform/test-component/main.tf new file mode 100644 index 0000000000..755892c8cb --- /dev/null +++ b/tests/fixtures/scenarios/cli-config-path/components/terraform/test-component/main.tf @@ -0,0 +1,8 @@ +variable "enabled" { + type = bool + default = false +} + +output "enabled" { + value = var.enabled +} diff --git a/tests/fixtures/scenarios/cli-config-path/config/atmos.yaml b/tests/fixtures/scenarios/cli-config-path/config/atmos.yaml new file mode 100644 index 0000000000..4fbc06bbf5 --- /dev/null +++ b/tests/fixtures/scenarios/cli-config-path/config/atmos.yaml @@ -0,0 +1,31 @@ +# This atmos.yaml is in a subdirectory (config/) while stacks/ and components/ +# are at the fixture root level. This tests the scenario where: +# - ATMOS_CLI_CONFIG_PATH=./config +# - base_path uses ".." to reference the parent directory (fixture root) +# - stacks/ and components/ directories are at the fixture root +# +# Path Resolution Behavior: +# - base_path: ".." → resolves relative to config dir (where atmos.yaml is) +# - stacks.base_path: "stacks" → simple relative, anchored to resolved base_path +# - components.terraform.base_path: "components/terraform" → same as above +# +# This validates that paths starting with ".." or "./" resolve relative to the +# atmos.yaml location (config-dir-relative), following the convention of +# tsconfig.json, package.json, etc. + +base_path: ".." + +components: + terraform: + base_path: "components/terraform" + +stacks: + base_path: "stacks" + included_paths: + - "**/*" + excluded_paths: + - "**/_defaults.yaml" + name_pattern: "{stage}" + +logs: + level: Info diff --git a/tests/fixtures/scenarios/cli-config-path/stacks/dev.yaml b/tests/fixtures/scenarios/cli-config-path/stacks/dev.yaml new file mode 100644 index 0000000000..21aeadb520 --- /dev/null +++ b/tests/fixtures/scenarios/cli-config-path/stacks/dev.yaml @@ -0,0 +1,8 @@ +vars: + stage: dev + +components: + terraform: + test-component: + vars: + enabled: true diff --git a/tests/fixtures/scenarios/nested-config-empty-base-path/components/terraform/test-component/main.tf b/tests/fixtures/scenarios/nested-config-empty-base-path/components/terraform/test-component/main.tf new file mode 100644 index 0000000000..09e097323c --- /dev/null +++ b/tests/fixtures/scenarios/nested-config-empty-base-path/components/terraform/test-component/main.tf @@ -0,0 +1,9 @@ +# Test component for issue #1858 fixture +variable "enabled" { + type = bool + default = true +} + +output "enabled" { + value = var.enabled +} diff --git a/tests/fixtures/scenarios/nested-config-empty-base-path/rootfs/usr/local/etc/atmos/atmos.yaml b/tests/fixtures/scenarios/nested-config-empty-base-path/rootfs/usr/local/etc/atmos/atmos.yaml new file mode 100644 index 0000000000..863578c254 --- /dev/null +++ b/tests/fixtures/scenarios/nested-config-empty-base-path/rootfs/usr/local/etc/atmos/atmos.yaml @@ -0,0 +1,29 @@ +# Test fixture: Nested config with empty base_path +# +# Scenario: +# - atmos.yaml is in a deeply nested subdirectory (rootfs/usr/local/etc/atmos/) +# - base_path is empty ("") - expects git repo root +# - stacks/ and components/ are at the repo root +# - ATMOS_CLI_CONFIG_PATH=./rootfs/usr/local/etc/atmos +# +# Expected behavior: Empty base_path should trigger git root discovery, +# resolving to the repo root, not the atmos.yaml directory. +# +# See: https://github.com/cloudposse/atmos/issues/1858 + +base_path: "" + +components: + terraform: + base_path: "components/terraform" + +stacks: + base_path: "stacks" + included_paths: + - "**/*" + excluded_paths: + - "**/_defaults.yaml" + name_pattern: "{stage}" + +logs: + level: Info diff --git a/tests/fixtures/scenarios/nested-config-empty-base-path/stacks/dev.yaml b/tests/fixtures/scenarios/nested-config-empty-base-path/stacks/dev.yaml new file mode 100644 index 0000000000..21aeadb520 --- /dev/null +++ b/tests/fixtures/scenarios/nested-config-empty-base-path/stacks/dev.yaml @@ -0,0 +1,8 @@ +vars: + stage: dev + +components: + terraform: + test-component: + vars: + enabled: true 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 4e324dc245..b76d80053d 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 @@ -9,6 +9,7 @@ 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 Found atmos.yaml in current directory 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 @@ -17,7 +18,7 @@ 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 Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level TRCE Preserved case-sensitive map keys paths="[env auth.identities]" files_processed=1 TRCE Git root base path disabled via ATMOS_GIT_ROOT_BASEPATH=false DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false @@ -31,6 +32,7 @@ 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 Found atmos.yaml in current directory 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 @@ -39,7 +41,7 @@ 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 Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level TRCE Preserved case-sensitive map keys paths="[env auth.identities]" files_processed=1 TRCE Git root base path disabled via ATMOS_GIT_ROOT_BASEPATH=false DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false @@ -53,6 +55,7 @@ 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 Found atmos.yaml in current directory 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 @@ -61,7 +64,7 @@ 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 Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/valid-log-level TRCE Preserved case-sensitive map keys paths="[env auth.identities]" files_processed=1 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/snapshots/TestCLICommands_Valid_Log_Level_in_Environment_Variable.stderr.golden b/tests/snapshots/TestCLICommands_Valid_Log_Level_in_Environment_Variable.stderr.golden index ed90e1cfcc..57abe3b697 100644 --- a/tests/snapshots/TestCLICommands_Valid_Log_Level_in_Environment_Variable.stderr.golden +++ b/tests/snapshots/TestCLICommands_Valid_Log_Level_in_Environment_Variable.stderr.golden @@ -1,8 +1,5 @@ DEBU Set logs-level=debug logs-file=/dev/stderr - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/priority-log-check DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/priority-log-check DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/priority-log-check DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false DEBU Telemetry event enqueued diff --git a/tests/snapshots/TestCLICommands_Valid_log_file_in_env_should_be_priortized_over_config.stdout.golden b/tests/snapshots/TestCLICommands_Valid_log_file_in_env_should_be_priortized_over_config.stdout.golden index 87861783c0..4e5c89de51 100644 --- a/tests/snapshots/TestCLICommands_Valid_log_file_in_env_should_be_priortized_over_config.stdout.golden +++ b/tests/snapshots/TestCLICommands_Valid_log_file_in_env_should_be_priortized_over_config.stdout.golden @@ -1,11 +1,8 @@ DEBU Set logs-level=debug logs-file=/dev/stdout - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/priority-log-check DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false 👽 Atmos test on darwin/arm64 - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/priority-log-check DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/priority-log-check DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false DEBU Telemetry event enqueued diff --git a/tests/snapshots/TestCLICommands_Valid_log_file_in_flag_should_be_priortized_over_env_and_config.stdout.golden b/tests/snapshots/TestCLICommands_Valid_log_file_in_flag_should_be_priortized_over_env_and_config.stdout.golden index 87861783c0..4e5c89de51 100644 --- a/tests/snapshots/TestCLICommands_Valid_log_file_in_flag_should_be_priortized_over_env_and_config.stdout.golden +++ b/tests/snapshots/TestCLICommands_Valid_log_file_in_flag_should_be_priortized_over_env_and_config.stdout.golden @@ -1,11 +1,8 @@ DEBU Set logs-level=debug logs-file=/dev/stdout - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/priority-log-check DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false 👽 Atmos test on darwin/arm64 - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/priority-log-check DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/priority-log-check DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false DEBU Telemetry event enqueued diff --git a/tests/snapshots/TestCLICommands_Valid_log_level_in_env_should_be_priortized_over_config.stderr.golden b/tests/snapshots/TestCLICommands_Valid_log_level_in_env_should_be_priortized_over_config.stderr.golden index ed90e1cfcc..57abe3b697 100644 --- a/tests/snapshots/TestCLICommands_Valid_log_level_in_env_should_be_priortized_over_config.stderr.golden +++ b/tests/snapshots/TestCLICommands_Valid_log_level_in_env_should_be_priortized_over_config.stderr.golden @@ -1,8 +1,5 @@ DEBU Set logs-level=debug logs-file=/dev/stderr - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/priority-log-check DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/priority-log-check DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/priority-log-check DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false DEBU Telemetry event enqueued diff --git a/tests/snapshots/TestCLICommands_Valid_log_level_in_flag_should_be_priortized_over_env_and_config.stderr.golden b/tests/snapshots/TestCLICommands_Valid_log_level_in_flag_should_be_priortized_over_env_and_config.stderr.golden index ed90e1cfcc..57abe3b697 100644 --- a/tests/snapshots/TestCLICommands_Valid_log_level_in_flag_should_be_priortized_over_env_and_config.stderr.golden +++ b/tests/snapshots/TestCLICommands_Valid_log_level_in_flag_should_be_priortized_over_env_and_config.stderr.golden @@ -1,8 +1,5 @@ DEBU Set logs-level=debug logs-file=/dev/stderr - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/priority-log-check DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/priority-log-check DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/priority-log-check DEBU Found ENV variable ATMOS_VERSION_CHECK_ENABLED=false DEBU Telemetry event enqueued diff --git a/tests/snapshots/TestCLICommands_atmos_describe_config_imports.stderr.golden b/tests/snapshots/TestCLICommands_atmos_describe_config_imports.stderr.golden index 196b7533b7..3a4a00d6bb 100644 --- a/tests/snapshots/TestCLICommands_atmos_describe_config_imports.stderr.golden +++ b/tests/snapshots/TestCLICommands_atmos_describe_config_imports.stderr.golden @@ -1,19 +1,14 @@ DEBU Set logs-level=debug logs-file=/dev/stderr DEBU processConfigImports resolved paths count=6 DEBU processConfigImports resolved paths count=6 - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-cli-imports DEBU processConfigImports resolved paths count=6 DEBU processConfigImports resolved paths count=6 - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-cli-imports **Notice:** Telemetry Enabled - Atmos now collects anonymous telemetry regarding usage. This information is used to shape the Atmos roadmap and prioritize features. You can learn more, including how to opt out if you'd prefer not to participate in this anonymous program, by visiting: https://atmos.tools/cli/telemetry DEBU processConfigImports resolved paths count=6 DEBU processConfigImports resolved paths count=6 - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-cli-imports DEBU processConfigImports resolved paths count=6 DEBU processConfigImports resolved paths count=6 - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-cli-imports DEBU processConfigImports resolved paths count=6 DEBU processConfigImports resolved paths count=6 - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-cli-imports DEBU Telemetry event enqueued diff --git a/tests/snapshots/TestCLICommands_atmos_describe_configuration.stderr.golden b/tests/snapshots/TestCLICommands_atmos_describe_configuration.stderr.golden index bdecee6096..a331b199e5 100644 --- a/tests/snapshots/TestCLICommands_atmos_describe_configuration.stderr.golden +++ b/tests/snapshots/TestCLICommands_atmos_describe_configuration.stderr.golden @@ -1,19 +1,14 @@ DEBU Set logs-level=debug logs-file=/dev/stderr DEBU Loaded configuration directory source=atmos.d files=5 pattern=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-configuration/atmos.d/**/* DEBU Loaded configuration directory source=atmos.d files=5 pattern=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-configuration/atmos.d/**/* - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-configuration DEBU Loaded configuration directory source=atmos.d files=5 pattern=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-configuration/atmos.d/**/* DEBU Loaded configuration directory source=atmos.d files=5 pattern=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-configuration/atmos.d/**/* - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-configuration **Notice:** Telemetry Enabled - Atmos now collects anonymous telemetry regarding usage. This information is used to shape the Atmos roadmap and prioritize features. You can learn more, including how to opt out if you'd prefer not to participate in this anonymous program, by visiting: https://atmos.tools/cli/telemetry DEBU Loaded configuration directory source=atmos.d files=5 pattern=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-configuration/atmos.d/**/* DEBU Loaded configuration directory source=atmos.d files=5 pattern=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-configuration/atmos.d/**/* - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-configuration DEBU Loaded configuration directory source=atmos.d files=5 pattern=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-configuration/atmos.d/**/* DEBU Loaded configuration directory source=atmos.d files=5 pattern=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-configuration/atmos.d/**/* - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-configuration DEBU Loaded configuration directory source=atmos.d files=5 pattern=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-configuration/atmos.d/**/* DEBU Loaded configuration directory source=atmos.d files=5 pattern=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-configuration/atmos.d/**/* - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/atmos-configuration DEBU Telemetry event enqueued diff --git a/tests/snapshots/TestCLICommands_atmos_vendor_pull_component_using_SSH.stderr.golden b/tests/snapshots/TestCLICommands_atmos_vendor_pull_component_using_SSH.stderr.golden index fea8192586..3674af6c4f 100644 --- a/tests/snapshots/TestCLICommands_atmos_vendor_pull_component_using_SSH.stderr.golden +++ b/tests/snapshots/TestCLICommands_atmos_vendor_pull_component_using_SSH.stderr.golden @@ -1,12 +1,8 @@ DEBU Set logs-level=debug logs-file=/dev/stderr - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendoring-dry-run - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendoring-dry-run **Notice:** Telemetry Enabled - Atmos now collects anonymous telemetry regarding usage. This information is used to shape the Atmos roadmap and prioritize features. You can learn more, including how to opt out if you'd prefer not to participate in this anonymous program, by visiting: https://atmos.tools/cli/telemetry - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendoring-dry-run DEBU ProcessCommandLineArgs input componentType=terraform args=[] DEBU After ParseFlags identity.Value="" identity.Changed=false - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendoring-dry-run DEBU Vendor config file not found file=vendor DEBU No TTY detected. Falling back to basic output. This can happen when no terminal is attached or when commands are pipelined. DEBU CustomGitDetector.Detect called @@ -17,6 +13,4 @@ INFO ✓ package=ipinfo version=(main) INFO Done! Dry run completed. No components vendored INFO Vendored components success=1 - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendoring-dry-run - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendoring-dry-run DEBU Telemetry event enqueued diff --git a/tests/snapshots/TestCLICommands_atmos_vendor_pull_using_SSH.stderr.golden b/tests/snapshots/TestCLICommands_atmos_vendor_pull_using_SSH.stderr.golden index 0e1f09a649..0d8dd61d54 100644 --- a/tests/snapshots/TestCLICommands_atmos_vendor_pull_using_SSH.stderr.golden +++ b/tests/snapshots/TestCLICommands_atmos_vendor_pull_using_SSH.stderr.golden @@ -1,14 +1,10 @@ DEBU Set logs-level=debug logs-file=/dev/stderr - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendor-pulls-ssh - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendor-pulls-ssh **Notice:** Telemetry Enabled - Atmos now collects anonymous telemetry regarding usage. This information is used to shape the Atmos roadmap and prioritize features. You can learn more, including how to opt out if you'd prefer not to participate in this anonymous program, by visiting: https://atmos.tools/cli/telemetry - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendor-pulls-ssh DEBU ProcessCommandLineArgs input componentType=terraform args=[] DEBU After ParseFlags identity.Value="" identity.Changed=false - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendor-pulls-ssh INFO Vendoring from 'vendor.yaml' - DEBU Added /. to Git URL without subdirectory normalized="git::git@github.com:cloudposse/terraform-null-label.git//.?ref=0.25.0" + DEBU Added //. to Git URL without subdirectory normalized="git::git@github.com:cloudposse/terraform-null-label.git//.?ref=0.25.0" DEBU Added //. to Git URL without subdirectory normalized="git::ssh://git@github.com/cloudposse/terraform-null-label.git//.?ref=0.25.0" DEBU No TTY detected. Falling back to basic output. This can happen when no terminal is attached or when commands are pipelined. DEBU Downloading and installing package package=terraform-null-label-cred @@ -26,6 +22,4 @@ INFO ✓ package=terraform-null-label-basic version=(0.25.0) INFO Done! Dry run completed. No components vendored INFO Vendored components success=2 - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendor-pulls-ssh - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendor-pulls-ssh DEBU Telemetry event enqueued diff --git a/tests/snapshots/TestCLICommands_atmos_vendor_pull_with_custom_detector_and_handling_credentials_leakage.stderr.golden b/tests/snapshots/TestCLICommands_atmos_vendor_pull_with_custom_detector_and_handling_credentials_leakage.stderr.golden index 5b6a15e42b..5184d02d41 100644 --- a/tests/snapshots/TestCLICommands_atmos_vendor_pull_with_custom_detector_and_handling_credentials_leakage.stderr.golden +++ b/tests/snapshots/TestCLICommands_atmos_vendor_pull_with_custom_detector_and_handling_credentials_leakage.stderr.golden @@ -1,14 +1,10 @@ DEBU Set logs-level=debug logs-file=/dev/stderr - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendor-creds-sanitize - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendor-creds-sanitize **Notice:** Telemetry Enabled - Atmos now collects anonymous telemetry regarding usage. This information is used to shape the Atmos roadmap and prioritize features. You can learn more, including how to opt out if you'd prefer not to participate in this anonymous program, by visiting: https://atmos.tools/cli/telemetry - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendor-creds-sanitize DEBU ProcessCommandLineArgs input componentType=terraform args=[] DEBU After ParseFlags identity.Value="" identity.Changed=false - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendor-creds-sanitize INFO Vendoring from 'vendor.yaml' - DEBU Added /. to Git URL without subdirectory normalized="github.com/cloudposse/terraform-null-label.git//.?ref=0.25.0" + DEBU Added //. to Git URL without subdirectory normalized="github.com/cloudposse/terraform-null-label.git//.?ref=0.25.0" DEBU Added //. to Git URL without subdirectory normalized="https://myuser:supersecret@github.com/cloudposse/terraform-null-label.git/.?ref=0.25.0" DEBU Added //. to Git URL without subdirectory normalized="https://git@github.com/cloudposse/terraform-null-label.git/.?ref=0.25.0" DEBU Added //. to Git URL without subdirectory normalized="gitlab.com/gitlab-org/ci-cd/deploy-stage/environments-group/examples/gitlab-terraform-aws.git//.?ref=master" @@ -51,6 +47,4 @@ INFO ✓ package=terraform-bitbucket-deploy-jenkins version=(master) INFO Done! Dry run completed. No components vendored INFO Vendored components success=5 - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendor-creds-sanitize - DEBU Found config ENV ATMOS_CLI_CONFIG_PATH=/absolute/path/to/repo/tests/fixtures/scenarios/vendor-creds-sanitize DEBU Telemetry event enqueued diff --git a/tests/test-cases/auth-mock.yaml b/tests/test-cases/auth-mock.yaml index 2a1d7b4ca5..6a92d5a954 100644 --- a/tests/test-cases/auth-mock.yaml +++ b/tests/test-cases/auth-mock.yaml @@ -240,6 +240,9 @@ tests: - "--" - "echo" - "test" + skip: + # echo is a shell built-in on Windows, not an executable in PATH. + os: !not windows expect: diff: [] stderr: [] diff --git a/tests/test-cases/core.yaml b/tests/test-cases/core.yaml index 3cc786291a..2e13fe49a8 100644 --- a/tests/test-cases/core.yaml +++ b/tests/test-cases/core.yaml @@ -25,6 +25,9 @@ tests: description: "Ensure tty is disabled." workdir: "../" command: "tty" + skip: + # tty command not available on Windows + os: !not windows expect: stdout: - "^not a tty" diff --git a/tests/test-cases/workflows.yaml b/tests/test-cases/workflows.yaml index 687188e281..02dc9ca9e1 100644 --- a/tests/test-cases/workflows.yaml +++ b/tests/test-cases/workflows.yaml @@ -25,6 +25,9 @@ tests: - "shell-pass" - "--file" - "test" + skip: + # echo is a shell built-in on Windows, not an executable in PATH. + os: !not windows expect: diff: [] stdout: @@ -42,6 +45,9 @@ tests: - "shell-command-not-found" - "--file" - "test" + skip: + # Exit code 127 is Unix/POSIX specific. Windows returns different codes. + os: !not windows expect: diff: [] stderr: @@ -86,6 +92,9 @@ tests: - "shell-failure" - "--file" - "test" + skip: + # Uses echo which is a shell built-in on Windows, not an executable in PATH. + os: !not windows expect: diff: [] stderr: @@ -285,6 +294,9 @@ tests: - "shell-with-path" - "--file" - "test" + skip: + # Uses env and grep which are not available on Windows. + os: !not windows expect: diff: [] stdout: diff --git a/website/docs/cli/configuration/configuration.mdx b/website/docs/cli/configuration/configuration.mdx index 285b8c4c23..7632a9bfd5 100644 --- a/website/docs/cli/configuration/configuration.mdx +++ b/website/docs/cli/configuration/configuration.mdx @@ -32,9 +32,9 @@ 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. **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) +4. **Current directory** (`./atmos.yaml`) - CWD only, no parent search +5. **Git repository root** (`repo-root/atmos.yaml`) - if in a git repository +6. **Parent directory search** - walks up from CWD looking for `atmos.yaml` 7. **Home directory** (`~/.atmos/atmos.yaml`) 8. **System directory** (`/usr/local/etc/atmos/atmos.yaml` on Linux, `%LOCALAPPDATA%/atmos/atmos.yaml` on Windows) @@ -166,6 +166,19 @@ Atmos extends standard YAML with custom functions that provide powerful tools fo See the [`!repo-root` documentation](/functions/yaml/repo-root) for more details. + +
`!cwd`
+
+ Retrieve the current working directory. Useful when you need paths relative to where Atmos is executed from rather than relative to the configuration file. + + ```yaml + base_path: !cwd + # or with a relative path + base_path: !cwd ./relative/path + ``` + + See the [`!cwd` documentation](/functions/yaml/cwd) for more details. +
## Base Path @@ -182,7 +195,43 @@ It can also be set using: It supports both absolute and relative paths. -### Path Resolution +### Path Resolution Semantics + +The `base_path` value determines where Atmos looks for stacks and components. Here's how different values are resolved: + +| `base_path` value | Resolves to | Description | +|-------------------|-------------|-------------| +| `""`, `~`, `null`, or unset | Git root, fallback to config directory | Smart default with git root discovery | +| `"."` | Config file directory | Relative to where `atmos.yaml` is located | +| `".."` | Parent of config file directory | Relative to where `atmos.yaml` is located | +| `"./foo"` | Config file directory + `foo` | Relative to where `atmos.yaml` is located | +| `"../foo"` | Parent of config directory + `foo` | Relative to where `atmos.yaml` is located | +| `"foo"` | Git root + `foo`, fallback to config directory + `foo` | Simple relative path with search | +| `"/absolute/path"` | `/absolute/path` | Explicit absolute path | +| `!repo-root` | Git repository root | Explicit git root tag | +| `!cwd` | Current working directory | Explicit CWD tag | + +**Why `.` and `..` are config-file-relative:** + +Paths in configuration files are relative to where the config file is located, not where you run from. This follows the convention established by other configuration files like `tsconfig.json`, `package.json`, and `.eslintrc`. + +For example, if your `atmos.yaml` is in `/repo/config/atmos.yaml` and contains: +```yaml +base_path: ".." # Resolves to /repo (parent of config directory) +``` + +This means you can run `atmos` from anywhere, and the paths will always resolve correctly relative to where the configuration file is defined. + +**Need CWD-relative behavior?** + +If you need paths relative to where Atmos is executed (the current working directory), use the `!cwd` YAML function: +```yaml +base_path: !cwd +# or with a relative path +base_path: !cwd ./my/path +``` + +### Subpath Configuration If `base_path` is not provided or is an empty string, the following paths are independent settings (supporting both absolute and relative paths): - `components.terraform.base_path` diff --git a/website/docs/functions/yaml/cwd.mdx b/website/docs/functions/yaml/cwd.mdx new file mode 100644 index 0000000000..5687d340ad --- /dev/null +++ b/website/docs/functions/yaml/cwd.mdx @@ -0,0 +1,84 @@ +--- +title: "!cwd" +sidebar_position: 11 +sidebar_label: "!cwd" +sidebar_class_name: command +description: Get the current working directory +--- +import Intro from '@site/src/components/Intro' + + + The `!cwd` Atmos YAML function retrieves the current working directory where Atmos is executed from. + + +## Usage + +The `!cwd` function can be called with or without a relative path: + +```yaml +# Get the current working directory +base_path: !cwd + +# Get CWD joined with a relative path +base_path: !cwd ./relative/path +``` + +## Why Use `!cwd` + +By default, relative paths in `atmos.yaml` (like `.` and `..`) are resolved relative to the config file location, not where you run the command. This follows the convention of other config files like `tsconfig.json` and `package.json`. + +Use `!cwd` when you need paths relative to where Atmos is executed, such as: + +- Running Atmos from within a component directory with a shared config +- Dynamic path resolution based on the current directory +- Scripts that change directories before running Atmos + +## Examples + +### Basic Usage + +```yaml +# atmos.yaml +# base_path will be set to the current working directory +base_path: !cwd +``` + +### With Relative Path + +```yaml +# atmos.yaml +# base_path will be CWD + ./components +base_path: !cwd ./components +``` + +### Component-Relative Execution + +A common use case is running Atmos from within a component directory while pointing to a shared configuration: + +```bash +# Repository structure: +# /repo/ +# atmos.yaml # shared config with base_path: !cwd +# components/terraform/vpc/ +# main.tf + +# When running from within a component: +cd /repo/components/terraform/vpc +ATMOS_CLI_CONFIG_PATH=/repo atmos terraform plan vpc -s prod + +# base_path resolves to /repo/components/terraform/vpc (the CWD) +``` + +## Comparison with Other Path Options + +| Configuration | Resolves to | Use when | +|---------------|-------------|----------| +| `base_path: "."` | Config file directory | Paths should be relative to atmos.yaml | +| `base_path: !cwd` | Current working directory | Paths should be relative to where you run from | +| `base_path: !repo-root` | Git repository root | Paths should be relative to repo root | +| `base_path: ""` | Git root with fallback to config dir | Smart default behavior | + +## See Also + +- [`!repo-root`](/functions/yaml/repo-root) - Get the Git repository root +- [Path Resolution Semantics](/cli/configuration#path-resolution-semantics) - How Atmos resolves paths diff --git a/website/docs/functions/yaml/index.mdx b/website/docs/functions/yaml/index.mdx index 71f129c0ec..c2e0353882 100644 --- a/website/docs/functions/yaml/index.mdx +++ b/website/docs/functions/yaml/index.mdx @@ -58,41 +58,45 @@ YAML supports three types of data: core, defined, and user-defined. directly from a [store](/cli/configuration/stores) without following the Atmos stack/component/key naming convention. This is useful for accessing values stored by external systems - or for retrieving global configuration that doesn't belong to a specific component + or for retrieving global configuration that doesn't belong to a specific component. - The [__`!include`__](/functions/yaml/include) YAML function allows downloading local or remote files from different sources, - and assigning the file contents or individual values to the sections in Atmos stack manifests + and assigning the file contents or individual values to the sections in Atmos stack manifests. - The [__`!template`__](/functions/yaml/template) YAML function is designed to [evaluate and inject outputs containing maps or lists](/functions/template/atmos.Component#handling-outputs-containing-maps-or-lists) into the YAML document, whether generated by the [__`atmos.Component`__](/functions/template/atmos.Component) template function or any Go template. - The [__`!exec`__](/functions/yaml/exec) YAML function is used to execute shell scripts and assign - the results to the sections in Atmos stack manifests + the results to the sections in Atmos stack manifests. - The [__`!env`__](/functions/yaml/env) YAML function is used to retrieve environment variables - and assign them to the sections in Atmos stack manifests + and assign them to the sections in Atmos stack manifests. - The [__`!repo-root`__](/functions/yaml/repo-root) YAML function is used to retrieve the - root directory of the Atmos repository + root directory of the Atmos repository. + + - The [__`!cwd`__](/functions/yaml/cwd) YAML function is used to retrieve the current working directory + where Atmos is executed from. Useful when you need paths relative to where you run Atmos + rather than relative to the configuration file. - The [__`!random`__](/functions/yaml/random) YAML function generates a cryptographically secure random integer - within a specified range, useful for generating random port numbers or IDs + within a specified range, useful for generating random port numbers or IDs. - The [__`!literal`__](/functions/yaml/literal) YAML function preserves values exactly as written, bypassing all template processing. Use it to pass template-like syntax (e.g., `{{...}}` or `${...}`) to downstream tools like Terraform, Helm, or ArgoCD without Atmos attempting to evaluate them. - The [__`!aws.account_id`__](/functions/yaml/aws.account-id) YAML function retrieves the AWS account ID - of the current caller identity using STS GetCallerIdentity + of the current caller identity using STS GetCallerIdentity. - The [__`!aws.caller_identity_arn`__](/functions/yaml/aws.caller-identity-arn) YAML function retrieves the full ARN - of the current AWS caller identity using STS GetCallerIdentity + of the current AWS caller identity using STS GetCallerIdentity. - The [__`!aws.caller_identity_user_id`__](/functions/yaml/aws.caller-identity-user-id) YAML function retrieves the unique user ID - of the current AWS caller identity using STS GetCallerIdentity + of the current AWS caller identity using STS GetCallerIdentity. - The [__`!aws.region`__](/functions/yaml/aws.region) YAML function retrieves the AWS region - from the current SDK configuration + from the current SDK configuration. :::tip You can combine [Atmos Stack Manifest Templating](/templates) with Atmos YAML functions within the same stack configuration.