diff --git a/.golangci.yml b/.golangci.yml index 65edc18f70..e4222ff797 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -66,6 +66,7 @@ linters: - "!**/pkg/auth/factory/**" - "!**/pkg/auth/types/aws_credentials.go" - "!**/pkg/auth/types/github_oidc_credentials.go" + - "!**/internal/aws_utils/**" - "$test" deny: # AWS: Identity and auth-related SDKs diff --git a/errors/errors.go b/errors/errors.go index cfabf10c97..07ac4a0632 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -90,6 +90,7 @@ var ( ErrInvalidTerraformSingleComponentAndMultiComponentFlags = errors.New("the single-component flags (`--from-plan`, `--planfile`) can't be used with the multi-component (bulk operations) flags (`--affected`, `--all`, `--query`, `--components`)") ErrYamlFuncInvalidArguments = errors.New("invalid number of arguments in the Atmos YAML function") + ErrAwsGetCallerIdentity = errors.New("failed to get AWS caller identity") ErrDescribeComponent = errors.New("failed to describe component") ErrReadTerraformState = errors.New("failed to read Terraform state") ErrEvaluateTerraformBackendVariable = errors.New("failed to evaluate terraform backend variable") diff --git a/internal/aws_utils/aws_utils.go b/internal/aws_utils/aws_utils.go index 0b3526399b..f6dfec612f 100644 --- a/internal/aws_utils/aws_utils.go +++ b/internal/aws_utils/aws_utils.go @@ -96,7 +96,7 @@ func LoadAWSConfigWithAuth( baseCfg, err := config.LoadDefaultConfig(ctx, cfgOpts...) if err != nil { log.Debug("Failed to load AWS config", "error", err) - return aws.Config{}, fmt.Errorf("%w: %v", errUtils.ErrLoadAwsConfig, err) + return aws.Config{}, fmt.Errorf("%w: %w", errUtils.ErrLoadAwsConfig, err) } log.Debug("Successfully loaded AWS SDK config", "region", baseCfg.Region) @@ -126,3 +126,54 @@ func LoadAWSConfig(ctx context.Context, region string, roleArn string, assumeRol return LoadAWSConfigWithAuth(ctx, region, roleArn, assumeRoleDuration, nil) } + +// AWSCallerIdentityResult holds the result of GetAWSCallerIdentity. +type AWSCallerIdentityResult struct { + Account string + Arn string + UserID string + Region string +} + +// GetAWSCallerIdentity retrieves AWS caller identity using STS GetCallerIdentity API. +// Returns account ID, ARN, user ID, and region. +// This function keeps AWS SDK STS imports contained within aws_utils package. +func GetAWSCallerIdentity( + ctx context.Context, + region string, + roleArn string, + assumeRoleDuration time.Duration, + authContext *schema.AWSAuthContext, +) (*AWSCallerIdentityResult, error) { + defer perf.Track(nil, "aws_utils.GetAWSCallerIdentity")() + + // Load AWS config. + cfg, err := LoadAWSConfigWithAuth(ctx, region, roleArn, assumeRoleDuration, authContext) + if err != nil { + return nil, err + } + + // Create STS client and get caller identity. + stsClient := sts.NewFromConfig(cfg) + output, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + if err != nil { + return nil, fmt.Errorf("%w: %w", errUtils.ErrAwsGetCallerIdentity, err) + } + + result := &AWSCallerIdentityResult{ + Region: cfg.Region, + } + + // Extract values from pointers. + if output.Account != nil { + result.Account = *output.Account + } + if output.Arn != nil { + result.Arn = *output.Arn + } + if output.UserId != nil { + result.UserID = *output.UserId + } + + return result, nil +} diff --git a/internal/exec/aws_getter.go b/internal/exec/aws_getter.go new file mode 100644 index 0000000000..27109f8ca8 --- /dev/null +++ b/internal/exec/aws_getter.go @@ -0,0 +1,164 @@ +package exec + +import ( + "context" + "fmt" + "sync" + + awsUtils "github.com/cloudposse/atmos/internal/aws_utils" + log "github.com/cloudposse/atmos/pkg/logger" + "github.com/cloudposse/atmos/pkg/perf" + "github.com/cloudposse/atmos/pkg/schema" +) + +// AWSCallerIdentity holds the information returned by AWS STS GetCallerIdentity. +type AWSCallerIdentity struct { + Account string + Arn string + UserID string + Region string // The AWS region from the loaded config. +} + +// AWSGetter provides an interface for retrieving AWS caller identity information. +// This interface enables dependency injection and testability. +// +//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -source=$GOFILE -destination=mock_aws_getter_test.go -package=exec +type AWSGetter interface { + // GetCallerIdentity retrieves the AWS caller identity for the current credentials. + // Returns the account ID, ARN, and user ID of the calling identity. + GetCallerIdentity( + ctx context.Context, + atmosConfig *schema.AtmosConfiguration, + authContext *schema.AWSAuthContext, + ) (*AWSCallerIdentity, error) +} + +// defaultAWSGetter is the production implementation that uses real AWS SDK calls. +type defaultAWSGetter struct{} + +// GetCallerIdentity retrieves the AWS caller identity using the STS GetCallerIdentity API. +func (d *defaultAWSGetter) GetCallerIdentity( + ctx context.Context, + atmosConfig *schema.AtmosConfiguration, + authContext *schema.AWSAuthContext, +) (*AWSCallerIdentity, error) { + defer perf.Track(atmosConfig, "exec.AWSGetter.GetCallerIdentity")() + + log.Debug("Getting AWS caller identity") + + // Use the aws_utils helper to get caller identity (keeps AWS SDK imports in aws_utils). + result, err := awsUtils.GetAWSCallerIdentity(ctx, "", "", 0, authContext) + if err != nil { + return nil, err // Error already wrapped by aws_utils. + } + + identity := &AWSCallerIdentity{ + Account: result.Account, + Arn: result.Arn, + UserID: result.UserID, + Region: result.Region, + } + + log.Debug("Retrieved AWS caller identity", + "account", identity.Account, + "arn", identity.Arn, + "user_id", identity.UserID, + "region", identity.Region, + ) + + return identity, nil +} + +// awsGetter is the global instance used by YAML functions. +// This allows test code to replace it with a mock. +var awsGetter AWSGetter = &defaultAWSGetter{} + +// SetAWSGetter allows tests to inject a mock AWSGetter. +// Returns a function to restore the original getter. +func SetAWSGetter(getter AWSGetter) func() { + defer perf.Track(nil, "exec.SetAWSGetter")() + + original := awsGetter + awsGetter = getter + return func() { + awsGetter = original + } +} + +// cachedAWSIdentity holds the cached AWS caller identity. +// The cache is per-CLI-invocation (stored in memory) to avoid repeated STS calls. +type cachedAWSIdentity struct { + identity *AWSCallerIdentity + err error +} + +var ( + awsIdentityCache map[string]*cachedAWSIdentity + awsIdentityCacheMu sync.RWMutex +) + +func init() { + awsIdentityCache = make(map[string]*cachedAWSIdentity) +} + +// getCacheKey generates a cache key based on the auth context. +// Different auth contexts (different credentials) get different cache entries. +// Includes Profile, CredentialsFile, and ConfigFile since all three affect AWS config loading. +func getCacheKey(authContext *schema.AWSAuthContext) string { + if authContext == nil { + return "default" + } + return fmt.Sprintf("%s:%s:%s", authContext.Profile, authContext.CredentialsFile, authContext.ConfigFile) +} + +// getAWSCallerIdentityCached retrieves the AWS caller identity with caching. +// Results are cached per auth context to avoid repeated STS calls within the same CLI invocation. +func getAWSCallerIdentityCached( + ctx context.Context, + atmosConfig *schema.AtmosConfiguration, + authContext *schema.AWSAuthContext, +) (*AWSCallerIdentity, error) { + defer perf.Track(atmosConfig, "exec.getAWSCallerIdentityCached")() + + cacheKey := getCacheKey(authContext) + + // Check cache first (read lock). + awsIdentityCacheMu.RLock() + if cached, ok := awsIdentityCache[cacheKey]; ok { + awsIdentityCacheMu.RUnlock() + log.Debug("Using cached AWS caller identity", "cache_key", cacheKey) + return cached.identity, cached.err + } + awsIdentityCacheMu.RUnlock() + + // Cache miss - acquire write lock and fetch. + awsIdentityCacheMu.Lock() + defer awsIdentityCacheMu.Unlock() + + // Double-check after acquiring write lock. + if cached, ok := awsIdentityCache[cacheKey]; ok { + log.Debug("Using cached AWS caller identity (double-check)", "cache_key", cacheKey) + return cached.identity, cached.err + } + + // Fetch from AWS. + identity, err := awsGetter.GetCallerIdentity(ctx, atmosConfig, authContext) + + // Cache the result (including errors to avoid repeated failed calls). + awsIdentityCache[cacheKey] = &cachedAWSIdentity{ + identity: identity, + err: err, + } + + return identity, err +} + +// ClearAWSIdentityCache clears the AWS identity cache. +// This is useful in tests or when credentials change during execution. +func ClearAWSIdentityCache() { + defer perf.Track(nil, "exec.ClearAWSIdentityCache")() + + awsIdentityCacheMu.Lock() + defer awsIdentityCacheMu.Unlock() + awsIdentityCache = make(map[string]*cachedAWSIdentity) +} diff --git a/internal/exec/yaml_func_aws.go b/internal/exec/yaml_func_aws.go new file mode 100644 index 0000000000..7b8efb7963 --- /dev/null +++ b/internal/exec/yaml_func_aws.go @@ -0,0 +1,151 @@ +package exec + +import ( + "context" + + errUtils "github.com/cloudposse/atmos/errors" + log "github.com/cloudposse/atmos/pkg/logger" + "github.com/cloudposse/atmos/pkg/perf" + "github.com/cloudposse/atmos/pkg/schema" + u "github.com/cloudposse/atmos/pkg/utils" +) + +const ( + execAWSYAMLFunction = "Executing Atmos YAML function" + invalidYAMLFunction = "Invalid YAML function" + failedGetIdentity = "Failed to get AWS caller identity" + functionKey = "function" +) + +// processTagAwsValue is a shared helper for AWS YAML functions. +// It validates the input tag, retrieves AWS caller identity, and returns the requested value. +func processTagAwsValue( + atmosConfig *schema.AtmosConfiguration, + input string, + expectedTag string, + stackInfo *schema.ConfigAndStacksInfo, + extractor func(*AWSCallerIdentity) string, +) any { + log.Debug(execAWSYAMLFunction, functionKey, input) + + // Validate the tag matches expected. + if input != expectedTag { + log.Error(invalidYAMLFunction, functionKey, input, "expected", expectedTag) + errUtils.CheckErrorPrintAndExit(errUtils.ErrYamlFuncInvalidArguments, "", "") + return nil + } + + // Get auth context from stack info if available. + var authContext *schema.AWSAuthContext + if stackInfo != nil && stackInfo.AuthContext != nil && stackInfo.AuthContext.AWS != nil { + authContext = stackInfo.AuthContext.AWS + } + + // Get the AWS caller identity (cached). + ctx := context.Background() + identity, err := getAWSCallerIdentityCached(ctx, atmosConfig, authContext) + if err != nil { + log.Error(failedGetIdentity, "error", err) + errUtils.CheckErrorPrintAndExit(err, "", "") + return nil + } + + // Extract the requested value. + return extractor(identity) +} + +// processTagAwsAccountID processes the !aws.account_id YAML function. +// It returns the AWS account ID of the current caller identity. +// The function takes no parameters. +// +// Usage in YAML: +// +// account_id: !aws.account_id +func processTagAwsAccountID( + atmosConfig *schema.AtmosConfiguration, + input string, + stackInfo *schema.ConfigAndStacksInfo, +) any { + defer perf.Track(atmosConfig, "exec.processTagAwsAccountID")() + + result := processTagAwsValue(atmosConfig, input, u.AtmosYamlFuncAwsAccountID, stackInfo, func(id *AWSCallerIdentity) string { + return id.Account + }) + + if result != nil { + log.Debug("Resolved !aws.account_id", "account_id", result) + } + return result +} + +// processTagAwsCallerIdentityArn processes the !aws.caller_identity_arn YAML function. +// It returns the ARN of the current AWS caller identity. +// The function takes no parameters. +// +// Usage in YAML: +// +// caller_arn: !aws.caller_identity_arn +func processTagAwsCallerIdentityArn( + atmosConfig *schema.AtmosConfiguration, + input string, + stackInfo *schema.ConfigAndStacksInfo, +) any { + defer perf.Track(atmosConfig, "exec.processTagAwsCallerIdentityArn")() + + result := processTagAwsValue(atmosConfig, input, u.AtmosYamlFuncAwsCallerIdentityArn, stackInfo, func(id *AWSCallerIdentity) string { + return id.Arn + }) + + if result != nil { + log.Debug("Resolved !aws.caller_identity_arn", "arn", result) + } + return result +} + +// processTagAwsCallerIdentityUserID processes the !aws.caller_identity_user_id YAML function. +// It returns the unique user ID of the current AWS caller identity. +// The function takes no parameters. +// +// Usage in YAML: +// +// user_id: !aws.caller_identity_user_id +func processTagAwsCallerIdentityUserID( + atmosConfig *schema.AtmosConfiguration, + input string, + stackInfo *schema.ConfigAndStacksInfo, +) any { + defer perf.Track(atmosConfig, "exec.processTagAwsCallerIdentityUserID")() + + result := processTagAwsValue(atmosConfig, input, u.AtmosYamlFuncAwsCallerIdentityUserID, stackInfo, func(id *AWSCallerIdentity) string { + return id.UserID + }) + + if result != nil { + log.Debug("Resolved !aws.caller_identity_user_id", "user_id", result) + } + return result +} + +// processTagAwsRegion processes the !aws.region YAML function. +// It returns the AWS region from the current configuration. +// The function takes no parameters. +// +// Usage in YAML: +// +// region: !aws.region +func processTagAwsRegion( + atmosConfig *schema.AtmosConfiguration, + input string, + stackInfo *schema.ConfigAndStacksInfo, +) any { + defer perf.Track(atmosConfig, "exec.processTagAwsRegion")() + + result := processTagAwsValue(atmosConfig, input, u.AtmosYamlFuncAwsRegion, stackInfo, func(id *AWSCallerIdentity) string { + return id.Region + }) + + if result != nil { + log.Debug("Resolved !aws.region", "region", result) + } + return result +} diff --git a/internal/exec/yaml_func_aws_test.go b/internal/exec/yaml_func_aws_test.go new file mode 100644 index 0000000000..67b955c47c --- /dev/null +++ b/internal/exec/yaml_func_aws_test.go @@ -0,0 +1,828 @@ +package exec + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/pkg/schema" + u "github.com/cloudposse/atmos/pkg/utils" +) + +// mockAWSGetter is a mock implementation of AWSGetter for testing. +type mockAWSGetter struct { + identity *AWSCallerIdentity + err error +} + +func (m *mockAWSGetter) GetCallerIdentity( + ctx context.Context, + atmosConfig *schema.AtmosConfiguration, + authContext *schema.AWSAuthContext, +) (*AWSCallerIdentity, error) { + return m.identity, m.err +} + +// runAWSYamlFuncTest is a helper that reduces duplication in AWS YAML function tests. +func runAWSYamlFuncTest( + input string, + mockIdentity *AWSCallerIdentity, + mockErr error, + testFunc func(*schema.AtmosConfiguration, string, *schema.ConfigAndStacksInfo) any, +) any { + // Clear cache before each test. + ClearAWSIdentityCache() + + // Set up mock. + restore := SetAWSGetter(&mockAWSGetter{ + identity: mockIdentity, + err: mockErr, + }) + defer restore() + + atmosConfig := &schema.AtmosConfiguration{} + stackInfo := &schema.ConfigAndStacksInfo{} + + return testFunc(atmosConfig, input, stackInfo) +} + +func TestProcessTagAwsAccountID(t *testing.T) { + tests := []struct { + name string + input string + mockIdentity *AWSCallerIdentity + mockErr error + expectedResult string + shouldReturnNil bool + }{ + { + name: "valid account ID", + input: u.AtmosYamlFuncAwsAccountID, + mockIdentity: &AWSCallerIdentity{ + Account: "123456789012", + Arn: "arn:aws:iam::123456789012:user/testuser", + UserID: "AIDAEXAMPLE", + }, + mockErr: nil, + expectedResult: "123456789012", + }, + { + name: "different account ID", + input: u.AtmosYamlFuncAwsAccountID, + mockIdentity: &AWSCallerIdentity{ + Account: "987654321098", + Arn: "arn:aws:sts::987654321098:assumed-role/TestRole/session", + UserID: "AROAEXAMPLE:session", + }, + mockErr: nil, + expectedResult: "987654321098", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := runAWSYamlFuncTest(tt.input, tt.mockIdentity, tt.mockErr, processTagAwsAccountID) + + if tt.shouldReturnNil { + assert.Nil(t, result) + } else { + assert.Equal(t, tt.expectedResult, result) + } + }) + } +} + +func TestProcessTagAwsCallerIdentityArn(t *testing.T) { + tests := []struct { + name string + input string + mockIdentity *AWSCallerIdentity + mockErr error + expectedResult string + }{ + { + name: "valid IAM user ARN", + input: u.AtmosYamlFuncAwsCallerIdentityArn, + mockIdentity: &AWSCallerIdentity{ + Account: "123456789012", + Arn: "arn:aws:iam::123456789012:user/testuser", + UserID: "AIDAEXAMPLE", + }, + mockErr: nil, + expectedResult: "arn:aws:iam::123456789012:user/testuser", + }, + { + name: "valid assumed role ARN", + input: u.AtmosYamlFuncAwsCallerIdentityArn, + mockIdentity: &AWSCallerIdentity{ + Account: "987654321098", + Arn: "arn:aws:sts::987654321098:assumed-role/AdminRole/session-name", + UserID: "AROAEXAMPLE:session-name", + }, + mockErr: nil, + expectedResult: "arn:aws:sts::987654321098:assumed-role/AdminRole/session-name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := runAWSYamlFuncTest(tt.input, tt.mockIdentity, tt.mockErr, processTagAwsCallerIdentityArn) + assert.Equal(t, tt.expectedResult, result) + }) + } +} + +func TestProcessTagAwsCallerIdentityUserID(t *testing.T) { + tests := []struct { + name string + input string + mockIdentity *AWSCallerIdentity + mockErr error + expectedResult string + }{ + { + name: "valid IAM user ID", + input: u.AtmosYamlFuncAwsCallerIdentityUserID, + mockIdentity: &AWSCallerIdentity{ + Account: "123456789012", + Arn: "arn:aws:iam::123456789012:user/testuser", + UserID: "AIDAEXAMPLE123456789", + }, + mockErr: nil, + expectedResult: "AIDAEXAMPLE123456789", + }, + { + name: "valid assumed role user ID", + input: u.AtmosYamlFuncAwsCallerIdentityUserID, + mockIdentity: &AWSCallerIdentity{ + Account: "987654321098", + Arn: "arn:aws:sts::987654321098:assumed-role/AdminRole/session-name", + UserID: "AROAEXAMPLE:session-name", + }, + mockErr: nil, + expectedResult: "AROAEXAMPLE:session-name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := runAWSYamlFuncTest(tt.input, tt.mockIdentity, tt.mockErr, processTagAwsCallerIdentityUserID) + assert.Equal(t, tt.expectedResult, result) + }) + } +} + +func TestProcessTagAwsRegion(t *testing.T) { + tests := []struct { + name string + input string + mockIdentity *AWSCallerIdentity + mockErr error + expectedResult string + }{ + { + name: "us-east-1 region", + input: u.AtmosYamlFuncAwsRegion, + mockIdentity: &AWSCallerIdentity{ + Account: "123456789012", + Arn: "arn:aws:iam::123456789012:user/testuser", + UserID: "AIDAEXAMPLE", + Region: "us-east-1", + }, + mockErr: nil, + expectedResult: "us-east-1", + }, + { + name: "eu-west-1 region", + input: u.AtmosYamlFuncAwsRegion, + mockIdentity: &AWSCallerIdentity{ + Account: "987654321098", + Arn: "arn:aws:sts::987654321098:assumed-role/AdminRole/session", + UserID: "AROAEXAMPLE:session", + Region: "eu-west-1", + }, + mockErr: nil, + expectedResult: "eu-west-1", + }, + { + name: "ap-northeast-1 region", + input: u.AtmosYamlFuncAwsRegion, + mockIdentity: &AWSCallerIdentity{ + Account: "111111111111", + Arn: "arn:aws:iam::111111111111:root", + UserID: "111111111111", + Region: "ap-northeast-1", + }, + mockErr: nil, + expectedResult: "ap-northeast-1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := runAWSYamlFuncTest(tt.input, tt.mockIdentity, tt.mockErr, processTagAwsRegion) + assert.Equal(t, tt.expectedResult, result) + }) + } +} + +func TestAWSIdentityCache(t *testing.T) { + // Clear cache before test. + ClearAWSIdentityCache() + + callCount := 0 + mockGetter := &mockAWSGetter{ + identity: &AWSCallerIdentity{ + Account: "111111111111", + Arn: "arn:aws:iam::111111111111:user/cachetest", + UserID: "AIDACACHETEST", + }, + err: nil, + } + + // Wrap to count calls. + countingGetter := &countingAWSGetter{ + wrapped: mockGetter, + callCount: &callCount, + } + + restore := SetAWSGetter(countingGetter) + defer restore() + + atmosConfig := &schema.AtmosConfiguration{} + ctx := context.Background() + + // First call should hit the mock. + identity1, err := getAWSCallerIdentityCached(ctx, atmosConfig, nil) + require.NoError(t, err) + assert.Equal(t, "111111111111", identity1.Account) + assert.Equal(t, 1, callCount, "First call should invoke the getter") + + // Second call with same auth context should use cache. + identity2, err := getAWSCallerIdentityCached(ctx, atmosConfig, nil) + require.NoError(t, err) + assert.Equal(t, "111111111111", identity2.Account) + assert.Equal(t, 1, callCount, "Second call should use cache, not invoke getter") + + // Call with different auth context should hit mock again. + differentAuth := &schema.AWSAuthContext{ + Profile: "different-profile", + CredentialsFile: "/different/path", + } + identity3, err := getAWSCallerIdentityCached(ctx, atmosConfig, differentAuth) + require.NoError(t, err) + assert.Equal(t, "111111111111", identity3.Account) + assert.Equal(t, 2, callCount, "Different auth context should invoke getter") + + // Clear cache and verify next call hits mock. + ClearAWSIdentityCache() + identity4, err := getAWSCallerIdentityCached(ctx, atmosConfig, nil) + require.NoError(t, err) + assert.Equal(t, "111111111111", identity4.Account) + assert.Equal(t, 3, callCount, "After cache clear, should invoke getter") +} + +// countingAWSGetter wraps another getter and counts calls. +type countingAWSGetter struct { + wrapped AWSGetter + callCount *int +} + +func (c *countingAWSGetter) GetCallerIdentity( + ctx context.Context, + atmosConfig *schema.AtmosConfiguration, + authContext *schema.AWSAuthContext, +) (*AWSCallerIdentity, error) { + *c.callCount++ + return c.wrapped.GetCallerIdentity(ctx, atmosConfig, authContext) +} + +func TestAWSCacheWithErrors(t *testing.T) { + // Clear cache before test. + ClearAWSIdentityCache() + + callCount := 0 + expectedErr := errors.New("mock AWS error") + mockGetter := &mockAWSGetter{ + identity: nil, + err: expectedErr, + } + + countingGetter := &countingAWSGetter{ + wrapped: mockGetter, + callCount: &callCount, + } + + restore := SetAWSGetter(countingGetter) + defer restore() + + atmosConfig := &schema.AtmosConfiguration{} + ctx := context.Background() + + // First call should return error and cache it. + _, err := getAWSCallerIdentityCached(ctx, atmosConfig, nil) + require.Error(t, err) + assert.Equal(t, 1, callCount) + + // Second call should return cached error. + _, err = getAWSCallerIdentityCached(ctx, atmosConfig, nil) + require.Error(t, err) + assert.Equal(t, 1, callCount, "Errors should be cached too") +} + +func TestGetCacheKey(t *testing.T) { + tests := []struct { + name string + authContext *schema.AWSAuthContext + expected string + }{ + { + name: "nil auth context", + authContext: nil, + expected: "default", + }, + { + name: "with profile credentials and config file", + authContext: &schema.AWSAuthContext{ + Profile: "my-profile", + CredentialsFile: "/home/user/.aws/credentials", + ConfigFile: "/home/user/.aws/config", + }, + expected: "my-profile:/home/user/.aws/credentials:/home/user/.aws/config", + }, + { + name: "empty profile", + authContext: &schema.AWSAuthContext{ + Profile: "", + CredentialsFile: "/some/path", + ConfigFile: "/some/config", + }, + expected: ":/some/path:/some/config", + }, + { + name: "empty config file", + authContext: &schema.AWSAuthContext{ + Profile: "prod", + CredentialsFile: "/creds", + ConfigFile: "", + }, + expected: "prod:/creds:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getCacheKey(tt.authContext) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestAWSGetterInterface(t *testing.T) { + // Ensure defaultAWSGetter implements AWSGetter. + var _ AWSGetter = &defaultAWSGetter{} +} + +func TestProcessTagAwsWithAuthContext(t *testing.T) { + // Clear cache before test. + ClearAWSIdentityCache() + + // Set up mock with specific identity. + restore := SetAWSGetter(&mockAWSGetter{ + identity: &AWSCallerIdentity{ + Account: "222222222222", + Arn: "arn:aws:sts::222222222222:assumed-role/MyRole/session", + UserID: "AROAEXAMPLE:session", + }, + err: nil, + }) + defer restore() + + atmosConfig := &schema.AtmosConfiguration{} + + // Test with auth context in stackInfo. + stackInfo := &schema.ConfigAndStacksInfo{ + AuthContext: &schema.AuthContext{ + AWS: &schema.AWSAuthContext{ + Profile: "test-profile", + CredentialsFile: "/test/credentials", + }, + }, + } + + result := processTagAwsAccountID(atmosConfig, u.AtmosYamlFuncAwsAccountID, stackInfo) + assert.Equal(t, "222222222222", result) + + // Clear cache for next test. + ClearAWSIdentityCache() + + result = processTagAwsCallerIdentityArn(atmosConfig, u.AtmosYamlFuncAwsCallerIdentityArn, stackInfo) + assert.Equal(t, "arn:aws:sts::222222222222:assumed-role/MyRole/session", result) +} + +func TestProcessSimpleTagsWithAWSFunctions(t *testing.T) { + // Clear cache before test. + ClearAWSIdentityCache() + + // Set up mock. + restore := SetAWSGetter(&mockAWSGetter{ + identity: &AWSCallerIdentity{ + Account: "333333333333", + Arn: "arn:aws:iam::333333333333:user/integration-test", + UserID: "AIDAINTEGRATION", + Region: "us-west-2", + }, + err: nil, + }) + defer restore() + + atmosConfig := &schema.AtmosConfiguration{} + stackInfo := &schema.ConfigAndStacksInfo{} + + // Test !aws.account_id through processSimpleTags. + result, handled := processSimpleTags(atmosConfig, u.AtmosYamlFuncAwsAccountID, "", nil, stackInfo) + assert.True(t, handled) + assert.Equal(t, "333333333333", result) + + // Clear cache for next test. + ClearAWSIdentityCache() + + // Test !aws.caller_identity_arn through processSimpleTags. + result, handled = processSimpleTags(atmosConfig, u.AtmosYamlFuncAwsCallerIdentityArn, "", nil, stackInfo) + assert.True(t, handled) + assert.Equal(t, "arn:aws:iam::333333333333:user/integration-test", result) + + // Clear cache for next test. + ClearAWSIdentityCache() + + // Test !aws.caller_identity_user_id through processSimpleTags. + result, handled = processSimpleTags(atmosConfig, u.AtmosYamlFuncAwsCallerIdentityUserID, "", nil, stackInfo) + assert.True(t, handled) + assert.Equal(t, "AIDAINTEGRATION", result) + + // Clear cache for next test. + ClearAWSIdentityCache() + + // Test !aws.region through processSimpleTags. + result, handled = processSimpleTags(atmosConfig, u.AtmosYamlFuncAwsRegion, "", nil, stackInfo) + assert.True(t, handled) + assert.Equal(t, "us-west-2", result) +} + +func TestProcessSimpleTagsSkipsAWSFunctions(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + stackInfo := &schema.ConfigAndStacksInfo{} + + // Test that skipping works for aws.account_id. + skip := []string{"aws.account_id"} + result, handled := processSimpleTags(atmosConfig, u.AtmosYamlFuncAwsAccountID, "", skip, stackInfo) + assert.False(t, handled) + assert.Nil(t, result) + + // Test that skipping works for aws.caller_identity_arn. + skip = []string{"aws.caller_identity_arn"} + result, handled = processSimpleTags(atmosConfig, u.AtmosYamlFuncAwsCallerIdentityArn, "", skip, stackInfo) + assert.False(t, handled) + assert.Nil(t, result) + + // Test that skipping works for aws.caller_identity_user_id. + skip = []string{"aws.caller_identity_user_id"} + result, handled = processSimpleTags(atmosConfig, u.AtmosYamlFuncAwsCallerIdentityUserID, "", skip, stackInfo) + assert.False(t, handled) + assert.Nil(t, result) + + // Test that skipping works for aws.region. + skip = []string{"aws.region"} + result, handled = processSimpleTags(atmosConfig, u.AtmosYamlFuncAwsRegion, "", skip, stackInfo) + assert.False(t, handled) + assert.Nil(t, result) +} + +// TestAWSYamlFunctionConstants verifies the constants are defined correctly. +func TestAWSYamlFunctionConstants(t *testing.T) { + assert.Equal(t, "!aws.account_id", u.AtmosYamlFuncAwsAccountID) + assert.Equal(t, "!aws.caller_identity_arn", u.AtmosYamlFuncAwsCallerIdentityArn) + assert.Equal(t, "!aws.caller_identity_user_id", u.AtmosYamlFuncAwsCallerIdentityUserID) + assert.Equal(t, "!aws.region", u.AtmosYamlFuncAwsRegion) +} + +// TestErrorWrapping verifies that AWS errors are properly wrapped. +func TestErrorWrapping(t *testing.T) { + // Clear cache before test. + ClearAWSIdentityCache() + + underlyingErr := errors.New("network timeout") + restore := SetAWSGetter(&mockAWSGetter{ + identity: nil, + err: underlyingErr, + }) + defer restore() + + atmosConfig := &schema.AtmosConfiguration{} + ctx := context.Background() + + _, err := getAWSCallerIdentityCached(ctx, atmosConfig, nil) + require.Error(t, err) + + // The error should be wrapped with the underlying error accessible. + assert.ErrorIs(t, err, underlyingErr) +} + +// TestDefaultAWSGetterExists verifies the default getter exists. +func TestDefaultAWSGetterExists(t *testing.T) { + // The awsGetter variable should be initialized. + assert.NotNil(t, awsGetter) + + // It should be a *defaultAWSGetter. + _, ok := awsGetter.(*defaultAWSGetter) + assert.True(t, ok, "Default awsGetter should be *defaultAWSGetter") +} + +// TestSetAWSGetterRestore verifies the restore function works. +func TestSetAWSGetterRestore(t *testing.T) { + originalGetter := awsGetter + + mockGetter := &mockAWSGetter{ + identity: &AWSCallerIdentity{Account: "444444444444"}, + } + + restore := SetAWSGetter(mockGetter) + + // Verify getter was replaced. + assert.Equal(t, mockGetter, awsGetter) + + // Restore original. + restore() + + // Verify original was restored. + assert.Equal(t, originalGetter, awsGetter) +} + +// TestErrAwsGetCallerIdentity verifies the error constant exists. +func TestErrAwsGetCallerIdentity(t *testing.T) { + assert.NotNil(t, errUtils.ErrAwsGetCallerIdentity) +} + +// TestProcessTagAwsWithNilStackInfo verifies functions work with nil stackInfo. +func TestProcessTagAwsWithNilStackInfo(t *testing.T) { + ClearAWSIdentityCache() + + restore := SetAWSGetter(&mockAWSGetter{ + identity: &AWSCallerIdentity{ + Account: "555555555555", + Arn: "arn:aws:iam::555555555555:user/nil-test", + UserID: "AIDANILTEST", + Region: "us-west-1", + }, + err: nil, + }) + defer restore() + + atmosConfig := &schema.AtmosConfiguration{} + + // Test with nil stackInfo - should still work using default auth context. + result := processTagAwsAccountID(atmosConfig, u.AtmosYamlFuncAwsAccountID, nil) + assert.Equal(t, "555555555555", result) + + ClearAWSIdentityCache() + + result = processTagAwsRegion(atmosConfig, u.AtmosYamlFuncAwsRegion, nil) + assert.Equal(t, "us-west-1", result) +} + +// TestProcessTagAwsWithPartialAuthContext verifies functions work with partial auth context. +func TestProcessTagAwsWithPartialAuthContext(t *testing.T) { + ClearAWSIdentityCache() + + restore := SetAWSGetter(&mockAWSGetter{ + identity: &AWSCallerIdentity{ + Account: "666666666666", + Arn: "arn:aws:iam::666666666666:user/partial-test", + UserID: "AIDAPARTIAL", + Region: "eu-central-1", + }, + err: nil, + }) + defer restore() + + atmosConfig := &schema.AtmosConfiguration{} + + // Test with stackInfo that has AuthContext but nil AWS. + stackInfo := &schema.ConfigAndStacksInfo{ + AuthContext: &schema.AuthContext{ + AWS: nil, // AWS is nil but AuthContext exists. + }, + } + + result := processTagAwsAccountID(atmosConfig, u.AtmosYamlFuncAwsAccountID, stackInfo) + assert.Equal(t, "666666666666", result) + + ClearAWSIdentityCache() + + // Test with stackInfo that has nil AuthContext. + stackInfo2 := &schema.ConfigAndStacksInfo{ + AuthContext: nil, + } + + result = processTagAwsCallerIdentityArn(atmosConfig, u.AtmosYamlFuncAwsCallerIdentityArn, stackInfo2) + assert.Equal(t, "arn:aws:iam::666666666666:user/partial-test", result) +} + +// TestProcessTagAwsWithEmptyIdentityFields verifies handling of empty identity fields. +func TestProcessTagAwsWithEmptyIdentityFields(t *testing.T) { + ClearAWSIdentityCache() + + restore := SetAWSGetter(&mockAWSGetter{ + identity: &AWSCallerIdentity{ + Account: "", + Arn: "", + UserID: "", + Region: "", + }, + err: nil, + }) + defer restore() + + atmosConfig := &schema.AtmosConfiguration{} + stackInfo := &schema.ConfigAndStacksInfo{} + + // Empty values should still be returned (not nil). + result := processTagAwsAccountID(atmosConfig, u.AtmosYamlFuncAwsAccountID, stackInfo) + assert.Equal(t, "", result) + + ClearAWSIdentityCache() + + result = processTagAwsRegion(atmosConfig, u.AtmosYamlFuncAwsRegion, stackInfo) + assert.Equal(t, "", result) +} + +// TestCacheConcurrency verifies cache is thread-safe under concurrent access. +func TestCacheConcurrency(t *testing.T) { + ClearAWSIdentityCache() + + callCount := 0 + restore := SetAWSGetter(&countingAWSGetter{ + wrapped: &mockAWSGetter{ + identity: &AWSCallerIdentity{ + Account: "777777777777", + Arn: "arn:aws:iam::777777777777:user/concurrent", + UserID: "AIDACONCURRENT", + Region: "ap-southeast-1", + }, + err: nil, + }, + callCount: &callCount, + }) + defer restore() + + atmosConfig := &schema.AtmosConfiguration{} + ctx := context.Background() + + // Run multiple goroutines concurrently accessing the cache. + const numGoroutines = 50 + done := make(chan bool, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func() { + identity, err := getAWSCallerIdentityCached(ctx, atmosConfig, nil) + assert.NoError(t, err) + assert.Equal(t, "777777777777", identity.Account) + done <- true + }() + } + + // Wait for all goroutines to complete. + for i := 0; i < numGoroutines; i++ { + <-done + } + + // Despite concurrent access, should only call getter once due to caching. + assert.Equal(t, 1, callCount, "Concurrent access should result in only one getter call") +} + +// TestCacheKeyWithRegion verifies cache key includes all relevant auth context fields. +func TestCacheKeyWithRegion(t *testing.T) { + tests := []struct { + name string + authContext *schema.AWSAuthContext + expected string + }{ + { + name: "full auth context with region", + authContext: &schema.AWSAuthContext{ + Profile: "prod", + CredentialsFile: "/prod/creds", + ConfigFile: "/prod/config", + Region: "us-east-1", // Region is in auth context but not in cache key. + }, + expected: "prod:/prod/creds:/prod/config", + }, + { + name: "same profile different region should have same cache key", + authContext: &schema.AWSAuthContext{ + Profile: "prod", + CredentialsFile: "/prod/creds", + ConfigFile: "/prod/config", + Region: "eu-west-1", // Different region, same cache key. + }, + expected: "prod:/prod/creds:/prod/config", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getCacheKey(tt.authContext) + assert.Equal(t, tt.expected, result) + }) + } +} + +// TestAllAWSFunctionsShareCache verifies all four functions share the same cache. +func TestAllAWSFunctionsShareCache(t *testing.T) { + ClearAWSIdentityCache() + + callCount := 0 + restore := SetAWSGetter(&countingAWSGetter{ + wrapped: &mockAWSGetter{ + identity: &AWSCallerIdentity{ + Account: "888888888888", + Arn: "arn:aws:iam::888888888888:user/shared-cache", + UserID: "AIDASHARED", + Region: "sa-east-1", + }, + err: nil, + }, + callCount: &callCount, + }) + defer restore() + + atmosConfig := &schema.AtmosConfiguration{} + stackInfo := &schema.ConfigAndStacksInfo{} + + // Call all four functions. + result1 := processTagAwsAccountID(atmosConfig, u.AtmosYamlFuncAwsAccountID, stackInfo) + result2 := processTagAwsCallerIdentityArn(atmosConfig, u.AtmosYamlFuncAwsCallerIdentityArn, stackInfo) + result3 := processTagAwsCallerIdentityUserID(atmosConfig, u.AtmosYamlFuncAwsCallerIdentityUserID, stackInfo) + result4 := processTagAwsRegion(atmosConfig, u.AtmosYamlFuncAwsRegion, stackInfo) + + // Verify all results are correct. + assert.Equal(t, "888888888888", result1) + assert.Equal(t, "arn:aws:iam::888888888888:user/shared-cache", result2) + assert.Equal(t, "AIDASHARED", result3) + assert.Equal(t, "sa-east-1", result4) + + // All functions should share the same cached result - only one getter call. + assert.Equal(t, 1, callCount, "All AWS functions should share the same cache") +} + +// TestCacheWithDifferentConfigFiles verifies different config files get different cache entries. +func TestCacheWithDifferentConfigFiles(t *testing.T) { + ClearAWSIdentityCache() + + callCount := 0 + restore := SetAWSGetter(&countingAWSGetter{ + wrapped: &mockAWSGetter{ + identity: &AWSCallerIdentity{ + Account: "999999999999", + Arn: "arn:aws:iam::999999999999:user/config-test", + UserID: "AIDACONFIG", + Region: "me-south-1", + }, + err: nil, + }, + callCount: &callCount, + }) + defer restore() + + atmosConfig := &schema.AtmosConfiguration{} + ctx := context.Background() + + // First call with config file A. + auth1 := &schema.AWSAuthContext{ + Profile: "test", + CredentialsFile: "/creds", + ConfigFile: "/config-a", + } + _, err := getAWSCallerIdentityCached(ctx, atmosConfig, auth1) + require.NoError(t, err) + assert.Equal(t, 1, callCount) + + // Second call with same config file A - should use cache. + _, err = getAWSCallerIdentityCached(ctx, atmosConfig, auth1) + require.NoError(t, err) + assert.Equal(t, 1, callCount, "Same config file should use cache") + + // Third call with config file B - should call getter again. + auth2 := &schema.AWSAuthContext{ + Profile: "test", + CredentialsFile: "/creds", + ConfigFile: "/config-b", // Different config file. + } + _, err = getAWSCallerIdentityCached(ctx, atmosConfig, auth2) + require.NoError(t, err) + assert.Equal(t, 2, callCount, "Different config file should result in new getter call") +} diff --git a/internal/exec/yaml_func_utils.go b/internal/exec/yaml_func_utils.go index f238be35a9..d8d9ac877e 100644 --- a/internal/exec/yaml_func_utils.go +++ b/internal/exec/yaml_func_utils.go @@ -154,6 +154,19 @@ func processSimpleTags( errUtils.CheckErrorPrintAndExit(err, "", "") return res, true } + // AWS YAML functions - note these check for exact match since they take no arguments. + if input == u.AtmosYamlFuncAwsAccountID && !skipFunc(skip, u.AtmosYamlFuncAwsAccountID) { + return processTagAwsAccountID(atmosConfig, input, stackInfo), true + } + if input == u.AtmosYamlFuncAwsCallerIdentityArn && !skipFunc(skip, u.AtmosYamlFuncAwsCallerIdentityArn) { + return processTagAwsCallerIdentityArn(atmosConfig, input, stackInfo), true + } + if input == u.AtmosYamlFuncAwsCallerIdentityUserID && !skipFunc(skip, u.AtmosYamlFuncAwsCallerIdentityUserID) { + return processTagAwsCallerIdentityUserID(atmosConfig, input, stackInfo), true + } + if input == u.AtmosYamlFuncAwsRegion && !skipFunc(skip, u.AtmosYamlFuncAwsRegion) { + return processTagAwsRegion(atmosConfig, input, stackInfo), true + } return nil, false } diff --git a/pkg/utils/yaml_utils.go b/pkg/utils/yaml_utils.go index 445fe0f56c..33173defd1 100644 --- a/pkg/utils/yaml_utils.go +++ b/pkg/utils/yaml_utils.go @@ -19,17 +19,21 @@ import ( const ( // Atmos YAML functions. - AtmosYamlFuncExec = "!exec" - AtmosYamlFuncStore = "!store" - AtmosYamlFuncStoreGet = "!store.get" - AtmosYamlFuncTemplate = "!template" - AtmosYamlFuncTerraformOutput = "!terraform.output" - AtmosYamlFuncTerraformState = "!terraform.state" - AtmosYamlFuncEnv = "!env" - AtmosYamlFuncInclude = "!include" - AtmosYamlFuncIncludeRaw = "!include.raw" - AtmosYamlFuncGitRoot = "!repo-root" - AtmosYamlFuncRandom = "!random" + AtmosYamlFuncExec = "!exec" + AtmosYamlFuncStore = "!store" + AtmosYamlFuncStoreGet = "!store.get" + AtmosYamlFuncTemplate = "!template" + AtmosYamlFuncTerraformOutput = "!terraform.output" + AtmosYamlFuncTerraformState = "!terraform.state" + AtmosYamlFuncEnv = "!env" + AtmosYamlFuncInclude = "!include" + AtmosYamlFuncIncludeRaw = "!include.raw" + AtmosYamlFuncGitRoot = "!repo-root" + AtmosYamlFuncRandom = "!random" + AtmosYamlFuncAwsAccountID = "!aws.account_id" + AtmosYamlFuncAwsCallerIdentityArn = "!aws.caller_identity_arn" + AtmosYamlFuncAwsCallerIdentityUserID = "!aws.caller_identity_user_id" + AtmosYamlFuncAwsRegion = "!aws.region" DefaultYAMLIndent = 2 @@ -48,20 +52,28 @@ var ( AtmosYamlFuncTerraformState, AtmosYamlFuncEnv, AtmosYamlFuncRandom, + AtmosYamlFuncAwsAccountID, + AtmosYamlFuncAwsCallerIdentityArn, + AtmosYamlFuncAwsCallerIdentityUserID, + AtmosYamlFuncAwsRegion, } // AtmosYamlTagsMap provides O(1) lookup for custom tag checking. // This optimization replaces the O(n) SliceContainsString calls that were previously // called 75M+ times, causing significant performance overhead. atmosYamlTagsMap = map[string]bool{ - AtmosYamlFuncExec: true, - AtmosYamlFuncStore: true, - AtmosYamlFuncStoreGet: true, - AtmosYamlFuncTemplate: true, - AtmosYamlFuncTerraformOutput: true, - AtmosYamlFuncTerraformState: true, - AtmosYamlFuncEnv: true, - AtmosYamlFuncRandom: true, + AtmosYamlFuncExec: true, + AtmosYamlFuncStore: true, + AtmosYamlFuncStoreGet: true, + AtmosYamlFuncTemplate: true, + AtmosYamlFuncTerraformOutput: true, + AtmosYamlFuncTerraformState: true, + AtmosYamlFuncEnv: true, + AtmosYamlFuncRandom: true, + AtmosYamlFuncAwsAccountID: true, + AtmosYamlFuncAwsCallerIdentityArn: true, + AtmosYamlFuncAwsCallerIdentityUserID: true, + AtmosYamlFuncAwsRegion: true, } // ParsedYAMLCache stores parsed yaml.Node objects and their position information diff --git a/pkg/utils/yaml_utils_test.go b/pkg/utils/yaml_utils_test.go index 27e2048daf..e5fc5631ec 100644 --- a/pkg/utils/yaml_utils_test.go +++ b/pkg/utils/yaml_utils_test.go @@ -763,6 +763,10 @@ func TestAtmosYamlTagsMap_ContainsAllTags(t *testing.T) { AtmosYamlFuncTerraformState, AtmosYamlFuncEnv, AtmosYamlFuncRandom, + AtmosYamlFuncAwsAccountID, + AtmosYamlFuncAwsCallerIdentityArn, + AtmosYamlFuncAwsCallerIdentityUserID, + AtmosYamlFuncAwsRegion, } for _, tag := range expectedTags { diff --git a/website/blog/2025-12-05-aws-yaml-functions.mdx b/website/blog/2025-12-05-aws-yaml-functions.mdx new file mode 100644 index 0000000000..f7f5cfd67e --- /dev/null +++ b/website/blog/2025-12-05-aws-yaml-functions.mdx @@ -0,0 +1,76 @@ +--- +slug: aws-yaml-functions +title: "AWS YAML Functions for Identity and Region" +authors: [osterman] +tags: [feature, terraform, cloud-architecture] +date: 2025-12-05 +--- + +Atmos now includes four AWS YAML functions that retrieve identity and region information directly in stack configurations: `!aws.account_id`, `!aws.caller_identity_arn`, `!aws.caller_identity_user_id`, and `!aws.region`. + + + +## What's New + +These functions use the AWS STS GetCallerIdentity API to retrieve information about the current AWS credentials: + +| Function | Returns | Example Output | +|----------|---------|----------------| +| `!aws.account_id` | AWS account ID | `123456789012` | +| `!aws.caller_identity_arn` | Full ARN of caller | `arn:aws:iam::123456789012:user/deploy` | +| `!aws.caller_identity_user_id` | Unique user identifier | `AIDAEXAMPLE123456789` | +| `!aws.region` | Current AWS region | `us-east-1` | + +## Usage + +```yaml +components: + terraform: + s3-bucket: + vars: + # Pass account ID and region for bucket naming in Terraform + aws_account_id: !aws.account_id + aws_region: !aws.region + + iam-policy: + vars: + # Reference caller identity in policies + deployer_arn: !aws.caller_identity_arn +``` + +## Use Cases + +**Dynamic Resource Naming**: Include account IDs in S3 bucket names, DynamoDB tables, or other resources that require globally unique names. + +**Audit and Logging**: Capture the ARN or user ID of the identity running deployments for audit trails. + +**Cross-Account References**: Build ARNs dynamically when referencing resources across accounts. + +**Region-Aware Configuration**: Configure resources based on the current AWS region without hardcoding values. + +## Caching and Performance + +All four functions share a single cached STS API call per CLI invocation. The first function call fetches the identity; subsequent calls use the cached result. This means using multiple AWS functions in the same configuration adds no extra API overhead. + +## Authentication Integration + +When using [Atmos Authentication](/cli/commands/auth/usage), these functions automatically use the credentials from the configured auth context. This works with AWS SSO, IAM roles, and other credential sources supported by the AWS SDK. + +## Comparison with Terragrunt + +These functions provide equivalent functionality to Terragrunt's built-in helpers: + +| Atmos | Terragrunt | +|-------|------------| +| `!aws.account_id` | `get_aws_account_id()` | +| `!aws.caller_identity_arn` | `get_aws_caller_identity_arn()` | +| `!aws.caller_identity_user_id` | `get_aws_caller_identity_user_id()` | +| `!aws.region` | Similar to region from `get_aws_caller_identity()` | + +## Learn More + +- [`!aws.account_id`](/functions/yaml/aws.account-id) - Full documentation +- [`!aws.caller_identity_arn`](/functions/yaml/aws.caller-identity-arn) - Full documentation +- [`!aws.caller_identity_user_id`](/functions/yaml/aws.caller-identity-user-id) - Full documentation +- [`!aws.region`](/functions/yaml/aws.region) - Full documentation +- [YAML Functions Overview](/functions/yaml/) - All available YAML functions diff --git a/website/docs/functions/yaml/aws.account-id.mdx b/website/docs/functions/yaml/aws.account-id.mdx new file mode 100644 index 0000000000..9314ab370c --- /dev/null +++ b/website/docs/functions/yaml/aws.account-id.mdx @@ -0,0 +1,166 @@ +--- +title: "!aws.account_id" +sidebar_position: 11 +sidebar_label: "!aws.account_id" +sidebar_class_name: command +description: Retrieve the AWS account ID of the current caller identity +--- + +import File from '@site/src/components/File' +import Intro from '@site/src/components/Intro' + + +The `!aws.account_id` YAML function retrieves the AWS account ID of the current caller identity +by calling the AWS STS `GetCallerIdentity` API. + + +## Usage + +The `!aws.account_id` function takes no parameters: + +```yaml + # Get the AWS account ID of the current caller identity + account_id: !aws.account_id +``` + +## Arguments + +This function takes no arguments. It uses the AWS credentials from the environment or the +Atmos authentication context if configured. + +## How It Works + +When processing the `!aws.account_id` YAML function, Atmos: + +1. **Loads AWS Configuration** - Uses the standard AWS SDK credential resolution chain: + - Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) + - Shared credentials file (`~/.aws/credentials`) + - Shared config file (`~/.aws/config`) + - EC2 Instance Metadata Service (IMDS) + - ECS Task credentials + - Web Identity Token credentials + +2. **Calls STS GetCallerIdentity** - Makes an API call to retrieve the caller identity + +3. **Returns Account ID** - Extracts and returns the 12-digit AWS account ID as a string + +:::note Atmos Auth Integration +When using [Atmos Authentication](/cli/commands/auth/usage), the function automatically uses credentials +from the active identity. This enables seamless integration with SSO, assume role chains, and other +authentication methods configured in your `atmos.yaml`. +::: + +## Caching + +The `!aws.account_id` function caches its results in memory for the duration of the CLI invocation. +This means: + +- Multiple uses of `!aws.account_id` in the same command only make one STS API call +- Different authentication contexts (e.g., different profiles) get separate cache entries +- Each new CLI command starts with a fresh cache + +This caching significantly improves performance when the function is used in multiple places +across your stack manifests. + +:::note Type-Aware Merging +Atmos supports type-aware merging of YAML functions and concrete values, allowing them to coexist in the inheritance chain without type conflicts. +See the full explanation: [YAML Function Merging](/reference/yaml-function-merging) +::: + +## Examples + +### Basic Usage + + +```yaml +components: + terraform: + my-component: + vars: + # Inject the AWS account ID into Terraform variables + aws_account_id: !aws.account_id +``` + + +### Use in Backend Configuration + + +```yaml +terraform: + backend: + s3: + # Pass account ID to Terraform for constructing bucket names + account_id: !aws.account_id +``` + + +### Conditional Logic with Account ID + + +```yaml +components: + terraform: + security-baseline: + vars: + # Pass account ID for resource naming + account_id: !aws.account_id + + # Use in tags + tags: + AccountId: !aws.account_id + ManagedBy: "atmos" +``` + + +### Multiple Components Using Account ID + + +```yaml +components: + terraform: + # Account ID is fetched once and cached + vpc: + vars: + account_id: !aws.account_id + + eks: + vars: + account_id: !aws.account_id # Uses cached value + + rds: + vars: + account_id: !aws.account_id # Uses cached value +``` + + +## Comparison with Terragrunt + +This function is equivalent to Terragrunt's `get_aws_account_id()` function: + +| Terragrunt | Atmos | +|------------|-------| +| `get_aws_account_id()` | `!aws.account_id` | + +## Error Handling + +If the function fails to retrieve the AWS caller identity (e.g., no credentials available, +network issues, or insufficient permissions), Atmos will log an error and exit. + +Common error scenarios: +- No AWS credentials configured +- Expired credentials +- Network connectivity issues +- Missing STS permissions + +## Considerations + +- **Requires valid AWS credentials** - The function will fail if no valid credentials are available +- **Network dependency** - Requires connectivity to AWS STS endpoint +- **Performance** - Results are cached per CLI invocation, so there's minimal overhead when used multiple times +- **IAM permissions** - Requires `sts:GetCallerIdentity` permission (usually available to all authenticated principals) + +## Related Functions + +- [!aws.caller_identity_arn](/functions/yaml/aws.caller-identity-arn) - Get the full ARN of the caller identity +- [!aws.caller_identity_user_id](/functions/yaml/aws.caller-identity-user-id) - Get the unique user ID +- [!aws.region](/functions/yaml/aws.region) - Get the AWS region diff --git a/website/docs/functions/yaml/aws.caller-identity-arn.mdx b/website/docs/functions/yaml/aws.caller-identity-arn.mdx new file mode 100644 index 0000000000..882abeba96 --- /dev/null +++ b/website/docs/functions/yaml/aws.caller-identity-arn.mdx @@ -0,0 +1,187 @@ +--- +title: "!aws.caller_identity_arn" +sidebar_position: 12 +sidebar_label: "!aws.caller_identity_arn" +sidebar_class_name: command +description: Retrieve the ARN of the current AWS caller identity +--- + +import File from '@site/src/components/File' +import Intro from '@site/src/components/Intro' + + +The `!aws.caller_identity_arn` YAML function retrieves the Amazon Resource Name (ARN) of the current +caller identity by calling the AWS STS `GetCallerIdentity` API. + + +## Usage + +The `!aws.caller_identity_arn` function takes no parameters: + +```yaml + # Get the ARN of the current AWS caller identity + caller_arn: !aws.caller_identity_arn +``` + +## Arguments + +This function takes no arguments. It uses the AWS credentials from the environment or the +Atmos authentication context if configured. + +## How It Works + +When processing the `!aws.caller_identity_arn` YAML function, Atmos: + +1. **Loads AWS Configuration** - Uses the standard AWS SDK credential resolution chain: + - Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) + - Shared credentials file (`~/.aws/credentials`) + - Shared config file (`~/.aws/config`) + - EC2 Instance Metadata Service (IMDS) + - ECS Task credentials + - Web Identity Token credentials + +2. **Calls STS GetCallerIdentity** - Makes an API call to retrieve the caller identity + +3. **Returns ARN** - Extracts and returns the full ARN of the calling identity + +The returned ARN format depends on the type of identity: + +| Identity Type | ARN Format | +|--------------|------------| +| IAM User | `arn:aws:iam::123456789012:user/username` | +| IAM Role (assumed) | `arn:aws:sts::123456789012:assumed-role/RoleName/session-name` | +| Root Account | `arn:aws:iam::123456789012:root` | +| Federated User | `arn:aws:sts::123456789012:federated-user/username` | + +:::note Atmos Auth Integration +When using [Atmos Authentication](/cli/commands/auth/usage), the function automatically uses credentials +from the active identity. This enables seamless integration with SSO, assume role chains, and other +authentication methods configured in your `atmos.yaml`. +::: + +## Caching + +The `!aws.caller_identity_arn` function caches its results in memory for the duration of the CLI invocation. +This means: + +- Multiple uses of `!aws.caller_identity_arn` in the same command only make one STS API call +- Different authentication contexts (e.g., different profiles) get separate cache entries +- Each new CLI command starts with a fresh cache + +The cache is shared with `!aws.account_id`, so using both functions only makes one STS API call. + +:::note Type-Aware Merging +Atmos supports type-aware merging of YAML functions and concrete values, allowing them to coexist in the inheritance chain without type conflicts. +See the full explanation: [YAML Function Merging](/reference/yaml-function-merging) +::: + +## Examples + +### Basic Usage + + +```yaml +components: + terraform: + my-component: + vars: + # Inject the caller ARN into Terraform variables + caller_arn: !aws.caller_identity_arn +``` + + +### Audit and Tagging + + +```yaml +components: + terraform: + infrastructure: + vars: + tags: + # Track who provisioned the resources + ProvisionedBy: !aws.caller_identity_arn + ManagedBy: "atmos" +``` + + +### IAM Policy Configuration + + +```yaml +components: + terraform: + s3-bucket: + vars: + # Allow the current identity to access the bucket + allowed_principals: + - !aws.caller_identity_arn +``` + + +### Combined with Account ID + + +```yaml +components: + terraform: + security-config: + vars: + # Both functions use the same cached STS call + aws_account_id: !aws.account_id + caller_arn: !aws.caller_identity_arn + + # Useful for logging and auditing + deployment_context: + account: !aws.account_id + identity: !aws.caller_identity_arn +``` + + +### Debugging and Troubleshooting + + +```yaml +components: + terraform: + debug-component: + vars: + # Verify which identity Atmos is using + debug_info: + current_identity_arn: !aws.caller_identity_arn + current_account_id: !aws.account_id +``` + + +## Comparison with Terragrunt + +This function is equivalent to Terragrunt's `get_aws_caller_identity_arn()` function: + +| Terragrunt | Atmos | +|------------|-------| +| `get_aws_caller_identity_arn()` | `!aws.caller_identity_arn` | + +## Error Handling + +If the function fails to retrieve the AWS caller identity (e.g., no credentials available, +network issues, or insufficient permissions), Atmos will log an error and exit. + +Common error scenarios: +- No AWS credentials configured +- Expired credentials +- Network connectivity issues +- Missing STS permissions + +## Considerations + +- **Requires valid AWS credentials** - The function will fail if no valid credentials are available +- **Network dependency** - Requires connectivity to AWS STS endpoint +- **Performance** - Results are cached per CLI invocation, so there's minimal overhead when used multiple times +- **IAM permissions** - Requires `sts:GetCallerIdentity` permission (usually available to all authenticated principals) +- **ARN format varies** - The format differs based on identity type (user, assumed role, etc.) + +## Related Functions + +- [!aws.account_id](/functions/yaml/aws.account-id) - Get the AWS account ID +- [!aws.caller_identity_user_id](/functions/yaml/aws.caller-identity-user-id) - Get the unique user ID +- [!aws.region](/functions/yaml/aws.region) - Get the AWS region diff --git a/website/docs/functions/yaml/aws.caller-identity-user-id.mdx b/website/docs/functions/yaml/aws.caller-identity-user-id.mdx new file mode 100644 index 0000000000..6bdb7993a0 --- /dev/null +++ b/website/docs/functions/yaml/aws.caller-identity-user-id.mdx @@ -0,0 +1,146 @@ +--- +title: "!aws.caller_identity_user_id" +sidebar_position: 13 +sidebar_label: "!aws.caller_identity_user_id" +sidebar_class_name: command +description: Retrieve the unique user ID of the current AWS caller identity +--- + +import File from '@site/src/components/File' +import Intro from '@site/src/components/Intro' + + +The `!aws.caller_identity_user_id` YAML function retrieves the unique user ID of the current +caller identity by calling the AWS STS `GetCallerIdentity` API. + + +## Usage + +The `!aws.caller_identity_user_id` function takes no parameters: + +```yaml + # Get the user ID of the current AWS caller identity + user_id: !aws.caller_identity_user_id +``` + +## Arguments + +This function takes no arguments. It uses the AWS credentials from the environment or the +Atmos authentication context if configured. + +## How It Works + +When processing the `!aws.caller_identity_user_id` YAML function, Atmos: + +1. **Loads AWS Configuration** - Uses the standard AWS SDK credential resolution chain: + - Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) + - Shared credentials file (`~/.aws/credentials`) + - Shared config file (`~/.aws/config`) + - EC2 Instance Metadata Service (IMDS) + - ECS Task credentials + - Web Identity Token credentials + +2. **Calls STS GetCallerIdentity** - Makes an API call to retrieve the caller identity + +3. **Returns User ID** - Extracts and returns the unique user ID + +The returned user ID format depends on the type of identity: + +| Identity Type | User ID Format | +|--------------|----------------| +| IAM User | `AIDAXXXXXXXXXXEXAMPLE` (21 character unique ID) | +| IAM Role (assumed) | `AROAXXXXXXXXXXEXAMPLE:session-name` | +| Root Account | The account ID (e.g., `123456789012`) | +| Federated User | `account-id:caller-specified-name` | + +:::note Atmos Auth Integration +When using [Atmos Authentication](/cli/commands/auth/usage), the function automatically uses credentials +from the active identity. This enables seamless integration with SSO, assume role chains, and other +authentication methods configured in your `atmos.yaml`. +::: + +## Caching + +The `!aws.caller_identity_user_id` function shares its cache with other AWS identity functions +(`!aws.account_id`, `!aws.caller_identity_arn`, `!aws.region`). This means: + +- All AWS identity functions share a single STS API call +- Results are cached per CLI invocation +- Different authentication contexts get separate cache entries + +:::note Type-Aware Merging +Atmos supports type-aware merging of YAML functions and concrete values, allowing them to coexist in the inheritance chain without type conflicts. +See the full explanation: [YAML Function Merging](/reference/yaml-function-merging) +::: + +## Examples + +### Basic Usage + + +```yaml +components: + terraform: + my-component: + vars: + # Inject the caller user ID into Terraform variables + caller_user_id: !aws.caller_identity_user_id +``` + + +### Audit Trail + + +```yaml +components: + terraform: + infrastructure: + vars: + tags: + # Track unique user ID for audit purposes + ProvisionedByUserID: !aws.caller_identity_user_id + ManagedBy: "atmos" +``` + + +### Combined with Other AWS Functions + + +```yaml +components: + terraform: + audit-config: + vars: + # All AWS functions share the same cached STS call + aws_account_id: !aws.account_id + caller_arn: !aws.caller_identity_arn + caller_user_id: !aws.caller_identity_user_id + aws_region: !aws.region +``` + + +## Comparison with Terragrunt + +This function is equivalent to Terragrunt's `get_aws_caller_identity_user_id()` function: + +| Terragrunt | Atmos | +|------------|-------| +| `get_aws_caller_identity_user_id()` | `!aws.caller_identity_user_id` | + +## Error Handling + +If the function fails to retrieve the AWS caller identity (e.g., no credentials available, +network issues, or insufficient permissions), Atmos will log an error and exit. + +## Considerations + +- **Requires valid AWS credentials** - The function will fail if no valid credentials are available +- **Network dependency** - Requires connectivity to AWS STS endpoint +- **Performance** - Results are cached and shared with other AWS identity functions +- **IAM permissions** - Requires `sts:GetCallerIdentity` permission + +## Related Functions + +- [!aws.account_id](/functions/yaml/aws.account-id) - Get the AWS account ID +- [!aws.caller_identity_arn](/functions/yaml/aws.caller-identity-arn) - Get the full ARN +- [!aws.region](/functions/yaml/aws.region) - Get the AWS region diff --git a/website/docs/functions/yaml/aws.region.mdx b/website/docs/functions/yaml/aws.region.mdx new file mode 100644 index 0000000000..f6212beac6 --- /dev/null +++ b/website/docs/functions/yaml/aws.region.mdx @@ -0,0 +1,178 @@ +--- +title: "!aws.region" +sidebar_position: 14 +sidebar_label: "!aws.region" +sidebar_class_name: command +description: Retrieve the current AWS region from the SDK configuration +--- + +import File from '@site/src/components/File' +import Intro from '@site/src/components/Intro' + + +The `!aws.region` YAML function retrieves the AWS region from the current SDK configuration. +This is the region that would be used for AWS API calls. + + +## Usage + +The `!aws.region` function takes no parameters: + +```yaml + # Get the current AWS region + region: !aws.region +``` + +## Arguments + +This function takes no arguments. It uses the AWS credentials and configuration from the environment +or the Atmos authentication context if configured. + +## How It Works + +When processing the `!aws.region` YAML function, Atmos: + +1. **Loads AWS Configuration** - Uses the standard AWS SDK credential resolution chain: + - Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) + - Shared credentials file (`~/.aws/credentials`) + - Shared config file (`~/.aws/config`) + - EC2 Instance Metadata Service (IMDS) + - ECS Task credentials + - Web Identity Token credentials + +2. **Calls STS GetCallerIdentity** - Makes an API call to retrieve the caller identity + +3. **Returns Region** - Extracts and returns the region from the loaded AWS configuration + +:::note Atmos Auth Integration +When using [Atmos Authentication](/cli/commands/auth/usage), the function automatically uses credentials +from the active identity. This enables seamless integration with SSO, assume role chains, and other +authentication methods configured in your `atmos.yaml`. +::: + +## Caching + +The `!aws.region` function shares its cache with other AWS identity functions +(`!aws.account_id`, `!aws.caller_identity_arn`, `!aws.caller_identity_user_id`). This means: + +- All AWS identity functions share a single STS API call +- Results are cached per CLI invocation +- Different authentication contexts get separate cache entries + +:::note Type-Aware Merging +Atmos supports type-aware merging of YAML functions and concrete values, allowing them to coexist in the inheritance chain without type conflicts. +See the full explanation: [YAML Function Merging](/reference/yaml-function-merging) +::: + +## Examples + +### Basic Usage + + +```yaml +components: + terraform: + my-component: + vars: + # Inject the AWS region into Terraform variables + aws_region: !aws.region +``` + + +### Provider Configuration + + +```yaml +components: + terraform: + vpc: + vars: + # Use the current region for the VPC + region: !aws.region + + providers: + aws: + region: !aws.region +``` + + +### Resource Naming with Region + + +```yaml +components: + terraform: + s3-bucket: + vars: + # Pass region as separate var for Terraform to construct names + aws_region: !aws.region + + tags: + Region: !aws.region + Environment: "production" +``` + + +### Combined with Other AWS Functions + + +```yaml +components: + terraform: + infrastructure: + vars: + # All AWS functions share the same cached STS call + aws_account_id: !aws.account_id + aws_region: !aws.region + caller_arn: !aws.caller_identity_arn +``` + + +### Cross-Region Configuration + + +```yaml +components: + terraform: + replication-config: + vars: + # Use the current region as the source + source_region: !aws.region + + # Replicate to a different region (hardcoded destination) + destination_region: "eu-west-1" +``` + + +## Region Resolution Order + +The region is resolved in the following order: + +1. **Atmos Auth Context** - If using Atmos authentication with a region specified +2. **AWS_REGION environment variable** +3. **AWS_DEFAULT_REGION environment variable** +4. **Shared config file** (`~/.aws/config`) - The `region` setting for the active profile +5. **Instance metadata** - For EC2 instances or ECS tasks + +## Error Handling + +If the function fails to determine the AWS region (e.g., no credentials available, +no region configured), Atmos will log an error and exit. + +Common error scenarios: +- No AWS credentials configured +- No region specified in credentials, config, or environment +- Network connectivity issues (for STS call) + +## Considerations + +- **Requires valid AWS credentials** - The function needs credentials to make the STS call +- **Region must be configured** - Either via environment, config file, or Atmos auth +- **Performance** - Results are cached and shared with other AWS identity functions +- **IAM permissions** - Requires `sts:GetCallerIdentity` permission + +## Related Functions + +- [!aws.account_id](/functions/yaml/aws.account-id) - Get the AWS account ID +- [!aws.caller_identity_arn](/functions/yaml/aws.caller-identity-arn) - Get the full ARN +- [!aws.caller_identity_user_id](/functions/yaml/aws.caller-identity-user-id) - Get the user ID diff --git a/website/docs/functions/yaml/index.mdx b/website/docs/functions/yaml/index.mdx index 8464536e70..2874917b7d 100644 --- a/website/docs/functions/yaml/index.mdx +++ b/website/docs/functions/yaml/index.mdx @@ -78,6 +78,18 @@ YAML supports three types of data: core, defined, and user-defined. - 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 + - The [__`!aws.account_id`__](/functions/yaml/aws.account-id) YAML function retrieves the AWS account ID + 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 + + - 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 + + - The [__`!aws.region`__](/functions/yaml/aws.region) YAML function retrieves the AWS region + from the current SDK configuration + :::tip You can combine [Atmos Stack Manifest Templating](/templates) with Atmos YAML functions within the same stack configuration. Atmos processes templates first, followed by YAML functions, enabling you to dynamically provide parameters to the YAML functions. @@ -154,6 +166,18 @@ components: # Generate a random port number between 1024 and 65535 app_port: !random 1024 65535 + + # Get the current AWS account ID + aws_account_id: !aws.account_id + + # Get the ARN of the current AWS caller identity + aws_caller_arn: !aws.caller_identity_arn + + # Get the unique user ID of the current AWS caller identity + aws_user_id: !aws.caller_identity_user_id + + # Get the current AWS region + aws_region: !aws.region ```