Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions cmd/config/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,46 @@ import (
cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/data"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/ui"
u "github.com/cloudposse/atmos/pkg/utils"
atmosyaml "github.com/cloudposse/atmos/pkg/yaml"
)

// valueType holds the --type flag for `config set`.
var valueType string

var configGetCmd = &cobra.Command{
Use: "get <path>",
Short: "Read a value from atmos.yaml by dot-notation path",
Long: "Read a value from atmos.yaml using a dot-notation path (e.g. logs.level).",
Use: "get <path>",
Short: "Read a value from the effective Atmos configuration by dot-notation path",
Long: `Read a value using a dot-notation path (e.g. logs.level) from the effective,
fully-merged Atmos configuration for this invocation -- the same configuration
"terraform plan", "list stacks", etc. actually use, including every --config
file, --config-path directory, and profile applied on top of each other. This
can differ from what a single physical atmos.yaml file declares on its own
(cloudposse/atmos#2867): use "atmos config format" or read the file directly
to inspect one file's own declared value instead.`,
Example: "atmos config get logs.level",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
defer perf.Track(atmosConfigPtr, "config.getRunE")()

file, err := resolveConfigFile(cmd)
// Reload rather than reuse atmosConfigPtr, mirroring configListCmd: this keeps `get`
// independently correct (and independently testable via RunE) even before root.go's
// PersistentPreRun has populated the package-level pointer. Safe to call with an empty
// ConfigAndStacksInfo{} -- LoadConfig now falls back to os.Args/env for
// --config/--config-path/--base-path itself (cloudposse/atmos#2868).
atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false)
if err != nil {
return err
}

effectiveYAML, err := u.ConvertToYAML(atmosConfig)
if err != nil {
return err
}

value, err := atmosyaml.GetFile(file, args[0])
value, err := atmosyaml.Get([]byte(effectiveYAML), args[0])
if err != nil {
return err
}
Expand Down
62 changes: 62 additions & 0 deletions cmd/config/operations_test.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
package config

import (
"bytes"
stdio "io"
"os"
"path/filepath"
"strings"
"testing"

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

errUtils "github.com/cloudposse/atmos/errors"
"github.com/cloudposse/atmos/pkg/data"
iolib "github.com/cloudposse/atmos/pkg/io"
"github.com/cloudposse/atmos/pkg/schema"
atmosyaml "github.com/cloudposse/atmos/pkg/yaml"
)
Expand Down Expand Up @@ -146,6 +151,63 @@ func TestConfigGetCommand_MissingValue(t *testing.T) {
require.ErrorIs(t, err, atmosyaml.ErrYAMLPathNotFound)
}

// configGetTestStreams is a minimal io.Streams implementation for capturing data output,
// mirroring configSchemaTestStreams in schema_test.go.
type configGetTestStreams struct {
stdin stdio.Reader
stdout *bytes.Buffer
stderr *bytes.Buffer
}

func (ts *configGetTestStreams) Input() stdio.Reader { return ts.stdin }
func (ts *configGetTestStreams) Output() stdio.Writer { return ts.stdout }
func (ts *configGetTestStreams) Error() stdio.Writer { return ts.stderr }
func (ts *configGetTestStreams) RawOutput() stdio.Writer { return ts.stdout }
func (ts *configGetTestStreams) RawError() stdio.Writer { return ts.stderr }

// TestConfigGetCommand_ReportsEffectiveMergedValue reproduces the "stale value" half of
// cloudposse/atmos#2867: `atmos config get` used to read only the FIRST --config file
// directly off disk (resolveConfigFile picked cfgFiles[0]), so a second --config file's
// override was invisible to `get` even though the rest of atmos (stack discovery, etc.)
// correctly used the merged value. `get` must report the same effective value everything
// else uses.
func TestConfigGetCommand_ReportsEffectiveMergedValue(t *testing.T) {
dir := t.TempDir()
mainFile := filepath.Join(dir, "main.yaml")
fragmentFile := filepath.Join(dir, "fragment.yaml")

require.NoError(t, os.WriteFile(mainFile, []byte(`
base_path: "."
stacks:
base_path: "stacks"
included_paths:
- "deploy/**/*"
`), 0o644))
require.NoError(t, os.WriteFile(fragmentFile, []byte(`
stacks:
included_paths:
- "deploy/**/*"
- "other/**/*"
`), 0o644))

streams := &configGetTestStreams{stdin: &bytes.Buffer{}, stdout: &bytes.Buffer{}, stderr: &bytes.Buffer{}}
ioCtx, err := iolib.NewContext(iolib.WithStreams(streams))
require.NoError(t, err)
data.InitWriter(ioCtx)
t.Cleanup(data.Reset)

origArgs := os.Args
t.Cleanup(func() { os.Args = origArgs })
os.Args = []string{"atmos", "--config", mainFile + "," + fragmentFile, "config", "get", "stacks.included_paths"}

require.NoError(t, configGetCmd.RunE(configGetCmd, []string{"stacks.included_paths"}))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

output := streams.stdout.String()
assert.True(t, strings.Contains(output, "deploy/**/*"), "output should contain the first file's value: %s", output)
assert.True(t, strings.Contains(output, "other/**/*"),
"output must reflect the SECOND --config file's override, not just the first file's stale value: %s", output)
}

func TestConfigSetCommand_TypeVariants(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "atmos.yaml")
Expand Down
9 changes: 9 additions & 0 deletions internal/exec/describe_workflows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ workflows:
// Update config with the correct base path
config.BasePath = tmpDir
config.Workflows.BasePath = "stacks/workflows"
// Refresh derived absolute paths (e.g. WorkflowsDirAbsolutePath) after mutating BasePath
// directly -- real callers only ever get a fresh AtmosConfiguration from InitCliConfig,
// which computes these together; a manual post-load field mutation must recompute them
// too or ExecuteDescribeWorkflows would resolve against the stale pre-mutation paths.
require.NoError(t, cfg.AtmosConfigAbsolutePaths(&config))

tests := []struct {
name string
Expand Down Expand Up @@ -190,6 +195,7 @@ workflows:
config := initTestConfig(t)
config.BasePath = tmpDir
config.Workflows.BasePath = "stacks/workflows"
require.NoError(t, cfg.AtmosConfigAbsolutePaths(&config))

tests := []struct {
name string
Expand Down Expand Up @@ -287,6 +293,7 @@ workflows:
config := initTestConfig(t)
config.BasePath = tmpDir
config.Workflows.BasePath = "stacks/workflows"
require.NoError(t, cfg.AtmosConfigAbsolutePaths(&config))

// Should continue processing and return valid workflows despite invalid file.
listResult, _, _, err := ExecuteDescribeWorkflows(config)
Expand Down Expand Up @@ -326,6 +333,7 @@ some_other_key:
config := initTestConfig(t)
config.BasePath = tmpDir
config.Workflows.BasePath = "stacks/workflows"
require.NoError(t, cfg.AtmosConfigAbsolutePaths(&config))

// Should continue processing and return valid workflows.
listResult, _, _, err := ExecuteDescribeWorkflows(config)
Expand All @@ -344,6 +352,7 @@ func TestExecuteDescribeWorkflows_EmptyWorkflowsDirectory(t *testing.T) {
config := initTestConfig(t)
config.BasePath = tmpDir
config.Workflows.BasePath = "stacks/workflows"
require.NoError(t, cfg.AtmosConfigAbsolutePaths(&config))

// Empty workflows directory.
listResult, mapResult, allResult, err := ExecuteDescribeWorkflows(config)
Expand Down
12 changes: 12 additions & 0 deletions internal/exec/validate_component.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ func getBasePathToUse(atmosConfig *schema.AtmosConfiguration) string {
return atmosConfig.BasePath
}

// getWorkflowsDirToUse returns the appropriate workflows directory for file resolution. It
// prefers the precomputed WorkflowsDirAbsolutePath (set by AtmosConfigAbsolutePaths, the same
// mechanism cloudposse/atmos#2864 uses for the top-level base_path), falling back to joining
// the raw BasePath/Workflows.BasePath for callers that construct an AtmosConfiguration by hand
// without running it through AtmosConfigAbsolutePaths first (e.g. tests).
func getWorkflowsDirToUse(atmosConfig *schema.AtmosConfiguration) string {
if atmosConfig.WorkflowsDirAbsolutePath != "" {
return atmosConfig.WorkflowsDirAbsolutePath
}
return filepath.Join(atmosConfig.BasePath, atmosConfig.Workflows.BasePath)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// enableProvenanceForRichOutput sets atmosConfig.TrackProvenance when the
// resolved output format is "rich". Provenance is enabled only for the rich
// invocation: it lets the command map JSON Schema fields back to the
Expand Down
17 changes: 15 additions & 2 deletions internal/exec/vendor_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,19 +130,32 @@ func ReadAndProcessVendorConfigFile(
return vendorConfig, true, foundVendorConfigFile, nil
}

// getVendorDirToUse returns the appropriate vendor directory for file resolution. It prefers
// the precomputed VendorDirAbsolutePath (set by AtmosConfigAbsolutePaths, the same mechanism
// cloudposse/atmos#2864 uses for the top-level base_path), falling back to joining the raw
// BasePath/Vendor.BasePath for callers that construct an AtmosConfiguration by hand without
// running it through AtmosConfigAbsolutePaths first (e.g. tests). Mirrors getWorkflowsDirToUse
// and getBasePathToUse (validate_component.go).
func getVendorDirToUse(atmosConfig *schema.AtmosConfiguration) string {
if atmosConfig.VendorDirAbsolutePath != "" {
return atmosConfig.VendorDirAbsolutePath
}
return filepath.Join(atmosConfig.BasePath, atmosConfig.Vendor.BasePath)
}

// Helper function to resolve the vendor config file path.
func resolveVendorConfigFilePath(atmosConfig *schema.AtmosConfiguration, vendorConfigFile string, checkGlobalConfig bool) string {
if checkGlobalConfig && atmosConfig.Vendor.BasePath != "" {
if !filepath.IsAbs(atmosConfig.Vendor.BasePath) {
return filepath.Join(atmosConfig.BasePath, atmosConfig.Vendor.BasePath)
return getVendorDirToUse(atmosConfig)
}
return atmosConfig.Vendor.BasePath
}

// Search for the vendor config file
foundVendorConfigFile, fileExists := u.SearchConfigFile(vendorConfigFile)
if !fileExists {
pathToVendorConfig := filepath.Join(atmosConfig.BasePath, vendorConfigFile)
pathToVendorConfig := filepath.Join(getBasePathToUse(atmosConfig), vendorConfigFile)
foundVendorConfigFile, fileExists = u.SearchConfigFile(pathToVendorConfig)
if !fileExists {
return "" // File does not exist, but this is not an error
Expand Down
2 changes: 1 addition & 1 deletion internal/exec/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ func ExecuteWorkflowCmd(cmd *cobra.Command, args []string) error {
if u.IsPathAbsolute(workflowFile) {
workflowPath = workflowFile
} else {
workflowPath = filepath.Join(atmosConfig.BasePath, atmosConfig.Workflows.BasePath, workflowFile)
workflowPath = filepath.Join(getWorkflowsDirToUse(&atmosConfig), workflowFile)
}

// If the workflow file is specified without an extension, use the default extension
Expand Down
19 changes: 14 additions & 5 deletions internal/exec/workflow_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -944,8 +944,14 @@ func ExecuteWorkflow(
stepErr := err
if !errors.Is(err, errUtils.ErrInvalidWorkflowStepType) {
stepErr = buildWorkflowStepError(err, &workflowStepErrorContext{
WorkflowPath: workflowPath,
WorkflowBasePath: atmosConfig.Workflows.BasePath,
WorkflowPath: workflowPath,
// Must be the SAME anchor workflowPath was actually joined against
// (workflow.go), not the raw, always-relative atmosConfig.Workflows.BasePath
// -- otherwise the TrimPrefix below in buildWorkflowStepError silently fails
// to strip it whenever workflowPath ends up absolute (e.g. via the
// precomputed WorkflowsDirAbsolutePath), leaving the resume-command hint
// showing a garbled path instead of the plain workflow file name.
WorkflowBasePath: getWorkflowsDirToUse(&atmosConfig),
Workflow: workflow,
StepName: step.Name,
Command: command,
Expand Down Expand Up @@ -1098,12 +1104,15 @@ func ExecuteDescribeWorkflows(
return nil, nil, nil, errUtils.ErrWorkflowBasePathNotConfigured
}

// If `workflows.base_path` is a relative path, join it with `stacks.base_path`
// If `workflows.base_path` is a relative path, resolve it via getWorkflowsDirToUse
// (prefers the precomputed WorkflowsDirAbsolutePath over the raw, possibly still-relative
// atmosConfig.BasePath -- same bug shape cloudposse/atmos#2864 fixed for the top-level
// base_path itself).
var workflowsDir string
if u.IsPathAbsolute(atmosConfig.Workflows.BasePath) {
workflowsDir = atmosConfig.Workflows.BasePath
} else {
workflowsDir = filepath.Join(atmosConfig.BasePath, atmosConfig.Workflows.BasePath)
workflowsDir = getWorkflowsDirToUse(&atmosConfig)
}

isDirectory, err := u.IsDirectory(workflowsDir)
Expand All @@ -1122,7 +1131,7 @@ func ExecuteDescribeWorkflows(
if u.IsPathAbsolute(atmosConfig.Workflows.BasePath) {
workflowPath = filepath.Join(atmosConfig.Workflows.BasePath, f)
} else {
workflowPath = filepath.Join(atmosConfig.BasePath, atmosConfig.Workflows.BasePath, f)
workflowPath = filepath.Join(getWorkflowsDirToUse(&atmosConfig), f)
}

fileContent, err := os.ReadFile(workflowPath)
Expand Down
25 changes: 18 additions & 7 deletions pkg/config/base_path_resolution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -739,8 +739,17 @@ func TestInitCliConfig_BasePathSource_SetForEnvVar(t *testing.T) {
"BasePathSource should be 'runtime' when ATMOS_BASE_PATH env var is set")
}

// TestFindAllStackConfigsInPathsForStack_ErrorWrapping verifies that when GetGlobMatches
// fails, the error is wrapped with the ErrFailedToFindImport sentinel.
// TestFindAllStackConfigsInPathsForStack_ErrorWrapping verifies that when every
// included_paths entry matches nothing (e.g. the whole stacks directory doesn't exist),
// the function still errors -- with ErrNoStackManifestsFound, not ErrFailedToFindImport.
//
// Prior to cloudposse/atmos#2867's fix, a single entry matching nothing surfaced
// ErrFailedToFindImport directly from inside the per-path loop, which also meant a SECOND,
// valid included_paths entry earlier in the list would have its already-found matches
// discarded by this same hard error. Now, an individual entry matching nothing is treated as
// "nothing here, keep looking" and only the aggregate "nothing matched at all" case (this
// test: the only entry present matches nothing) errors, with a sentinel describing that
// outcome directly instead of leaking the glob-matching implementation's own error identity.
func TestFindAllStackConfigsInPathsForStack_ErrorWrapping(t *testing.T) {
atmosConfig := schema.AtmosConfiguration{
StacksBaseAbsolutePath: filepath.Join(os.TempDir(), "nonexistent-stacks-dir-test"),
Expand All @@ -759,11 +768,13 @@ func TestFindAllStackConfigsInPathsForStack_ErrorWrapping(t *testing.T) {

require.Error(t, err)

assert.True(t, errors.Is(err, errUtils.ErrFailedToFindImport),
"Error should wrap ErrFailedToFindImport, got: %v", err)
assert.True(t, errors.Is(err, errUtils.ErrNoStackManifestsFound),
"Error should wrap ErrNoStackManifestsFound, got: %v", err)
}

// TestFindAllStackConfigsInPaths_ErrorWrapping verifies error wrapping in the non-stack variant.
// TestFindAllStackConfigsInPaths_ErrorWrapping verifies error wrapping in the non-stack
// variant. See TestFindAllStackConfigsInPathsForStack_ErrorWrapping above for why this is
// ErrNoStackManifestsFound rather than ErrFailedToFindImport post-#2867.
func TestFindAllStackConfigsInPaths_ErrorWrapping(t *testing.T) {
atmosConfig := schema.AtmosConfiguration{
StacksBaseAbsolutePath: filepath.Join(os.TempDir(), "nonexistent-stacks-dir-test2"),
Expand All @@ -781,6 +792,6 @@ func TestFindAllStackConfigsInPaths_ErrorWrapping(t *testing.T) {

require.Error(t, err)

assert.True(t, errors.Is(err, errUtils.ErrFailedToFindImport),
"Error should wrap ErrFailedToFindImport, got: %v", err)
assert.True(t, errors.Is(err, errUtils.ErrNoStackManifestsFound),
"Error should wrap ErrNoStackManifestsFound, got: %v", err)
}
19 changes: 19 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,25 @@ func AtmosConfigAbsolutePaths(atmosConfig *schema.AtmosConfiguration) error {
}
atmosConfig.HelmDirAbsolutePath = helmDirAbsPath

// Convert Vendor base path to an absolute path. Consumers previously re-joined the raw
// (possibly still-relative) atmosConfig.BasePath at call time instead of using a
// precomputed absolute path -- the same bug shape #2864 fixed for the top-level
// base_path itself, just not yet applied here.
vendorBasePath := u.JoinPath(atmosBasePathAbs, atmosConfig.Vendor.BasePath)
vendorDirAbsPath, err := filepath.Abs(vendorBasePath)
if err != nil {
return err
}
atmosConfig.VendorDirAbsolutePath = vendorDirAbsPath

// Convert Workflows base path to an absolute path (same rationale as Vendor above).
workflowsBasePath := u.JoinPath(atmosBasePathAbs, atmosConfig.Workflows.BasePath)
workflowsDirAbsPath, err := filepath.Abs(workflowsBasePath)
if err != nil {
return err
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
atmosConfig.WorkflowsDirAbsolutePath = workflowsDirAbsPath

return nil
}

Expand Down
21 changes: 21 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,27 @@ func TestAtmosConfigAbsolutePaths(t *testing.T) {
assert.Equal(t, absPath, config.Components.Helmfile.BasePath)
assert.Equal(t, absPath, config.Stacks.BasePath)
})

// TestAtmosConfigAbsolutePaths_VendorAndWorkflows guards against the same bug shape
// cloudposse/atmos#2864 fixed for the top-level base_path: Vendor.BasePath and
// Workflows.BasePath were never centralized into an absolute field, so consumers
// re-joined the raw (possibly still-relative) atmosConfig.BasePath at call time instead.
t.Run("computes vendor and workflows absolute paths", func(t *testing.T) {
baseDir := filepath.Join(os.TempDir(), "atmos-vendor-workflows-test")
config := &schema.AtmosConfiguration{
BasePath: baseDir,
Vendor: schema.Vendor{BasePath: "vendor.yaml"},
Workflows: schema.Workflows{
BasePath: "stacks/workflows",
},
}

err := AtmosConfigAbsolutePaths(config)
assert.NoError(t, err)

assert.Equal(t, filepath.Join(baseDir, "vendor.yaml"), config.VendorDirAbsolutePath)
assert.Equal(t, filepath.Join(baseDir, "stacks", "workflows"), config.WorkflowsDirAbsolutePath)
})
}

// Helper functions.
Expand Down
Loading
Loading