diff --git a/cmd/auth_login.go b/cmd/auth_login.go index 1fc9b4d6d7..2093069bff 100644 --- a/cmd/auth_login.go +++ b/cmd/auth_login.go @@ -66,7 +66,8 @@ func executeAuthLoginCommand(cmd *cobra.Command, args []string) error { // Provider-level authentication (e.g., for SSO auto-provisioning). whoami, err = authManager.AuthenticateProvider(ctx, providerName) if err != nil { - return fmt.Errorf("%w: provider=%s: %w", errUtils.ErrAuthenticationFailed, providerName, err) + // Return error directly - it already has ErrorBuilder context with hints. + return err } } else { // Try identity-level authentication first. @@ -82,7 +83,8 @@ func executeAuthLoginCommand(cmd *cobra.Command, args []string) error { } whoami, err = authManager.AuthenticateProvider(ctx, providerName) if err != nil { - return fmt.Errorf("%w: provider=%s: %w", errUtils.ErrAuthenticationFailed, providerName, err) + // Return error directly - it already has ErrorBuilder context with hints. + return err } } else if err != nil { return err @@ -118,14 +120,16 @@ func authenticateIdentity(ctx context.Context, cmd *cobra.Command, authManager a errors.Is(err, errUtils.ErrNoDefaultIdentity) { return nil, true, nil } - return nil, false, fmt.Errorf(errUtils.ErrWrapFormat, errUtils.ErrDefaultIdentity, err) + // Return error directly - it already has ErrorBuilder context with hints. + return nil, false, err } } // Perform identity authentication. whoami, err := authManager.Authenticate(ctx, identityName) if err != nil { - return nil, false, fmt.Errorf("%w: identity=%s: %w", errUtils.ErrAuthenticationFailed, identityName, err) + // Return error directly - it already has ErrorBuilder context with hints. + return nil, false, err } return whoami, false, nil diff --git a/cmd/terraform/utils.go b/cmd/terraform/utils.go index 1bf401d0e3..21cde840bb 100644 --- a/cmd/terraform/utils.go +++ b/cmd/terraform/utils.go @@ -333,7 +333,8 @@ func handleInteractiveIdentitySelection(info *schema.ConfigAndStacksInfo) { // Note: We bypass error formatting as user abort is not an error condition. errUtils.Exit(errUtils.ExitCodeSIGINT) } - errUtils.CheckErrorPrintAndExit(fmt.Errorf(errWrapFormat, errUtils.ErrDefaultIdentity, err), "", "") + // Print error directly - it already has ErrorBuilder context with hints. + errUtils.CheckErrorPrintAndExit(err, "", "") } info.Identity = selectedIdentity diff --git a/docs/prd/auth-error-messaging.md b/docs/prd/auth-error-messaging.md new file mode 100644 index 0000000000..ecd15a8534 --- /dev/null +++ b/docs/prd/auth-error-messaging.md @@ -0,0 +1,268 @@ +# PRD: Improved Auth Error Messaging + +## Overview + +This PRD defines requirements for improving error messaging in `atmos auth` to provide better developer experience when authentication fails. Currently, auth errors lack context about why authentication failed, making troubleshooting difficult. + +## Problem Statement + +When authentication fails, users receive minimal error messages like: + +```text +Error: authentication failed: failed to authenticate via credential chain +for identity "plat-dev/terraform": authentication failed: identity=plat- +dev/terraform step=1: authentication failed +``` + +This error provides no visibility into: +- The identity being authenticated +- The current profile (or lack thereof) +- Actionable steps to resolve the issue + +## Goals + +1. Provide rich, contextual error messages for all auth-related failures +2. Use the standard ErrorBuilder pattern consistently across auth code +3. Include profile info and actionable hints +4. Cover authentication failures, credential cache issues, and configuration validation errors + +## Non-Goals + +- Changing authentication logic or flow +- Adding new authentication providers +- Modifying the credential storage system + +## Scope + +This PRD covers error improvements for three categories: + +### 1. Authentication Failures +- Provider authentication failures (AWS SSO, GitHub OIDC, etc.) +- Identity authentication failures (assume-role, permission-set) +- Credential chain failures (multi-step authentication) + +### 2. Credential Cache Issues +- Missing credentials (`ErrNoCredentialsFound`) +- Expired credentials (`ErrExpiredCredentials`) +- Invalid credentials +- Credential store initialization failures + +### 3. Configuration Validation Errors +- Invalid provider configuration +- Invalid identity configuration +- Missing required fields +- Circular dependencies in identity chains +- Identity/provider not found + +## Requirements + +### R1: Error Context Requirements + +All auth errors MUST include the following context using the existing ErrorBuilder `.WithContext()` API: + +| Context | Description | Example | +|---------|-------------|---------| +| `profile` | Active profile(s) or "(not set)" | `devops` | +| `provider` | Provider name | `aws-sso` | +| `identity` | Target identity name | `plat-dev/terraform` | +| `expiration` | Credential expiration time (when applicable) | `2024-01-15T10:30:00Z` | + +### R2: Profile Status Display + +All auth errors MUST include current profile status in the context table. + +If not set: +```text +| profile | (not set) | +``` + +Multiple profiles should be comma-separated: +```text +| profile | ci, developer | +``` + +### R3: Actionable Hints + +Each error type MUST include appropriate hints: + +| Error Type | Required Hints | +|------------|----------------| +| Auth failure | "Run `atmos auth --help` for troubleshooting" | +| Expired credentials | "Run `atmos auth login` to refresh credentials" | +| Missing credentials | "Run `atmos auth login` to authenticate" | +| SSO session expired | "Run `atmos auth login --provider ` to re-authenticate" | +| Identity not found | "Run `atmos list identities` to see available identities" | +| Provider not found | "Run `atmos list providers` to see available providers" | +| No profile set | "Set ATMOS_PROFILE or use --profile flag" | +| Circular dependency | "Check identity chain configuration for cycles" | + +### R4: ErrorBuilder Migration + +All auth errors MUST use the ErrorBuilder pattern: + +```go +return errUtils.Build(errUtils.ErrAuthenticationFailed). + WithCause(underlyingErr). + WithExplanation("Failed to authenticate identity"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", formatProfile(profilesFromArg)). + WithContext("provider", providerName). + WithContext("identity", identityName). + Err() +``` + +### R5: Error Categories and Sentinels + +Use appropriate sentinel errors for each category: + +**Authentication Failures:** +- `ErrAuthenticationFailed` - General auth failure +- `ErrPostAuthenticationHookFailed` - Post-auth hook failure + +**Credential Issues:** +- `ErrNoCredentialsFound` - Missing credentials +- `ErrExpiredCredentials` - Expired/invalid credentials +- `ErrInitializingCredentialStore` - Store init failure + +**Configuration Errors:** +- `ErrInvalidIdentityConfig` - Invalid identity config +- `ErrInvalidProviderConfig` - Invalid provider config +- `ErrIdentityNotFound` - Identity not in config +- `ErrProviderNotFound` - Provider not in config +- `ErrCircularDependency` - Circular chain dependency +- `ErrNoDefaultIdentity` - No default identity configured + +**SSO-Specific:** +- `ErrSSOSessionExpired` - SSO session expired +- `ErrSSODeviceAuthFailed` - Device auth failed +- `ErrSSOTokenCreationFailed` - Token creation failed + +## Example Error Output + +### Example 1: Authentication Failed + +```markdown +# Authentication Error + +**Error:** authentication failed + +## Explanation + +Failed to authenticate via credential chain for identity "plat-dev/terraform". + +## Hints + +💡 Run `atmos auth --help` for troubleshooting +💡 Check that the GitHub OIDC provider is correctly configured + +## Context + +| Key | Value | +|-----|-------| +| profile | devops | +| provider | github-oidc | +| identity | plat-dev/terraform | +``` + +### Example 2: Credentials Expired (No Profile) + +```markdown +# Authentication Error + +**Error:** credentials for identity are expired or invalid + +## Explanation + +Cached credentials for identity "core-auto/terraform" have expired. + +## Hints + +💡 Run `atmos auth login` to refresh credentials +💡 Set ATMOS_PROFILE or use --profile flag to select a profile + +## Context + +| Key | Value | +|-----|-------| +| profile | (not set) | +| provider | aws-sso | +| identity | core-auto/terraform | +| expiration | 2024-01-15T10:30:00Z | +``` + +### Example 3: Identity Not Found in Profile + +```markdown +# Authentication Error + +**Error:** identity not found + +## Explanation + +Identity "plat-sandbox/terraform" not found in the current configuration. + +## Hints + +💡 Run `atmos list identities` to see available identities +💡 Check that the identity is defined in your auth configuration +💡 Current profile may not include this identity + +## Context + +| Key | Value | +|-----|-------| +| profile | github-plan | +| provider | (not set) | +| identity | plat-sandbox/terraform | +``` + +## Implementation Notes + +### Helper Functions + +Create helper functions for consistent formatting: + +```go +// FormatProfile formats profile information for error display. +func FormatProfile(profiles []string) string { + if len(profiles) == 0 { + return "(not set)" + } + return strings.Join(profiles, ", ") +} +``` + +### Files to Modify + +| File | Changes | +|------|---------| +| `pkg/auth/manager.go` | Migrate all error returns to ErrorBuilder pattern | +| `pkg/auth/manager_helpers.go` | Add profile context to errors | +| `pkg/auth/credentials/store.go` | Improve credential store errors | +| `pkg/auth/validation/validator.go` | Improve validation error messages | +| `pkg/auth/identities/aws/*.go` | Add identity-specific error context | +| `pkg/auth/providers/aws/*.go` | Add provider-specific error context | +| `pkg/auth/errors.go` (new) | Helper functions for error formatting | + +### Testing Requirements + +1. Unit tests for each error type with expected context +2. Test that `errors.Is()` works correctly with wrapped errors +3. Test profile formatting (set, not set, multiple) + +## Success Criteria + +1. All auth errors use ErrorBuilder pattern consistently +2. Every auth error includes identity, provider, and profile context +3. Every error includes at least one actionable hint +4. Users can understand why authentication failed from the error message alone +5. All existing tests pass with updated error format +6. New tests cover all error scenarios + +## References + +- [ErrorBuilder Documentation](../errors.md) +- [Auth Configuration](../auth/README.md) +- [Atmos Profiles PRD](./atmos-profiles.md) +- [Auth Default Settings PRD](./auth-default-settings.md) +- Linear Issue: DEV-3809 diff --git a/errors/formatter.go b/errors/formatter.go index 129770fbd9..d2a5675bab 100644 --- a/errors/formatter.go +++ b/errors/formatter.go @@ -59,20 +59,21 @@ func DefaultFormatterConfig() FormatterConfig { // Context is extracted from cockroachdb/errors safe details and displayed // as key-value pairs only in verbose mode. func formatContextTable(err error, useColor bool) string { - details := errors.GetSafeDetails(err) - if len(details.SafeDetails) == 0 { + // Use GetAllSafeDetails to traverse the entire error chain. + // Safe details may be wrapped at different levels of the error chain. + allDetails := errors.GetAllSafeDetails(err) + if len(allDetails) == 0 { return "" } - // Parse "component=vpc stack=prod" format into key-value pairs. + // Parse "key1=value1 key2=value2" format into key-value pairs. + // Values can contain spaces (e.g., "profile=(not set)"), so we use a smarter parser. var rows [][]string - for _, detail := range details.SafeDetails { - str := fmt.Sprintf("%v", detail) - pairs := strings.Split(str, " ") - for _, pair := range pairs { - if parts := strings.SplitN(pair, "=", 2); len(parts) == 2 { - rows = append(rows, []string{parts[0], parts[1]}) - } + for _, layer := range allDetails { + for _, detail := range layer.SafeDetails { + str := fmt.Sprintf("%v", detail) + pairs := parseContextPairs(str) + rows = append(rows, pairs...) } } @@ -413,19 +414,22 @@ func renderMarkdown(md string, maxLineLength int) string { // formatContextForMarkdown formats context as a markdown table. func formatContextForMarkdown(err error) string { - details := errors.GetSafeDetails(err) - if len(details.SafeDetails) == 0 { + // Use GetAllSafeDetails to traverse the entire error chain. + // Safe details may be wrapped at different levels of the error chain. + allDetails := errors.GetAllSafeDetails(err) + if len(allDetails) == 0 { return "" } - // Parse "component=vpc stack=prod" format into key-value pairs. + // Parse "key1=value1 key2=value2" format into key-value pairs. + // Values can contain spaces (e.g., "profile=(not set)"), so we use a smarter parser. var rows []string - for _, detail := range details.SafeDetails { - str := fmt.Sprintf("%v", detail) - pairs := strings.Split(str, " ") - for _, pair := range pairs { - if parts := strings.SplitN(pair, "=", 2); len(parts) == 2 { - rows = append(rows, fmt.Sprintf("| %s | %s |", parts[0], parts[1])) + for _, layer := range allDetails { + for _, detail := range layer.SafeDetails { + str := fmt.Sprintf("%v", detail) + pairs := parseContextPairs(str) + for _, pair := range pairs { + rows = append(rows, fmt.Sprintf("| %s | %s |", pair[0], pair[1])) } } } @@ -553,3 +557,45 @@ func formatStackTrace(err error, useColor bool) string { details := fmt.Sprintf("%+v", err) return style.Render(details) } + +// parseContextPairs parses a context string like "key1=value1 key2=value2" into key-value pairs. +// Values can contain spaces (e.g., "profile=(not set)"), so we parse by finding the next key= +// pattern rather than splitting on spaces. +func parseContextPairs(s string) [][]string { + var pairs [][]string + + // Find all positions where a key starts (word followed by =). + // Pattern: space or start of string, followed by word characters, followed by =. + keyPattern := regexp.MustCompile(`(?:^|\s)([a-zA-Z_][a-zA-Z0-9_]*)=`) + matches := keyPattern.FindAllStringSubmatchIndex(s, -1) + + for i, match := range matches { + // match[2] and match[3] are the start/end of the captured key group. + keyStart := match[2] + keyEnd := match[3] + key := s[keyStart:keyEnd] + + // Value starts after the = sign. + valueStart := keyEnd + 1 // skip the = + + // Value ends at the start of the next key or end of string. + var valueEnd int + if i+1 < len(matches) { + // Next match's full match starts at match[0], but we want the space before. + valueEnd = matches[i+1][0] + // Trim trailing space from value. + for valueEnd > valueStart && s[valueEnd-1] == ' ' { + valueEnd-- + } + } else { + valueEnd = len(s) + } + + if valueStart <= valueEnd { + value := s[valueStart:valueEnd] + pairs = append(pairs, []string{key, value}) + } + } + + return pairs +} diff --git a/pkg/auth/errors.go b/pkg/auth/errors.go new file mode 100644 index 0000000000..52efb9568f --- /dev/null +++ b/pkg/auth/errors.go @@ -0,0 +1,24 @@ +package auth + +import ( + "strings" + "time" +) + +// FormatProfile formats profile information for error display. +// Returns comma-separated profile names or "(not set)" if empty. +func FormatProfile(profiles []string) string { + if len(profiles) == 0 { + return "(not set)" + } + return strings.Join(profiles, ", ") +} + +// FormatExpiration formats expiration time for error display. +// Returns RFC3339 formatted time or empty string if nil. +func FormatExpiration(expiration *time.Time) string { + if expiration == nil { + return "" + } + return expiration.Format(time.RFC3339) +} diff --git a/pkg/auth/errors_test.go b/pkg/auth/errors_test.go new file mode 100644 index 0000000000..8bfc32120c --- /dev/null +++ b/pkg/auth/errors_test.go @@ -0,0 +1,77 @@ +package auth + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestFormatProfile(t *testing.T) { + tests := []struct { + name string + profiles []string + expected string + }{ + { + name: "nil profiles", + profiles: nil, + expected: "(not set)", + }, + { + name: "empty profiles", + profiles: []string{}, + expected: "(not set)", + }, + { + name: "single profile", + profiles: []string{"devops"}, + expected: "devops", + }, + { + name: "multiple profiles", + profiles: []string{"ci", "developer"}, + expected: "ci, developer", + }, + { + name: "three profiles", + profiles: []string{"admin", "devops", "ci"}, + expected: "admin, devops, ci", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatProfile(tt.profiles) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatExpiration(t *testing.T) { + testTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + + tests := []struct { + name string + expiration *time.Time + expected string + }{ + { + name: "nil expiration", + expiration: nil, + expected: "", + }, + { + name: "valid expiration", + expiration: &testTime, + expected: "2024-01-15T10:30:00Z", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatExpiration(tt.expiration) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/auth/hooks.go b/pkg/auth/hooks.go index 8e4068c244..65ee2f188d 100644 --- a/pkg/auth/hooks.go +++ b/pkg/auth/hooks.go @@ -30,10 +30,16 @@ const hookOpTerraformPreHook = "TerraformPreHook" // TerraformPreHook runs before Terraform commands to set up authentication. func TerraformPreHook(atmosConfig *schema.AtmosConfiguration, stackInfo *schema.ConfigAndStacksInfo) error { if stackInfo == nil { - return fmt.Errorf("%w: stack info is nil", errUtils.ErrInvalidAuthConfig) + return errUtils.Build(errUtils.ErrInvalidAuthConfig). + WithExplanation("Stack info is nil - this is an internal error"). + WithHint("Please report this issue at https://github.com/cloudposse/atmos/issues"). + Err() } if atmosConfig == nil { - return fmt.Errorf("%w: atmos configuration is nil", errUtils.ErrInvalidAuthConfig) + return errUtils.Build(errUtils.ErrInvalidAuthConfig). + WithExplanation("Atmos configuration is nil - this is an internal error"). + WithHint("Please report this issue at https://github.com/cloudposse/atmos/issues"). + Err() } atmosLevel, authLevel := getConfigLogLevels(atmosConfig) @@ -60,8 +66,12 @@ func TerraformPreHook(atmosConfig *schema.AtmosConfiguration, stackInfo *schema. authManager, err := newAuthManager(&authConfig, stackInfo) if err != nil { - errUtils.CheckErrorAndPrint(errUtils.ErrAuthManager, hookOpTerraformPreHook, "failed to create auth manager") - return errUtils.ErrAuthManager + return errUtils.Build(errUtils.ErrAuthManager). + WithCause(err). + WithExplanation("Failed to create auth manager"). + WithHint("Check your auth configuration in atmos.yaml"). + WithContext("profile", FormatProfile(stackInfo.ProfilesFromArg)). + Err() } // Determine target identity and authenticate. @@ -79,8 +89,12 @@ func TerraformPreHook(atmosConfig *schema.AtmosConfiguration, stackInfo *schema. func decodeAuthConfigFromStack(stackInfo *schema.ConfigAndStacksInfo) (schema.AuthConfig, error) { var authConfig schema.AuthConfig if err := mapstructure.Decode(stackInfo.ComponentAuthSection, &authConfig); err != nil { - errUtils.CheckErrorAndPrint(errUtils.ErrInvalidAuthConfig, hookOpTerraformPreHook, "failed to decode component auth config - check atmos.yaml or component auth section") - return schema.AuthConfig{}, errUtils.ErrInvalidAuthConfig + return schema.AuthConfig{}, errUtils.Build(errUtils.ErrInvalidAuthConfig). + WithCause(err). + WithExplanation("Failed to decode component auth config"). + WithHint("Check your auth configuration in atmos.yaml or component auth section"). + WithContext("profile", FormatProfile(stackInfo.ProfilesFromArg)). + Err() } return authConfig, nil } @@ -92,12 +106,15 @@ func resolveTargetIdentityName(stackInfo *schema.ConfigAndStacksInfo, authManage // Hooks don't have CLI flags, so never force selection here. name, err := authManager.GetDefaultIdentity(false) if err != nil { - errUtils.CheckErrorAndPrint(errUtils.ErrDefaultIdentity, hookOpTerraformPreHook, "failed to get default identity") - return "", errUtils.ErrDefaultIdentity + // Return error directly - it already has ErrorBuilder context with hints. + return "", err } if name == "" { - errUtils.CheckErrorAndPrint(errUtils.ErrNoDefaultIdentity, hookOpTerraformPreHook, "Use the identity flag or specify an identity as default.") - return "", errUtils.ErrNoDefaultIdentity + return "", errUtils.Build(errUtils.ErrNoDefaultIdentity). + WithExplanation("No default identity is configured for authentication"). + WithHint("Use --identity flag to specify an identity"). + WithHint("Or set default: true on an identity in your auth configuration"). + Err() } return name, nil } @@ -111,7 +128,8 @@ func authenticateAndWriteEnv(ctx context.Context, authManager types.AuthManager, log.Debug("Authenticating with identity", "identity", identityName) whoami, err := authManager.Authenticate(ctx, identityName) if err != nil { - return fmt.Errorf("failed to authenticate with identity %q: %w", identityName, err) + // Return error directly - it already has ErrorBuilder context. + return err } log.Debug("Authentication successful", "identity", whoami.Identity, "expiration", whoami.Expiration) @@ -123,7 +141,8 @@ func authenticateAndWriteEnv(ctx context.Context, authManager types.AuthManager, // This configures file-based credentials (AWS_SHARED_CREDENTIALS_FILE, AWS_PROFILE, etc.). envList, err := authManager.PrepareShellEnvironment(ctx, identityName, baseEnvList) if err != nil { - return fmt.Errorf("failed to prepare environment variables: %w", err) + // Return error directly - it already has ErrorBuilder context. + return err } // Convert back to ComponentEnvSection map for downstream processing. @@ -139,7 +158,13 @@ func authenticateAndWriteEnv(ctx context.Context, authManager types.AuthManager, } if err := utils.PrintAsYAMLToFileDescriptor(atmosConfig, stackInfo.ComponentEnvSection); err != nil { - return fmt.Errorf("failed to print component env section: %w", err) + return errUtils.Build(errUtils.ErrAuthManager). + WithCause(err). + WithExplanation("Failed to print component env section"). + WithHint("This is an internal error - please report at https://github.com/cloudposse/atmos/issues"). + WithContext("profile", FormatProfile(stackInfo.ProfilesFromArg)). + WithContext("identity", identityName). + Err() } return nil } @@ -168,7 +193,12 @@ func newAuthManager(authConfig *schema.AuthConfig, stackInfo *schema.ConfigAndSt stackInfo, ) if err != nil { - return nil, fmt.Errorf("%v: failed to create auth manager: %w", errUtils.ErrAuthManager, err) + return nil, errUtils.Build(errUtils.ErrAuthManager). + WithCause(err). + WithExplanation("Failed to create auth manager"). + WithHint("Check your auth configuration in atmos.yaml"). + WithContext("profile", FormatProfile(stackInfo.ProfilesFromArg)). + Err() } return authManager, nil } diff --git a/pkg/auth/manager.go b/pkg/auth/manager.go index 5bc5307262..58ec0d3d7d 100644 --- a/pkg/auth/manager.go +++ b/pkg/auth/manager.go @@ -126,16 +126,24 @@ func NewAuthManager( // Initialize providers. if err := m.initializeProviders(); err != nil { - wrappedErr := fmt.Errorf("failed to initialize providers: %w", err) - errUtils.CheckErrorAndPrint(wrappedErr, "Initialize Providers", "") - return nil, wrappedErr + return nil, errUtils.Build(errUtils.ErrAuthManager). + WithCause(err). + WithExplanation("Failed to initialize authentication providers"). + WithHint("Check your auth provider configuration in atmos.yaml"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + Err() } // Initialize identities. if err := m.initializeIdentities(); err != nil { - wrappedErr := fmt.Errorf("failed to initialize identities: %w", err) - errUtils.CheckErrorAndPrint(wrappedErr, "Initialize Identities", "") - return nil, wrappedErr + return nil, errUtils.Build(errUtils.ErrAuthManager). + WithCause(err). + WithExplanation("Failed to initialize authentication identities"). + WithHint("Check your auth identity configuration in atmos.yaml"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + Err() } return m, nil @@ -154,15 +162,25 @@ func (m *manager) Authenticate(ctx context.Context, identityName string) (*types // We expect the identity name to be provided by the caller. if identityName == "" { - errUtils.CheckErrorAndPrint(errUtils.ErrNilParam, identityNameKey, "no identity specified") - return nil, fmt.Errorf(errFormatWithString, errUtils.ErrNilParam, identityNameKey) + return nil, errUtils.Build(errUtils.ErrNilParam). + WithExplanation("No identity specified for authentication"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", "(not specified)"). + Err() } // Resolve identity name case-insensitively resolvedName, found := m.resolveIdentityName(identityName) if !found { - errUtils.CheckErrorAndPrint(errUtils.ErrInvalidAuthConfig, identityNameKey, "Identity specified was not found in the auth config.") - return nil, fmt.Errorf(errFormatWithString, errUtils.ErrIdentityNotFound, fmt.Sprintf(backtickedFmt, identityName)) + return nil, errUtils.Build(errUtils.ErrIdentityNotFound). + WithExplanation(fmt.Sprintf("Identity %q not found in the current configuration", identityName)). + WithHint("Run `atmos list identities` to see available identities"). + WithHint("Check that the identity is defined in your auth configuration"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", "(not set)"). + WithContext("identity", identityName). + Err() } // Use the resolved lowercase name for internal lookups identityName = resolvedName @@ -170,9 +188,16 @@ func (m *manager) Authenticate(ctx context.Context, identityName string) (*types // Build the complete authentication chain. chain, err := m.buildAuthenticationChain(identityName) if err != nil { - wrappedErr := fmt.Errorf("failed to build authentication chain for identity %q: %w", identityName, err) - errUtils.CheckErrorAndPrint(wrappedErr, buildAuthenticationChain, "") - return nil, wrappedErr + providerName := m.getProviderForIdentity(identityName) + return nil, errUtils.Build(errUtils.ErrAuthenticationFailed). + WithCause(err). + WithExplanation(fmt.Sprintf("Failed to build authentication chain for identity %q", identityName)). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithHint("Check identity chain configuration for cycles"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", providerName). + WithContext("identity", identityName). + Err() } // Persist the chain for later retrieval by providers or callers. m.chain = chain @@ -181,9 +206,18 @@ func (m *manager) Authenticate(ctx context.Context, identityName string) (*types // Perform credential chain authentication (bottom-up). finalCreds, err := m.authenticateChain(ctx, identityName) if err != nil { - wrappedErr := fmt.Errorf("%w: failed to authenticate via credential chain for identity %q: %w", errUtils.ErrAuthenticationFailed, identityName, err) - errUtils.CheckErrorAndPrint(wrappedErr, "Authenticate Credential Chain", "") - return nil, wrappedErr + providerName := "" + if len(chain) > 0 { + providerName = chain[0] + } + return nil, errUtils.Build(errUtils.ErrAuthenticationFailed). + WithCause(err). + WithExplanation(fmt.Sprintf("Failed to authenticate via credential chain for identity %q", identityName)). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", providerName). + WithContext("identity", identityName). + Err() } // Call post-authentication hook on the identity (now part of Identity interface). @@ -211,9 +245,14 @@ func (m *manager) Authenticate(ctx context.Context, identityName string) (*types Credentials: finalCreds, Manager: m, }); err != nil { - wrappedErr := fmt.Errorf("%w: post-authentication failed: %w", errUtils.ErrAuthenticationFailed, err) - errUtils.CheckErrorAndPrint(wrappedErr, "Post Authenticate", "") - return nil, wrappedErr + return nil, errUtils.Build(errUtils.ErrPostAuthenticationHookFailed). + WithCause(err). + WithExplanation(fmt.Sprintf("Post-authentication hook failed for identity %q", identityName)). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", rootProviderName). + WithContext("identity", identityName). + Err() } } @@ -238,7 +277,12 @@ func (m *manager) AuthenticateProvider(ctx context.Context, providerName string) } if resolvedProviderName == "" { - return nil, fmt.Errorf(errFormatWithString, errUtils.ErrProviderNotFound, fmt.Sprintf(backtickedFmt, providerName)) + return nil, errUtils.Build(errUtils.ErrProviderNotFound). + WithExplanation(fmt.Sprintf("Provider %q not found in configuration", providerName)). + WithHint("Run `atmos list providers` to see available providers"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", providerName). + Err() } // Use resolved name for authentication. @@ -297,7 +341,13 @@ func (m *manager) GetCachedCredentials(ctx context.Context, identityName string) // Resolve identity name case-insensitively resolvedName, found := m.resolveIdentityName(identityName) if !found { - return nil, fmt.Errorf(errFormatWithString, errUtils.ErrIdentityNotFound, fmt.Sprintf(backtickedFmt, identityName)) + return nil, errUtils.Build(errUtils.ErrIdentityNotFound). + WithExplanation(fmt.Sprintf("Identity %q not found in the current configuration", identityName)). + WithHint("Run `atmos list identities` to see available identities"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", "(not set)"). + WithContext("identity", identityName). + Err() } // Use the resolved lowercase name for internal lookups identityName = resolvedName @@ -306,22 +356,40 @@ func (m *manager) GetCachedCredentials(ctx context.Context, identityName string) creds, err := m.loadCredentialsWithFallback(ctx, identityName) if err != nil { // Credentials not found or error occurred. - providerName := "unknown" - if prov, provErr := m.identities[identityName].GetProviderName(); provErr == nil { - providerName = prov + providerName := "(not set)" + if identity, exists := m.identities[identityName]; exists { + if prov, provErr := identity.GetProviderName(); provErr == nil { + providerName = prov + } } - return nil, fmt.Errorf("%w: identity=%s, provider=%s, credential_store=%s: %w", - errUtils.ErrNoCredentialsFound, - identityName, - providerName, - m.credentialStore.Type(), - err) + return nil, errUtils.Build(errUtils.ErrNoCredentialsFound). + WithCause(err). + WithExplanation(fmt.Sprintf("No credentials found for identity %q", identityName)). + WithHint("Run `atmos auth login` to authenticate"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", providerName). + WithContext("identity", identityName). + Err() } // Check if credentials are expired. if creds.IsExpired() { log.Debug("Cached credentials are expired", logKeyIdentity, identityName) - return nil, fmt.Errorf(errFormatWithString, errUtils.ErrExpiredCredentials, fmt.Sprintf(backtickedFmt, identityName)) + providerName := "(not set)" + if identity, exists := m.identities[identityName]; exists { + if prov, provErr := identity.GetProviderName(); provErr == nil { + providerName = prov + } + } + expTime, _ := creds.GetExpiration() + return nil, errUtils.Build(errUtils.ErrExpiredCredentials). + WithExplanation(fmt.Sprintf("Cached credentials for identity %q have expired", identityName)). + WithHint("Run `atmos auth login` to refresh credentials"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", providerName). + WithContext("identity", identityName). + WithContext("expiration", FormatExpiration(expTime)). + Err() } return m.buildWhoamiInfo(identityName, creds), nil @@ -364,7 +432,13 @@ func (m *manager) Validate() error { defer perf.Track(nil, "auth.Manager.Validate")() if err := m.validator.ValidateAuthConfig(m.config); err != nil { - return fmt.Errorf("%w: %w", errUtils.ErrInvalidAuthConfig, err) + return errUtils.Build(errUtils.ErrInvalidAuthConfig). + WithCause(err). + WithExplanation("Auth configuration validation failed"). + WithHint("Check your auth configuration in atmos.yaml"). + WithHint("Run `atmos validate stacks` for detailed validation"). + WithContext("profile", FormatProfile(m.getProfiles())). + Err() } return nil } @@ -379,7 +453,12 @@ func (m *manager) GetDefaultIdentity(forceSelect bool) (string, error) { // Check if we're in interactive mode (have TTY). if !isInteractive() { // User requested interactive selection but we don't have a TTY. - return "", errUtils.ErrIdentitySelectionRequiresTTY + return "", errUtils.Build(errUtils.ErrIdentitySelectionRequiresTTY). + WithExplanation("Interactive identity selection requires a terminal"). + WithHint("Use --identity flag to specify an identity"). + WithHint("Or set a default identity in your auth configuration"). + WithContext("profile", FormatProfile(m.getProfiles())). + Err() } // We have a TTY - show selector. return m.promptForIdentity("Select an identity:", m.ListIdentities()) @@ -398,7 +477,12 @@ func (m *manager) GetDefaultIdentity(forceSelect bool) (string, error) { case 0: // No default identities found. if !isInteractive() { - return "", errUtils.ErrNoDefaultIdentity + return "", errUtils.Build(errUtils.ErrNoDefaultIdentity). + WithExplanation("No default identity is configured for authentication"). + WithHint("Use --identity flag to specify an identity"). + WithHint("Or set default: true on an identity in your auth configuration"). + WithContext("profile", FormatProfile(m.getProfiles())). + Err() } // In interactive mode, prompt user to choose from all identities. return m.promptForIdentity("No default identity configured. Please choose an identity:", m.ListIdentities()) @@ -410,7 +494,13 @@ func (m *manager) GetDefaultIdentity(forceSelect bool) (string, error) { default: // Multiple default identities found. if !isInteractive() { - return "", fmt.Errorf(errFormatWithString, errUtils.ErrMultipleDefaultIdentities, fmt.Sprintf(backtickedFmt, defaultIdentities)) + return "", errUtils.Build(errUtils.ErrMultipleDefaultIdentities). + WithExplanation("Multiple identities are marked as default"). + WithHint("Use --identity flag to specify which identity to use"). + WithHint("Or ensure only one identity has default: true"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("defaults", strings.Join(defaultIdentities, ", ")). + Err() } // In interactive mode, prompt user to choose from default identities. return m.promptForIdentity("Multiple default identities found. Please choose one:", defaultIdentities) @@ -420,7 +510,12 @@ func (m *manager) GetDefaultIdentity(forceSelect bool) (string, error) { // promptForIdentity prompts the user to select an identity from the given list. func (m *manager) promptForIdentity(message string, identities []string) (string, error) { if len(identities) == 0 { - return "", errUtils.ErrNoIdentitiesAvailable + return "", errUtils.Build(errUtils.ErrNoIdentitiesAvailable). + WithExplanation("No identities are configured for authentication"). + WithHint("Add identities to your auth configuration in atmos.yaml"). + WithHint("Run `atmos auth --help` for configuration examples"). + WithContext("profile", FormatProfile(m.getProfiles())). + Err() } // Sort identities alphabetically for consistent ordering. @@ -452,8 +547,13 @@ func (m *manager) promptForIdentity(message string, identities []string) (string if errors.Is(err, huh.ErrUserAborted) { return "", errUtils.ErrUserAborted } - errUtils.CheckErrorAndPrint(err, "Prompt for Identity", "") - return "", fmt.Errorf("%w: %w", errUtils.ErrUnsupportedInputType, err) + return "", errUtils.Build(errUtils.ErrUnsupportedInputType). + WithCause(err). + WithExplanation("Failed to run identity selection prompt"). + WithHint("Ensure you are running in an interactive terminal"). + WithHint("Use --identity flag to specify an identity non-interactively"). + WithContext("profile", FormatProfile(m.getProfiles())). + Err() } return selectedIdentity, nil @@ -497,8 +597,14 @@ func (m *manager) initializeProviders() error { for name, providerConfig := range m.config.Providers { provider, err := factory.NewProvider(name, &providerConfig) if err != nil { - errUtils.CheckErrorAndPrint(err, "Initialize Providers", "") - return fmt.Errorf("%w: provider=%s: %w", errUtils.ErrInvalidProviderConfig, name, err) + return errUtils.Build(errUtils.ErrInvalidProviderConfig). + WithCause(err). + WithExplanation(fmt.Sprintf("Failed to initialize provider %q", name)). + WithHint("Check the provider configuration in your auth settings"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", name). + Err() } m.providers[name] = provider } @@ -510,8 +616,14 @@ func (m *manager) initializeIdentities() error { for name, identityConfig := range m.config.Identities { identity, err := factory.NewIdentity(name, &identityConfig) if err != nil { - errUtils.CheckErrorAndPrint(err, "Initialize Identities", "") - return fmt.Errorf("%w: identity=%s: %w", errUtils.ErrInvalidIdentityConfig, name, err) + return errUtils.Build(errUtils.ErrInvalidIdentityConfig). + WithCause(err). + WithExplanation(fmt.Sprintf("Failed to initialize identity %q", name)). + WithHint("Check the identity configuration in your auth settings"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", name). + Err() } m.identities[name] = identity } @@ -584,14 +696,24 @@ func (m *manager) GetProviderKindForIdentity(identityName string) (string, error // Build the complete authentication chain. chain, err := m.buildAuthenticationChain(identityName) if err != nil { - wrappedErr := fmt.Errorf("failed to get provider kind for identity %q: %w", identityName, err) - errUtils.CheckErrorAndPrint(wrappedErr, buildAuthenticationChain, "") - return "", wrappedErr + return "", errUtils.Build(errUtils.ErrInvalidAuthConfig). + WithCause(err). + WithExplanation(fmt.Sprintf("Failed to get provider kind for identity %q", identityName)). + WithHint("Check the identity configuration and its provider chain"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityName). + Err() } if len(chain) == 0 { - errUtils.CheckErrorAndPrint(errUtils.ErrInvalidAuthConfig, buildAuthenticationChain, "") - return "", fmt.Errorf("%w: empty chain", errUtils.ErrInvalidAuthConfig) + return "", errUtils.Build(errUtils.ErrInvalidAuthConfig). + WithExplanation(fmt.Sprintf("Empty authentication chain for identity %q", identityName)). + WithHint("Check the identity configuration has a valid provider chain"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityName). + Err() } // The first element in the chain is the root provider name. @@ -606,8 +728,14 @@ func (m *manager) GetProviderKindForIdentity(identityName string) (string, error return identity.Kind, nil } - errUtils.CheckErrorAndPrint(errUtils.ErrInvalidAuthConfig, "GetProviderKindForIdentity", fmt.Sprintf("provider %q not found in configuration", providerName)) - return "", fmt.Errorf("%w: provider %q not found in configuration", errUtils.ErrInvalidAuthConfig, providerName) + return "", errUtils.Build(errUtils.ErrProviderNotFound). + WithExplanation(fmt.Sprintf("Provider %q from chain not found in configuration", providerName)). + WithHint("Check that the provider is defined in your auth configuration"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", providerName). + WithContext("identity", identityName). + Err() } // ensureIdentityHasManager ensures the identity has the authentication chain context. @@ -654,7 +782,14 @@ func (m *manager) ensureIdentityHasManager(identityName string) error { // Build the authentication chain so GetProviderForIdentity() can resolve the root provider. chain, err := m.buildAuthenticationChain(identityName) if err != nil { - return fmt.Errorf("failed to build authentication chain: %w", err) + return errUtils.Build(errUtils.ErrInvalidAuthConfig). + WithCause(err). + WithExplanation(fmt.Sprintf("Failed to build authentication chain for identity %q", identityName)). + WithHint("Check the identity configuration and its provider chain"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityName). + Err() } // Store the chain in the manager so GetProviderForIdentity() can use it. @@ -828,8 +963,14 @@ func (m *manager) authenticateProviderChain(ctx context.Context, startIndex int) // Allow provider to inspect the chain and prepare pre-auth preferences. if provider, exists := m.providers[m.chain[0]]; exists { if err := provider.PreAuthenticate(m); err != nil { - errUtils.CheckErrorAndPrint(err, "Pre Authenticate", "") - return nil, fmt.Errorf("%w: provider=%s: %w", errUtils.ErrAuthenticationFailed, m.chain[0], err) + return nil, errUtils.Build(errUtils.ErrAuthenticationFailed). + WithCause(err). + WithExplanation(fmt.Sprintf("Pre-authentication failed for provider %q", m.chain[0])). + WithHint("Check your provider configuration"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", m.chain[0]). + Err() } } currentCreds, err = m.authenticateWithProvider(ctx, m.chain[0]) @@ -914,7 +1055,14 @@ func (m *manager) loadCredentialsWithFallback(ctx context.Context, identityName // If keyring returned an error other than "not found", propagate it. if !errors.Is(keyringErr, credentials.ErrCredentialsNotFound) { - return nil, fmt.Errorf("keyring error for identity %q: %w", identityName, keyringErr) + return nil, errUtils.Build(errUtils.ErrAuthManager). + WithCause(keyringErr). + WithExplanation(fmt.Sprintf("Keyring error while retrieving credentials for identity %q", identityName)). + WithHint("Check your system keyring configuration"). + WithHint("Run `atmos auth login` to re-authenticate"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityName). + Err() } // Slow path: Fall back to identity storage (AWS files, etc.). @@ -924,7 +1072,13 @@ func (m *manager) loadCredentialsWithFallback(ctx context.Context, identityName identity, exists := m.identities[identityName] if !exists { - return nil, fmt.Errorf("%w: %s", errUtils.ErrIdentityNotInConfig, identityName) + return nil, errUtils.Build(errUtils.ErrIdentityNotInConfig). + WithExplanation(fmt.Sprintf("Identity %q not found in configuration", identityName)). + WithHint("Run `atmos list identities` to see available identities"). + WithHint("Check your auth configuration in atmos.yaml"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityName). + Err() } // Ensure the identity has access to manager for resolving provider information. @@ -937,11 +1091,24 @@ func (m *manager) loadCredentialsWithFallback(ctx context.Context, identityName // Each identity type knows how to load its own credentials from storage. loadedCreds, loadErr := identity.LoadCredentials(ctx) if loadErr != nil { - return nil, fmt.Errorf("failed to load credentials from identity storage for %q: %w", identityName, loadErr) + return nil, errUtils.Build(errUtils.ErrNoCredentialsFound). + WithCause(loadErr). + WithExplanation(fmt.Sprintf("Failed to load credentials from identity storage for %q", identityName)). + WithHint("Run `atmos auth login` to authenticate"). + WithHint("Check that your credentials are valid and not expired"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityName). + Err() } if loadedCreds == nil { - return nil, fmt.Errorf("%w: credentials loaded from storage are nil for identity %q", errUtils.ErrNoCredentialsFound, identityName) + return nil, errUtils.Build(errUtils.ErrNoCredentialsFound). + WithExplanation(fmt.Sprintf("No credentials found in storage for identity %q", identityName)). + WithHint("Run `atmos auth login` to authenticate"). + WithHint("Check that your credentials are valid and not expired"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityName). + Err() } log.Debug("Successfully loaded credentials from identity storage", logKeyIdentity, identityName) @@ -992,16 +1159,26 @@ func (m *manager) getChainCredentials(chain []string, startIndex int) (types.ICr func (m *manager) authenticateWithProvider(ctx context.Context, providerName string) (types.ICredentials, error) { provider, exists := m.providers[providerName] if !exists { - wrappedErr := fmt.Errorf("provider %q not registered: %w", providerName, errUtils.ErrInvalidAuthConfig) - errUtils.CheckErrorAndPrint(wrappedErr, "Authenticate with Provider", "") - return nil, wrappedErr + return nil, errUtils.Build(errUtils.ErrProviderNotFound). + WithExplanation(fmt.Sprintf("Provider %q is not registered in the current configuration", providerName)). + WithHint("Run `atmos list providers` to see available providers"). + WithHint("Check that the provider is defined in your auth configuration"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", providerName). + Err() } log.Debug("Authenticating with provider", "provider", providerName) credentials, err := provider.Authenticate(ctx) if err != nil { - errUtils.CheckErrorAndPrint(err, "Authenticate with Provider", "") - return nil, fmt.Errorf("%w: provider=%s: %w", errUtils.ErrAuthenticationFailed, providerName, err) + return nil, errUtils.Build(errUtils.ErrAuthenticationFailed). + WithCause(err). + WithExplanation(fmt.Sprintf("Failed to authenticate with provider %q", providerName)). + WithHint("Check your provider configuration and credentials"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", providerName). + Err() } // Cache provider credentials, but skip session tokens. @@ -1118,12 +1295,24 @@ func (m *manager) writeProvisionedIdentities(result *types.ProvisioningResult) e writer, err := types.NewProvisioningWriter() if err != nil { - return fmt.Errorf("failed to create provisioning writer: %w", err) + return errUtils.Build(errUtils.ErrAuthManager). + WithCause(err). + WithExplanation("Failed to create provisioning writer"). + WithHint("Check that the cache directory is writable"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + Err() } filePath, err := writer.Write(result) if err != nil { - return fmt.Errorf("failed to write provisioned identities: %w", err) + return errUtils.Build(errUtils.ErrAuthManager). + WithCause(err). + WithExplanation("Failed to write provisioned identities to cache"). + WithHint("Check that the cache directory is writable"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + Err() } log.Debug("Wrote provisioned identities to cache", "path", filePath) @@ -1160,9 +1349,13 @@ func (m *manager) authenticateIdentityChain(ctx context.Context, startIndex int, identityStep := m.chain[i] identity, exists := m.identities[identityStep] if !exists { - wrappedErr := fmt.Errorf("%w: identity %q not found in chain step %d", errUtils.ErrInvalidAuthConfig, identityStep, i) - errUtils.CheckErrorAndPrint(wrappedErr, "Authenticate Identity Chain", "") - return nil, wrappedErr + return nil, errUtils.Build(errUtils.ErrIdentityNotFound). + WithExplanation(fmt.Sprintf("Identity %q not found in chain step %d", identityStep, i)). + WithHint("Check your identity chain configuration"). + WithHint("Run `atmos list identities` to see available identities"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityStep). + Err() } log.Debug("Authenticating identity step", "step", i, logKeyIdentity, identityStep, "kind", identity.Kind()) @@ -1170,7 +1363,20 @@ func (m *manager) authenticateIdentityChain(ctx context.Context, startIndex int, // Each identity receives credentials from the previous step. nextCreds, err := identity.Authenticate(ctx, currentCreds) if err != nil { - return nil, fmt.Errorf("%w: identity=%s step=%d: %w", errUtils.ErrAuthenticationFailed, identityStep, i, err) + // Get provider name from chain if available. + providerName := "" + if len(m.chain) > 0 { + providerName = m.chain[0] + } + return nil, errUtils.Build(errUtils.ErrAuthenticationFailed). + WithCause(err). + WithExplanation(fmt.Sprintf("Failed to authenticate identity %q at step %d", identityStep, i)). + WithHint("Check your identity configuration and credentials"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", providerName). + WithContext("identity", identityStep). + Err() } currentCreds = nextCreds @@ -1205,9 +1411,14 @@ func (m *manager) buildAuthenticationChain(identityName string) ([]string, error // Recursively build the chain. err := m.buildChainRecursive(identityName, &chain, visited) if err != nil { - wrappedErr := fmt.Errorf("failed to build authentication chain for identity %q: %w", identityName, err) - errUtils.CheckErrorAndPrint(wrappedErr, buildAuthenticationChain, "") - return nil, wrappedErr + return nil, errUtils.Build(errUtils.ErrInvalidAuthConfig). + WithCause(err). + WithExplanation(fmt.Sprintf("Failed to build authentication chain for identity %q", identityName)). + WithHint("Check the identity configuration and its provider chain"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityName). + Err() } // Reverse the chain so provider is first, then identities in authentication order. @@ -1223,8 +1434,13 @@ func (m *manager) buildAuthenticationChain(identityName string) ([]string, error func (m *manager) buildChainRecursive(identityName string, chain *[]string, visited map[string]bool) error { // Check for circular dependencies. if visited[identityName] { - errUtils.CheckErrorAndPrint(errUtils.ErrCircularDependency, buildChainRecursive, fmt.Sprintf("circular dependency detected in identity chain involving %q", identityName)) - return fmt.Errorf("%w: circular dependency detected in identity chain involving %q", errUtils.ErrCircularDependency, identityName) + return errUtils.Build(errUtils.ErrCircularDependency). + WithExplanation(fmt.Sprintf("Circular dependency detected in identity chain involving %q", identityName)). + WithHint("Check that your identity chain does not have circular references"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityName). + Err() } visited[identityName] = true @@ -1232,8 +1448,13 @@ func (m *manager) buildChainRecursive(identityName string, chain *[]string, visi identity, exists := m.config.Identities[identityName] if !exists { - errUtils.CheckErrorAndPrint(errUtils.ErrInvalidAuthConfig, buildChainRecursive, fmt.Sprintf("identity %q not found", identityName)) - return fmt.Errorf("%w: identity %q not found", errUtils.ErrInvalidAuthConfig, identityName) + return errUtils.Build(errUtils.ErrIdentityNotFound). + WithExplanation(fmt.Sprintf("Identity %q not found in configuration", identityName)). + WithHint("Run `atmos list identities` to see available identities"). + WithHint("Check your auth configuration in atmos.yaml"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityName). + Err() } // AWS User identities don't require via configuration - they are standalone. @@ -1243,8 +1464,13 @@ func (m *manager) buildChainRecursive(identityName string, chain *[]string, visi *chain = append(*chain, identityName) return nil } - errUtils.CheckErrorAndPrint(errUtils.ErrInvalidIdentityConfig, buildChainRecursive, fmt.Sprintf("identity %q has no via configuration", identityName)) - return fmt.Errorf("%w: identity %q has no via configuration", errUtils.ErrInvalidIdentityConfig, identityName) + return errUtils.Build(errUtils.ErrInvalidIdentityConfig). + WithExplanation(fmt.Sprintf("Identity %q has no via configuration", identityName)). + WithHint("Non-user identities must have a via.provider or via.identity configured"). + WithHint("Check the identity configuration in atmos.yaml"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityName). + Err() } // Add current identity to chain. @@ -1261,8 +1487,13 @@ func (m *manager) buildChainRecursive(identityName string, chain *[]string, visi return m.buildChainRecursive(identity.Via.Identity, chain, visited) } - errUtils.CheckErrorAndPrint(errUtils.ErrInvalidIdentityConfig, buildChainRecursive, fmt.Sprintf("identity %q has invalid via configuration", identityName)) - return fmt.Errorf("%w: identity %q has invalid via configuration", errUtils.ErrInvalidIdentityConfig, identityName) + return errUtils.Build(errUtils.ErrInvalidIdentityConfig). + WithExplanation(fmt.Sprintf("Identity %q has invalid via configuration", identityName)). + WithHint("Via configuration must specify either provider or identity"). + WithHint("Check the identity configuration in atmos.yaml"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityName). + Err() } // GetEnvironmentVariables returns the environment variables for an identity @@ -1273,7 +1504,13 @@ func (m *manager) GetEnvironmentVariables(identityName string) (map[string]strin // Verify identity exists. identity, exists := m.identities[identityName] if !exists { - return nil, fmt.Errorf(errFormatWithString, errUtils.ErrIdentityNotFound, fmt.Sprintf(backtickedFmt, identityName)) + return nil, errUtils.Build(errUtils.ErrIdentityNotFound). + WithExplanation(fmt.Sprintf("Identity %q not found in the current configuration", identityName)). + WithHint("Run `atmos list identities` to see available identities"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", "(not set)"). + WithContext("identity", identityName). + Err() } // Ensure the identity has access to manager for resolving provider information. @@ -1285,7 +1522,14 @@ func (m *manager) GetEnvironmentVariables(identityName string) (map[string]strin // Get environment variables from the identity. env, err := identity.Environment() if err != nil { - return nil, fmt.Errorf("%w: failed to get environment variables: %w", errUtils.ErrAuthManager, err) + return nil, errUtils.Build(errUtils.ErrAuthManager). + WithCause(err). + WithExplanation(fmt.Sprintf("Failed to get environment variables for identity %q", identityName)). + WithHint("Check the identity configuration"). + WithHint("Run `atmos auth --help` for troubleshooting"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityName). + Err() } return env, nil @@ -1300,7 +1544,13 @@ func (m *manager) PrepareShellEnvironment(ctx context.Context, identityName stri // Verify identity exists. identity, exists := m.identities[identityName] if !exists { - return nil, fmt.Errorf(errFormatWithString, errUtils.ErrIdentityNotFound, fmt.Sprintf(backtickedFmt, identityName)) + return nil, errUtils.Build(errUtils.ErrIdentityNotFound). + WithExplanation(fmt.Sprintf("Identity %q not found in the current configuration", identityName)). + WithHint("Run `atmos list identities` to see available identities"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", "(not set)"). + WithContext("identity", identityName). + Err() } // Ensure the identity has access to manager for resolving provider information. @@ -1314,7 +1564,14 @@ func (m *manager) PrepareShellEnvironment(ctx context.Context, identityName stri // This is provider-specific (AWS sets AWS_SHARED_CREDENTIALS_FILE, AWS_PROFILE, etc.). preparedEnvMap, err := identity.PrepareEnvironment(ctx, envMap) if err != nil { - return nil, fmt.Errorf("failed to prepare shell environment for identity %q: %w", identityName, err) + return nil, errUtils.Build(errUtils.ErrAuthManager). + WithCause(err). + WithExplanation(fmt.Sprintf("Failed to prepare shell environment for identity %q", identityName)). + WithHint("Check the identity configuration and credentials"). + WithHint("Run `atmos auth login` to re-authenticate"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityName). + Err() } // Convert map back to list for subprocess execution. @@ -1346,3 +1603,12 @@ func mapToEnvironList(envMap map[string]string) []string { } return envList } + +// getProfiles returns the current profiles from stackInfo for error context. +// Returns nil if stackInfo is not set or has no profiles. +func (m *manager) getProfiles() []string { + if m.stackInfo == nil { + return nil + } + return m.stackInfo.ProfilesFromArg +} diff --git a/pkg/auth/manager_extended_test.go b/pkg/auth/manager_extended_test.go index 2306650740..8daf522a5e 100644 --- a/pkg/auth/manager_extended_test.go +++ b/pkg/auth/manager_extended_test.go @@ -488,6 +488,8 @@ func TestManager_GetCachedCredentials_ExpiredCredentials(t *testing.T) { } mockStore.EXPECT().Retrieve("identity1").Return(expiredCreds, nil) + // GetProviderName is called when building error context. + mockIdentity.EXPECT().GetProviderName().Return("aws-sso", nil) _, err := m.GetCachedCredentials(context.Background(), "identity1") assert.Error(t, err) diff --git a/pkg/auth/manager_helpers.go b/pkg/auth/manager_helpers.go index c576ee2e85..5ca5f85e36 100644 --- a/pkg/auth/manager_helpers.go +++ b/pkg/auth/manager_helpers.go @@ -3,7 +3,6 @@ package auth import ( "context" "errors" - "fmt" errUtils "github.com/cloudposse/atmos/errors" "github.com/cloudposse/atmos/pkg/auth/credentials" @@ -251,7 +250,13 @@ func CreateAndAuthenticateManagerWithAtmosConfig( // Validate auth is configured when we have an identity to use. if !isAuthConfigured(authConfig) { - return nil, fmt.Errorf("%w: authentication requires at least one identity configured in atmos.yaml", errUtils.ErrAuthNotConfigured) + return nil, errUtils.Build(errUtils.ErrAuthNotConfigured). + WithExplanation("Authentication requires at least one identity configured"). + WithHint("Add identity configuration to your atmos.yaml file"). + WithHint("Run `atmos auth --help` for configuration examples"). + WithContext("profile", FormatProfile(nil)). + WithContext("identity", resolvedIdentity). + Err() } // Create AuthManager instance. diff --git a/pkg/auth/manager_helpers_test.go b/pkg/auth/manager_helpers_test.go index 73ff21dfe9..048d2ab3f2 100644 --- a/pkg/auth/manager_helpers_test.go +++ b/pkg/auth/manager_helpers_test.go @@ -336,18 +336,16 @@ func TestCreateAndAuthenticateManager_ErrorMessageClarity(t *testing.T) { // Test that error messages are clear and helpful. tests := []struct { - name string - identityName string - authConfig *schema.AuthConfig - selectValue string - expectedError string + name string + identityName string + authConfig *schema.AuthConfig + selectValue string }{ { - name: "nil config error mentions requirement", - identityName: "test", - authConfig: nil, - selectValue: "__SELECT__", - expectedError: "authentication requires at least one identity", + name: "nil config error mentions requirement", + identityName: "test", + authConfig: nil, + selectValue: "__SELECT__", }, { name: "empty identities error mentions requirement", @@ -355,8 +353,7 @@ func TestCreateAndAuthenticateManager_ErrorMessageClarity(t *testing.T) { authConfig: &schema.AuthConfig{ Identities: map[string]schema.Identity{}, }, - selectValue: "__SELECT__", - expectedError: "authentication requires at least one identity", + selectValue: "__SELECT__", }, } @@ -365,7 +362,8 @@ func TestCreateAndAuthenticateManager_ErrorMessageClarity(t *testing.T) { manager, err := CreateAndAuthenticateManager(tt.identityName, tt.authConfig, tt.selectValue) require.Error(t, err) - assert.Contains(t, err.Error(), tt.expectedError, "error message should be clear") + // Use errors.Is to check for the sentinel error. + assert.ErrorIs(t, err, errUtils.ErrAuthNotConfigured) assert.Nil(t, manager) }) } diff --git a/pkg/auth/manager_logout.go b/pkg/auth/manager_logout.go index d9fd58e2ee..dfa4b77f74 100644 --- a/pkg/auth/manager_logout.go +++ b/pkg/auth/manager_logout.go @@ -20,7 +20,13 @@ func (m *manager) Logout(ctx context.Context, identityName string, deleteKeychai // Validate identity exists in configuration. identity, exists := m.identities[identityName] if !exists { - return fmt.Errorf("%w: identity %q", errUtils.ErrIdentityNotInConfig, identityName) + return errUtils.Build(errUtils.ErrIdentityNotInConfig). + WithExplanation(fmt.Sprintf("Identity %q not found in configuration", identityName)). + WithHint("Run `atmos list identities` to see available identities"). + WithHint("Check your auth configuration in atmos.yaml"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("identity", identityName). + Err() } log.Debug("Logout identity", logKeyIdentity, identityName, "deleteKeychain", deleteKeychain) @@ -111,7 +117,13 @@ func (m *manager) LogoutProvider(ctx context.Context, providerName string, delet // Validate provider exists in configuration. provider, exists := m.providers[providerName] if !exists { - return fmt.Errorf("%w: provider %q", errUtils.ErrProviderNotInConfig, providerName) + return errUtils.Build(errUtils.ErrProviderNotInConfig). + WithExplanation(fmt.Sprintf("Provider %q not found in configuration", providerName)). + WithHint("Run `atmos list providers` to see available providers"). + WithHint("Check your auth configuration in atmos.yaml"). + WithContext("profile", FormatProfile(m.getProfiles())). + WithContext("provider", providerName). + Err() } log.Debug("Logout provider", logKeyProvider, providerName, "deleteKeychain", deleteKeychain) diff --git a/pkg/auth/manager_test.go b/pkg/auth/manager_test.go index a482b45eba..18906d6af4 100644 --- a/pkg/auth/manager_test.go +++ b/pkg/auth/manager_test.go @@ -5,9 +5,11 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" + ckerrors "github.com/cockroachdb/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -164,14 +166,37 @@ func TestManager_GetDefaultIdentity_MultipleDefaultsOrder(t *testing.T) { _, err := manager.GetDefaultIdentity(false) require.Error(t, err) - // The error should contain all three default identities. - errorMsg := err.Error() - assert.Contains(t, errorMsg, "multiple default identities found:") - assert.Contains(t, errorMsg, "alpha") - assert.Contains(t, errorMsg, "charlie") - assert.Contains(t, errorMsg, "zebra") + // Should be the correct sentinel error. + assert.ErrorIs(t, err, errUtils.ErrMultipleDefaultIdentities) + + // The error context should contain all three default identities in the "defaults" key. + // Context is stored in SafeDetails within the error chain. + // Use GetAllSafeDetails to get all layers of the error chain. + allDetails := ckerrors.GetAllSafeDetails(err) + require.NotEmpty(t, allDetails, "error should have safe details with context") + + // Find the safe details layer that contains our context. + // Format is: "defaults=charlie, zebra, alpha profile=(not set)". + var contextStr string + for _, layer := range allDetails { + for _, detail := range layer.SafeDetails { + if strings.Contains(detail, "defaults=") { + contextStr = detail + break + } + } + if contextStr != "" { + break + } + } + + require.NotEmpty(t, contextStr, "error should have defaults context") + // The context string should contain all default identities. + assert.Contains(t, contextStr, "alpha") + assert.Contains(t, contextStr, "charlie") + assert.Contains(t, contextStr, "zebra") // Should not contain the non-default identity. - assert.NotContains(t, errorMsg, "beta") + assert.NotContains(t, contextStr, "beta") } func TestManager_ListIdentities(t *testing.T) { diff --git a/tests/snapshots/TestCLICommands_atmos_auth_whoami_--identity_nonexistent.stderr.golden b/tests/snapshots/TestCLICommands_atmos_auth_whoami_--identity_nonexistent.stderr.golden index 3c431bdb82..707a89ed62 100644 --- a/tests/snapshots/TestCLICommands_atmos_auth_whoami_--identity_nonexistent.stderr.golden +++ b/tests/snapshots/TestCLICommands_atmos_auth_whoami_--identity_nonexistent.stderr.golden @@ -1,15 +1,12 @@ **Notice:** Telemetry Enabled - Atmos now collects anonymous telemetry regarding usage. This information is used to shape the Atmos roadmap and prioritize features. You can learn more, including how to opt out if you'd prefer not to participate in this anonymous program, by visiting: https://atmos.tools/cli/telemetry -# identityName - -**Error:** invalid auth config - -## Hints - -💡 Identity specified was not found in the auth config. # Error **Error:** identity not found ## Explanation -nonexistent +Identity "nonexistent" not found in the current configuration + +## Hints + +💡 Run atmos list identities to see available identities