Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ linters:
- pattern: ^path\.Join$
msg: Use `filepath.Join` for OS-appropriate path separators instead of `path.Join` (which always uses forward slashes)
- pattern: term\.IsTerminal\(
msg: Use `term.IsTTYSupportForStdout()` or `term.IsTTYSupportForStderr()` instead of `term.IsTerminal()` for consistent and mockable TTY detection
msg: Use `term.IsTTYSupportForStdout()`, `term.IsTTYSupportForStderr()`, or `term.IsTTYSupportForStdin()` instead of `term.IsTerminal()` for consistent and mockable TTY detection
exclude-godoc-examples: false
analyze-types: true
funlen:
Expand Down
8 changes: 6 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@ repos:
files: ^\.golangci\.yml$
pass_filenames: false

# IMPORTANT: Do NOT build custom-gcl automatically in this hook!
# Building during pre-commit can cause git corruption in worktrees.
# Users should run `make custom-gcl` once before committing.
# The run script will provide helpful error if binary is missing.
- id: golangci-lint
name: golangci-lint
description: Run golangci-lint on modified Go files (uses custom binary with lintroller)
entry: sh -c 'make custom-gcl && ./custom-gcl run --new-from-rev=origin/main --config=.golangci.yml'
description: Run golangci-lint on modified Go files (requires pre-built custom-gcl binary)
entry: scripts/run-custom-golangci-lint.sh
language: system
types: [go]
pass_filenames: false
Expand Down
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ lint: get lintroller custom-gcl
# Build custom golangci-lint binary with lintroller plugin.
# Uses a temporary directory to prevent git corruption during pre-commit hooks
custom-gcl: tools/lintroller/.lintroller .custom-gcl.yml
@./scripts/build-custom-golangci-lint.sh
@echo "Building custom golangci-lint binary with lintroller plugin..."
@GOFLAGS="-buildvcs=false" golangci-lint custom
@echo "Custom golangci-lint binary built successfully: ./custom-gcl"

# Custom linter for Atmos-specific rules (t.Setenv misuse, os.Setenv in tests, os.MkdirTemp in tests).
.PHONY: lintroller
Expand Down
16 changes: 15 additions & 1 deletion internal/tui/templates/term/mock_term_writer.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions internal/tui/templates/term/term_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type TTYDetector interface {
IsTTYForStdout() bool
// IsTTYForStderr checks if stderr supports TTY.
IsTTYForStderr() bool
// IsTTYForStdin checks if stdin supports TTY (interactive input).
IsTTYForStdin() bool
}

// DefaultTTYDetector implements TTYDetector using the actual terminal checks.
Expand All @@ -34,6 +36,12 @@ func (d *DefaultTTYDetector) IsTTYForStderr() bool {
return term.IsTerminal(fd)
}

// IsTTYForStdin checks if stdin supports TTY (interactive input).
func (d *DefaultTTYDetector) IsTTYForStdin() bool {
fd := int(os.Stdin.Fd())
return term.IsTerminal(fd)
}

// defaultDetector is the global instance used by package-level functions.
var defaultDetector TTYDetector = &DefaultTTYDetector{}

Expand Down Expand Up @@ -119,3 +127,9 @@ func IsTTYSupportForStdout() bool {
func IsTTYSupportForStderr() bool {
return defaultDetector.IsTTYForStderr()
}

// IsTTYSupportForStdin checks if stdin supports TTY for accepting interactive input.
// This is a convenience function that uses the default TTY detector.
func IsTTYSupportForStdin() bool {
return defaultDetector.IsTTYForStdin()
}
12 changes: 9 additions & 3 deletions pkg/auth/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ import (
"github.com/charmbracelet/huh"

errUtils "github.com/cloudposse/atmos/errors"
"github.com/cloudposse/atmos/internal/tui/templates/term"
"github.com/cloudposse/atmos/pkg/auth/factory"
"github.com/cloudposse/atmos/pkg/auth/identities/aws"
"github.com/cloudposse/atmos/pkg/auth/types"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/telemetry"
)

const (
Expand All @@ -27,6 +27,12 @@ const (
errFormatWithString = "%w: %s"
)

// isInteractive checks if we're running in an interactive terminal (has stdin TTY).
// This is used to determine if we can prompt the user for input.
func isInteractive() bool {
return term.IsTTYSupportForStdin()
}

// manager implements the AuthManager interface.
type manager struct {
config *schema.AuthConfig
Expand Down Expand Up @@ -194,7 +200,7 @@ func (m *manager) GetDefaultIdentity() (string, error) {
switch len(defaultIdentities) {
case 0:
// No default identities found.
if telemetry.IsCI() {
if !isInteractive() {
return "", errUtils.ErrNoDefaultIdentity
}
// In interactive mode, prompt user to choose from all identities.
Expand All @@ -206,7 +212,7 @@ func (m *manager) GetDefaultIdentity() (string, error) {

default:
// Multiple default identities found.
if telemetry.IsCI() {
if !isInteractive() {
return "", fmt.Errorf(errFormatWithString, errUtils.ErrMultipleDefaultIdentities, fmt.Sprintf(backtickedQuotedFmt, defaultIdentities))
}
// In interactive mode, prompt user to choose from default identities.
Expand Down
44 changes: 37 additions & 7 deletions pkg/auth/providers/aws/sso.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/ssooidc/types"

errUtils "github.com/cloudposse/atmos/errors"
"github.com/cloudposse/atmos/internal/tui/templates/term"
awsCloud "github.com/cloudposse/atmos/pkg/auth/cloud/aws"
authTypes "github.com/cloudposse/atmos/pkg/auth/types"
log "github.com/cloudposse/atmos/pkg/logger"
Expand All @@ -24,6 +25,13 @@ const (
ssoDefaultSessionMinutes = 60
)

// isInteractive checks if we're running in an interactive terminal.
// For SSO device flow, we need stderr to be a TTY so the user can see the authentication URL.
// We check stderr (not stdin) because that's where we output the authentication instructions.
func isInteractive() bool {
return term.IsTTYSupportForStderr()
}

// ssoProvider implements AWS IAM Identity Center authentication.
type ssoProvider struct {
name string
Expand Down Expand Up @@ -77,26 +85,37 @@ func (p *ssoProvider) Authenticate(ctx context.Context) (authTypes.ICredentials,
// Note: SSO provider no longer caches credentials directly.
// Caching is handled at the manager level to prevent duplicates.

// Check if we're in a headless environment - SSO device flow requires user interaction.
if !isInteractive() {
return nil, fmt.Errorf("%w: SSO device flow requires an interactive terminal (no TTY detected). Use environment credentials or service account authentication in headless environments", errUtils.ErrAuthenticationFailed)
}

// Build config options.
configOpts := []func(*config.LoadOptions) error{
config.WithRegion(p.region),
// Disable credential providers to avoid hanging on EC2 metadata service or other credential sources.
// SSO device flow doesn't require existing credentials.
config.WithCredentialsProvider(aws.AnonymousCredentials{}),
}

// Add custom endpoint resolver if configured.
if resolverOpt := awsCloud.GetResolverConfigOption(nil, p.config); resolverOpt != nil {
configOpts = append(configOpts, resolverOpt)
}

log.Debug("Loading AWS config for SSO authentication", "region", p.region)
// Initialize AWS config for the SSO region with isolated environment
// to avoid conflicts with external AWS env vars.
cfg, err := awsCloud.LoadIsolatedAWSConfig(ctx, configOpts...)
if err != nil {
return nil, fmt.Errorf("%w: failed to load AWS config: %v", errUtils.ErrAuthenticationFailed, err)
}
log.Debug("AWS config loaded successfully")

// Create OIDC client for device authorization.
oidcClient := ssooidc.NewFromConfig(cfg)

log.Debug("Registering SSO client")
// Register the client.
registerResp, err := oidcClient.RegisterClient(ctx, &ssooidc.RegisterClientInput{
ClientName: aws.String("atmos-auth"),
Expand All @@ -105,7 +124,9 @@ func (p *ssoProvider) Authenticate(ctx context.Context) (authTypes.ICredentials,
if err != nil {
return nil, fmt.Errorf("%w: failed to register SSO client: %v", errUtils.ErrAuthenticationFailed, err)
}
log.Debug("SSO client registered successfully")

log.Debug("Starting device authorization")
// Start device authorization.
authResp, err := oidcClient.StartDeviceAuthorization(ctx, &ssooidc.StartDeviceAuthorizationInput{
ClientId: registerResp.ClientId,
Expand All @@ -115,6 +136,7 @@ func (p *ssoProvider) Authenticate(ctx context.Context) (authTypes.ICredentials,
if err != nil {
return nil, fmt.Errorf("%w: failed to start device authorization: %v", errUtils.ErrAuthenticationFailed, err)
}
log.Debug("Device authorization started")

p.promptDeviceAuth(authResp)
// Poll for token using helper to keep function size small.
Expand All @@ -138,20 +160,28 @@ func (p *ssoProvider) Authenticate(ctx context.Context) (authTypes.ICredentials,
}, nil
}

// promptDeviceAuth displays user code and verification URI if not in CI.
// promptDeviceAuth displays user code and verification URI.
// Shows the prompt unless we're in a non-interactive environment (real CI without TTY).
func (p *ssoProvider) promptDeviceAuth(authResp *ssooidc.StartDeviceAuthorizationOutput) {
code := ""
if authResp.UserCode != nil {
code = *authResp.UserCode
}
if !telemetry.IsCI() {
if authResp.VerificationUriComplete != nil && *authResp.VerificationUriComplete != "" {
if err := utils.OpenUrl(*authResp.VerificationUriComplete); err != nil {
log.Debug(err)
utils.PrintfMessageToTUI("🔐 Please visit %s and enter code: %s.", *authResp.VerificationUriComplete, code)
}

// Always show the prompt - even if CI env vars are set, the user might be running
// make locally. The browser open will work if there's a display available.
if authResp.VerificationUriComplete != nil && *authResp.VerificationUriComplete != "" {
// Always print the message so users know authentication is required.
log.Debug("Displaying authentication prompt", "url", *authResp.VerificationUriComplete, "code", code, "isCI", telemetry.IsCI())
utils.PrintfMessageToTUI("🔐 Authenticating via browser. Please visit %s and verify code: %s\n", *authResp.VerificationUriComplete, code)

if err := utils.OpenUrl(*authResp.VerificationUriComplete); err != nil {
log.Debug("Failed to open browser automatically", "error", err)
} else {
log.Debug("Browser opened successfully")
}
}
log.Debug("Finished promptDeviceAuth, starting polling")
}

// Validate validates the provider configuration.
Expand Down
60 changes: 55 additions & 5 deletions pkg/telemetry/ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ package telemetry
import (
"os"
"sort"
"strings"

log "github.com/cloudposse/atmos/pkg/logger"
)

const (
ciEnvVar = "CI"
ciEnvVar = "CI"
logKeyProvider = "provider"
)

var (
Expand All @@ -32,14 +36,19 @@ var (
"GOOGLE_CLOUD_BUILD": "BUILDER_OUTPUT",
"HARNESS": "HARNESS_BUILD_ID",
"HUDSON": "HUDSON_URL",
"JENKINS": "JENKINS_URL", // "JENKINS_URL" and "BUILD_ID"
"PROW": "PROW_JOB_ID",
"SEMAPHORE": "SEMAPHORE",
"TEAMCITY": "TEAMCITY_VERSION",
"TRAVIS": "TRAVIS",
"SPACELIFT": "TF_VAR_spacelift_run_id",
}

// Map of CI providers that require ALL listed environment variables to exist.
// Jenkins requires both JENKINS_URL and BUILD_ID to avoid false positives from build-harness.
ciProvidersEnvVarsAllExist = map[string][]string{
"JENKINS": {"JENKINS_URL", "BUILD_ID"},
}

// Map of CI providers that can be detected by checking if environment variables equal specific values.
ciProvidersEnvVarsEquals = map[string]map[string]string{
"CODESHIP": {
Expand Down Expand Up @@ -70,7 +79,10 @@ func isEnvVarTrue(key string) bool {
// IsCI determines if the current environment is a CI/CD environment.
// Returns true if CI=true or if a specific CI provider is detected.
func IsCI() bool {
return isEnvVarTrue(ciEnvVar) || ciProvider() != ""
ciEnvTrue := isEnvVarTrue(ciEnvVar)
provider := ciProvider()

return ciEnvTrue || provider != ""
}

// PreserveCIEnvVars temporarily removes CI-related environment variables from the current process
Expand Down Expand Up @@ -151,9 +163,36 @@ func applyAlphabeticalOrder[V string | map[string]string](table map[string]V, fi
// ciProvider detects which CI/CD provider is currently running.
// Returns the name of the detected provider or empty string if none found.
func ciProvider() string {
// First, check providers that can be detected by environment variable existence.
// First, check providers that require ALL specified environment variables to exist.
// This prevents false positives (e.g., Jenkins from build-harness).
// Sort keys alphabetically for consistent ordering.
var allExistKeys []string
for key := range ciProvidersEnvVarsAllExist {
allExistKeys = append(allExistKeys, key)
}
sort.Strings(allExistKeys)
for _, key := range allExistKeys {
vars := ciProvidersEnvVarsAllExist[key]
allExist := true
for _, envVar := range vars {
if !isEnvVarExists(envVar) {
allExist = false
break
}
}
if allExist {
log.Debug("CI provider detected", logKeyProvider, key, "env", strings.Join(vars, ","))
return key
}
}

// Then, check providers that can be detected by single environment variable existence.
// Process in alphabetical order for consistent results.
if result := applyAlphabeticalOrder(ciProvidersEnvVarsExists, isEnvVarExists); result != "" {
// Log which specific env var was detected.
if envVar, exists := ciProvidersEnvVarsExists[result]; exists {
log.Debug("CI provider detected", logKeyProvider, result, "env", envVar)
}
return result
}

Expand All @@ -167,9 +206,20 @@ func ciProvider() string {
return false
}

// Then, check providers that require specific environment variable values.
// Finally, check providers that require specific environment variable values.
// Process in alphabetical order for consistent results.
if result := applyAlphabeticalOrder(ciProvidersEnvVarsEquals, checkEnvVarsEquals); result != "" {
if envVars, exists := ciProvidersEnvVarsEquals[result]; exists {
var detectedVars []string
for envName := range envVars {
if _, found := os.LookupEnv(envName); found {
detectedVars = append(detectedVars, envName)
}
}
if len(detectedVars) > 0 {
log.Debug("CI provider detected", logKeyProvider, result, "env", strings.Join(detectedVars, ","))
}
}
return result
}

Expand Down
1 change: 1 addition & 0 deletions pkg/telemetry/ci_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ func TestCiProvider(t *testing.T) {
name: "JENKINS",
envVars: map[string]string{
"JENKINS_URL": "http://jenkins.example.com",
"BUILD_ID": "123",
},
expectedResult: "JENKINS",
},
Expand Down
1 change: 1 addition & 0 deletions pkg/telemetry/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ func TestCaptureCmd(t *testing.T) {

// Set Jenkins CI environment and test command capture.
t.Setenv("JENKINS_URL", "https://jenkins.example.com")
t.Setenv("BUILD_ID", "123")
captureCmd(cmd, nil, mockClientProvider.NewMockClient)
}

Expand Down
Loading
Loading