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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions cmd/auth_console_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,14 @@ func (m *mockAuthManagerForProvider) GetIntegration(integrationName string) (*sc
return nil, errUtils.ErrNotImplemented
}

func (m *mockAuthManagerForProvider) ResolvePrincipalSetting(identityName, key string) (interface{}, bool) {
return nil, false
}

func (m *mockAuthManagerForProvider) ResolveProviderConfig(identityName string) (*schema.Provider, bool) {
return nil, false
}

// mockAuthManagerForIdentity implements minimal AuthManager for testing resolveIdentityName.
// Only GetDefaultIdentity is implemented - other methods return ErrNotImplemented
// because they are not needed by TestResolveIdentityName.
Expand Down Expand Up @@ -911,6 +919,14 @@ func (m *mockAuthManagerForIdentity) GetIntegration(integrationName string) (*sc
return nil, errUtils.ErrNotImplemented
}

func (m *mockAuthManagerForIdentity) ResolvePrincipalSetting(identityName, key string) (interface{}, bool) {
return nil, false
}

func (m *mockAuthManagerForIdentity) ResolveProviderConfig(identityName string) (*schema.Provider, bool) {
return nil, false
}

func TestResolveConsoleDuration(t *testing.T) {
_ = NewTestKit(t)

Expand Down
7 changes: 2 additions & 5 deletions cmd/auth_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,8 @@ func TestAuthCommandCompletion(t *testing.T) {
// Call the completion function.
completions, directive := completionFunc(authEnvCmd, []string{}, "")

// Verify we get the expected formats.
assert.Equal(t, 3, len(completions))
assert.Contains(t, completions, "json")
assert.Contains(t, completions, "bash")
assert.Contains(t, completions, "dotenv")
// Verify we get the expected formats (must match SupportedFormats in auth_env.go).
assert.ElementsMatch(t, SupportedFormats, completions)
assert.Equal(t, 4, int(directive)) // ShellCompDirectiveNoFileComp
})

Expand Down
57 changes: 57 additions & 0 deletions cmd/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,63 @@
return result
}

// formatDotenv formats environment variables in .env format.
func formatDotenv(envVars map[string]string) string {
keys := sortedKeys(envVars)
var sb strings.Builder
for _, key := range keys {
value := envVars[key]
// Use the same safe single-quoted escaping as bash output.
safe := strings.ReplaceAll(value, "'", "'\\''")
sb.WriteString(fmt.Sprintf("%s='%s'\n", key, safe))

Check failure on line 162 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (macos)

undefined: fmt

Check failure on line 162 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (linux)

undefined: fmt

Check failure on line 162 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (windows)

undefined: fmt
}
return sb.String()
}

// formatGitHub formats environment variables for GitHub Actions $GITHUB_ENV file.
// Uses KEY=value format without quoting. For multiline values, GitHub uses heredoc syntax.
func formatGitHub(envVars map[string]string) string {
keys := sortedKeys(envVars)
var sb strings.Builder
for _, key := range keys {
value := envVars[key]
// Check if value contains newlines - use heredoc syntax.
// Use ATMOS_EOF_ prefix to avoid collision with values containing "EOF".
if strings.Contains(value, "\n") {
sb.WriteString(fmt.Sprintf("%s<<ATMOS_EOF_%s\n%s\nATMOS_EOF_%s\n", key, key, value, key))

Check failure on line 177 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (macos)

undefined: fmt

Check failure on line 177 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (linux)

undefined: fmt

Check failure on line 177 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (windows)

undefined: fmt
} else {
sb.WriteString(fmt.Sprintf("%s=%s\n", key, value))

Check failure on line 179 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (macos)

undefined: fmt

Check failure on line 179 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (linux)

undefined: fmt

Check failure on line 179 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (windows)

undefined: fmt
}
}
return sb.String()
}

// writeEnvToFile writes formatted environment variables to a file (append mode).
func writeEnvToFile(envVars map[string]string, filePath string, formatter func(map[string]string) string) error {
// Open file in append mode, create if doesn't exist.
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, defaultFileMode)

Check failure on line 188 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (macos)

undefined: defaultFileMode

Check failure on line 188 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (macos)

undefined: os

Check failure on line 188 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (linux)

undefined: defaultFileMode

Check failure on line 188 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (linux)

undefined: os

Check failure on line 188 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (windows)

undefined: defaultFileMode

Check failure on line 188 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (windows)

undefined: os
if err != nil {
return fmt.Errorf("%w: '%s': %w", errUtils.ErrOpenFile, filePath, err)

Check failure on line 190 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (macos)

undefined: fmt

Check failure on line 190 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (linux)

undefined: fmt

Check failure on line 190 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (windows)

undefined: fmt
}
defer f.Close()

content := formatter(envVars)
if _, err := f.WriteString(content); err != nil {
return fmt.Errorf("%w: '%s': %w", errUtils.ErrWriteFile, filePath, err)

Check failure on line 196 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (macos)

undefined: fmt

Check failure on line 196 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (linux)

undefined: fmt

Check failure on line 196 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (windows)

undefined: fmt
}
return nil
}

// sortedKeys returns the keys of a map sorted alphabetically.
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)

Check failure on line 207 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (macos)

undefined: sort

Check failure on line 207 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (linux)

undefined: sort

Check failure on line 207 in cmd/env/env.go

View workflow job for this annotation

GitHub Actions / Build (windows)

undefined: sort
return keys
}

func init() {
// Create parser with env-specific flags using functional options.
envParser = flags.NewStandardParser(
Expand Down
26 changes: 22 additions & 4 deletions docs/prd/aws-auth-file-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,18 @@ output = json
- Purpose: Selects which profile section to use within the credentials/config files
- Note: Profile name matches identity name for consistency

**`AWS_REGION`** - Region override (optional)
**`AWS_REGION`** - Region from identity/provider configuration
- Example: `us-east-1`
- Purpose: Overrides region from config file
- Supports component-level overrides via stack inheritance
- Purpose: Sets region for AWS SDK operations
- Exported by: `atmos auth env` (when region is explicitly configured)
- Also available via: `!env AWS_REGION` in stack configurations
- Note: Only exported when region is explicitly configured in identity or provider; no default fallback

**`AWS_DEFAULT_REGION`** - Same as AWS_REGION (for SDK compatibility)
- Example: `us-east-1`
- Purpose: Fallback region for older AWS SDKs/tools
- Exported by: `atmos auth env` (when region is explicitly configured)
- Note: Set to same value as AWS_REGION for compatibility with legacy tools

### Conflicting Variables Cleared

Expand Down Expand Up @@ -229,12 +237,21 @@ Logic flow:
1. Create copy of input environment (doesn't mutate input)
2. Clear conflicting AWS credential env vars
3. Set `AWS_SHARED_CREDENTIALS_FILE`, `AWS_CONFIG_FILE`, `AWS_PROFILE`
4. Set `AWS_REGION` if provided
4. Set `AWS_REGION` and `AWS_DEFAULT_REGION` if region is explicitly configured
5. Set `AWS_EC2_METADATA_DISABLED=true`
6. Return new map

**Key Feature:** Returns NEW map instead of mutating input for safety and testability.

**Identity `Environment()` Method:**

The `Environment()` method on AWS identities returns environment variables for `atmos auth env`:
- Always returns: `AWS_SHARED_CREDENTIALS_FILE`, `AWS_CONFIG_FILE`, `AWS_PROFILE`
- Conditionally returns: `AWS_REGION`, `AWS_DEFAULT_REGION` (only when region is explicitly configured)
- Does NOT use default fallback region - exports only explicitly configured values

This enables users to reference `!env AWS_REGION` in stack configurations after sourcing auth environment variables.

### Auth Context Schema (`pkg/schema/schema.go`)

```go
Expand Down Expand Up @@ -539,3 +556,4 @@ AWS authentication successfully implements the universal pattern:
| Date | Version | Changes |
|------|---------|---------|
| 2025-01-XX | 1.0 | Initial AWS implementation PRD created to document existing implementation |
| 2025-01-12 | 1.1 | Added AWS_REGION and AWS_DEFAULT_REGION export via `atmos auth env` when region is explicitly configured |
1 change: 1 addition & 0 deletions errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ var (
ErrCopyFile = errors.New("failed to copy file")
ErrCreateDirectory = errors.New("failed to create directory")
ErrOpenFile = errors.New("failed to open file")
ErrWriteFile = errors.New("failed to write to file")
ErrStatFile = errors.New("failed to stat file")
ErrRemoveDirectory = errors.New("failed to remove directory")
ErrSetPermissions = errors.New("failed to set permissions")
Expand Down
1 change: 0 additions & 1 deletion internal/exec/packer.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,6 @@ func ExecutePacker(
}

// Check if the component is locked (`metadata.locked` is set to true).
// For Packer, only `build` modifies external resources.
if info.ComponentIsLocked && info.SubCommand == "build" {
return fmt.Errorf("%w: component '%s' cannot be modified (metadata.locked: true)",
errUtils.ErrLockedComponentCantBeProvisioned,
Expand Down
2 changes: 1 addition & 1 deletion internal/exec/packer_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func ExecutePackerOutput(

manifestPath := filepath.Join(componentPath, manifestFilename)
if !u.FileExists(manifestPath) {
return nil, fmt.Errorf("%w: '%s' does not exist - it is generated by Packer when executing 'atmos packer build' and the manifest filename is specified in the 'manifest_file_name' variable of the Atmos component",
return nil, fmt.Errorf("%w: '%s' (generated by 'atmos packer build', filename configured via 'manifest_file_name' variable)",
errUtils.ErrMissingPackerManifest,
filepath.Join(atmosConfig.Components.Packer.BasePath, info.ComponentFolderPrefix, info.FinalComponent, manifestFilename),
)
Expand Down
16 changes: 16 additions & 0 deletions internal/exec/terraform_output_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,22 @@ func (a *authContextWrapper) GetIntegration(integrationName string) (*schema.Int
panic("authContextWrapper.GetIntegration should not be called")
}

func (a *authContextWrapper) ResolvePrincipalSetting(identityName, key string) (interface{}, bool) {
defer perf.Track(nil, "exec.authContextWrapper.ResolvePrincipalSetting")()

// Return false - this wrapper doesn't have access to identity/provider configuration.
// It only propagates existing auth context for nested component resolution.
return nil, false
}

func (a *authContextWrapper) ResolveProviderConfig(identityName string) (*schema.Provider, bool) {
defer perf.Track(nil, "exec.authContextWrapper.ResolveProviderConfig")()

// Return false - this wrapper doesn't have access to provider configuration.
// It only propagates existing auth context for nested component resolution.
return nil, false
}

// newAuthContextWrapper creates an AuthManager wrapper that returns the given AuthContext.
func newAuthContextWrapper(authContext *schema.AuthContext) *authContextWrapper {
if authContext == nil {
Expand Down
8 changes: 8 additions & 0 deletions pkg/auth/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ func (s *stubAuthManager) GetIntegration(integrationName string) (*schema.Integr
return nil, nil
}

func (s *stubAuthManager) ResolvePrincipalSetting(identityName, key string) (interface{}, bool) {
return nil, false
}

func (s *stubAuthManager) ResolveProviderConfig(identityName string) (*schema.Provider, bool) {
return nil, false
}

func TestGetConfigLogLevels(t *testing.T) {
tests := []struct {
name string
Expand Down
41 changes: 39 additions & 2 deletions pkg/auth/identities/aws/assume_role.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,13 @@ func (i *assumeRoleIdentity) Environment() (map[string]string, error) {
env[envVar.Key] = envVar.Value
}

// Resolve region through identity chain inheritance.
// First checks identity principal, then parent identities, then provider.
if region := i.resolveRegion(); region != "" {
env["AWS_REGION"] = region
env["AWS_DEFAULT_REGION"] = region
}

// Add environment variables from identity config.
for _, envVar := range i.config.Env {
env[envVar.Key] = envVar.Value
Expand Down Expand Up @@ -413,8 +420,8 @@ func (i *assumeRoleIdentity) PrepareEnvironment(ctx context.Context, environ map
credentialsFile := awsFileManager.GetCredentialsPath(providerName)
configFile := awsFileManager.GetConfigPath(providerName)

// Get region from identity if available.
region := i.region
// Resolve region through identity chain inheritance.
region := i.resolveRegion()

// Use shared AWS environment preparation helper.
return awsCloud.PrepareEnvironment(environ, i.name, credentialsFile, configFile, region), nil
Expand Down Expand Up @@ -467,6 +474,36 @@ func (i *assumeRoleIdentity) getRootProviderFromVia() (string, error) {
return "", fmt.Errorf("%w: cannot determine root provider for identity %q before authentication", errUtils.ErrInvalidAuthConfig, i.name)
}

// resolveRegion resolves the AWS region by traversing the identity chain.
// First checks identity chain for region setting, then falls back to provider's region.
// This uses the manager's generic chain resolution methods to support inheritance.
func (i *assumeRoleIdentity) resolveRegion() string {
// If manager is not available, fall back to direct config check or cached region.
if i.manager == nil {
if i.region != "" {
return i.region
}
if region, ok := i.config.Principal["region"].(string); ok && region != "" {
return region
}
return ""
}

// First check identity chain for region setting.
if val, ok := i.manager.ResolvePrincipalSetting(i.name, "region"); ok {
if region, ok := val.(string); ok && region != "" {
return region
}
}

// Fall back to provider's region.
if provider, ok := i.manager.ResolveProviderConfig(i.name); ok {
return provider.Region
}

return ""
}

// SetManagerAndProvider sets the manager and root provider name on the identity.
// This is used when loading cached credentials to allow the identity to resolve provider information.
func (i *assumeRoleIdentity) SetManagerAndProvider(manager types.AuthManager, rootProviderName string) {
Expand Down
38 changes: 38 additions & 0 deletions pkg/auth/identities/aws/assume_role_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,44 @@ func TestAssumeRoleIdentity_Environment(t *testing.T) {
assert.Equal(t, "role", env["AWS_PROFILE"])
}

func TestAssumeRoleIdentity_Environment_WithRegion(t *testing.T) {
// When region is set on the identity, Environment should include AWS_REGION and AWS_DEFAULT_REGION.
i := &assumeRoleIdentity{
name: "role",
region: "eu-west-1",
config: &schema.Identity{
Kind: "aws/assume-role",
Principal: map[string]any{"assume_role": "arn:aws:iam::123:role/x"},
Via: &schema.IdentityVia{Provider: "test-provider"},
},
}
env, err := i.Environment()
assert.NoError(t, err)
// Should include region vars when explicitly configured.
assert.Equal(t, "eu-west-1", env["AWS_REGION"])
assert.Equal(t, "eu-west-1", env["AWS_DEFAULT_REGION"])
}

func TestAssumeRoleIdentity_Environment_WithoutRegion(t *testing.T) {
// When region is NOT set, Environment should NOT include AWS_REGION (no default fallback).
i := &assumeRoleIdentity{
name: "role",
region: "", // No region set
config: &schema.Identity{
Kind: "aws/assume-role",
Principal: map[string]any{"assume_role": "arn:aws:iam::123:role/x"},
Via: &schema.IdentityVia{Provider: "test-provider"},
},
}
env, err := i.Environment()
assert.NoError(t, err)
// Should NOT include region vars when not explicitly configured.
_, hasRegion := env["AWS_REGION"]
_, hasDefaultRegion := env["AWS_DEFAULT_REGION"]
assert.False(t, hasRegion, "AWS_REGION should not be set when region is not explicitly configured")
assert.False(t, hasDefaultRegion, "AWS_DEFAULT_REGION should not be set when region is not explicitly configured")
}

func TestAssumeRoleIdentity_BuildAssumeRoleInput(t *testing.T) {
// External ID and duration should be set when provided.
i := &assumeRoleIdentity{name: "role", config: &schema.Identity{
Expand Down
41 changes: 39 additions & 2 deletions pkg/auth/identities/aws/assume_root.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,13 @@ func (i *assumeRootIdentity) Environment() (map[string]string, error) {
env[envVar.Key] = envVar.Value
}

// Resolve region through identity chain inheritance.
// First checks identity principal, then parent identities, then provider.
if region := i.resolveRegion(); region != "" {
env["AWS_REGION"] = region
env["AWS_DEFAULT_REGION"] = region
}

// Add environment variables from identity config.
for _, envVar := range i.config.Env {
env[envVar.Key] = envVar.Value
Expand Down Expand Up @@ -339,8 +346,8 @@ func (i *assumeRootIdentity) PrepareEnvironment(ctx context.Context, environ map
credentialsFile := awsFileManager.GetCredentialsPath(providerName)
configFile := awsFileManager.GetConfigPath(providerName)

// Get region from identity if available.
region := i.region
// Resolve region through identity chain inheritance.
region := i.resolveRegion()

// Use shared AWS environment preparation helper.
return awsCloud.PrepareEnvironment(environ, i.name, credentialsFile, configFile, region), nil
Expand Down Expand Up @@ -386,6 +393,36 @@ func (i *assumeRootIdentity) getRootProviderFromVia() (string, error) {
return "", fmt.Errorf("%w: cannot determine root provider for identity %q before authentication", errUtils.ErrInvalidAuthConfig, i.name)
}

// resolveRegion resolves the AWS region by traversing the identity chain.
// First checks identity chain for region setting, then falls back to provider's region.
// This uses the manager's generic chain resolution methods to support inheritance.
func (i *assumeRootIdentity) resolveRegion() string {
// If manager is not available, fall back to direct config check or cached region.
if i.manager == nil {
if i.region != "" {
return i.region
}
if region, ok := i.config.Principal["region"].(string); ok && region != "" {
return region
}
return ""
}

// First check identity chain for region setting.
if val, ok := i.manager.ResolvePrincipalSetting(i.name, "region"); ok {
if region, ok := val.(string); ok && region != "" {
return region
}
}

// Fall back to provider's region.
if provider, ok := i.manager.ResolveProviderConfig(i.name); ok {
return provider.Region
}

return ""
}

// SetManagerAndProvider sets the manager and root provider name on the identity.
func (i *assumeRootIdentity) SetManagerAndProvider(manager types.AuthManager, rootProviderName string) {
i.manager = manager
Expand Down
Loading
Loading