Skip to content

Commit 049afda

Browse files
authored
feat: add support for resolving AWS profiles per account. (#209)
Introduced logic to resolve AWS profiles using account-specific environment variables, shared configuration, or a default profile. This ensures better flexibility in handling multiple AWS accounts and their configurations.
1 parent 4e239f2 commit 049afda

File tree

4 files changed

+137
-6
lines changed

4 files changed

+137
-6
lines changed

README.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
# Docker Credentials from the Environment
32

43
A [Docker credential helper](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers) to streamline repository interactions in scenarios where the cacheing of credentials to `~/.docker/config.json` is undesirable, including CI/CD pipelines, or anywhere ephemeral credentials are used.
@@ -28,6 +27,19 @@ If no environment variables for the target repository's FQDN is found, then:
2827
* `AWS_SECRET_ACCESS_KEY_<account_id>`
2928
* `AWS_SESSION_TOKEN_<account_id>` (optional)
3029
* `AWS_ROLE_ARN_<account_id>` (optional)
30+
* `AWS_PROFILE_<account_id>` (optional)
31+
32+
### AWS Profile Selection
33+
34+
The helper supports using AWS named profiles for authentication:
35+
36+
* `AWS_PROFILE`: Specifies which profile to use from your AWS shared configuration files. This is used when no account-specific credentials or profile is found.
37+
* `AWS_PROFILE_<account_id>`: Account-specific profile selection. When accessing an ECR repository for a specific AWS account, you can set this environment variable to use a specific named profile from your AWS shared configuration files.
38+
39+
The profile selection follows this order of precedence:
40+
1. Account-specific profile (`AWS_PROFILE_<account_id>`)
41+
2. Standard AWS credentials for the specific account (if any account-specific credentials are found)
42+
3. Standard AWS profile (`AWS_PROFILE`) if no account-specific settings are found
3143

3244
Important note: The helper will first look for account-suffixed AWS credentials (e.g. AWS_ACCESS_KEY_ID_123456789012).
3345
If ANY account-suffixed credentials are found, even partially, the helper requires ALL mandatory credentials to be
@@ -120,7 +132,26 @@ stages {
120132
}
121133
}
122134
123-
stage('Push Image to GHCR') {
135+
stage('Push Image to AWS-ECR (Using Named Profiles)') {
136+
environment {
137+
// Using standard profile for one account
138+
AWS_PROFILE = 'default-profile'
139+
// Using account-specific profile for another account
140+
AWS_PROFILE_987654321098 = 'account-specific-profile'
141+
DOCKER_CREDENTIAL_ENV_DEBUG = 'true' // Enable debug output for credential helper
142+
}
143+
steps {
144+
sh '''
145+
# Uses AWS_PROFILE_987654321098
146+
docker push 987654321098.dkr.ecr.eu-west-1.amazonaws.com/another-example/another-image:2.0
147+
148+
# Uses AWS_PROFILE for a different account
149+
docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/example/example-image:1.0
150+
'''
151+
}
152+
}
153+
154+
stage('Push Image to GHCR') {
124155
environment {
125156
GITHUB_TOKEN = credentials('github') // String credential
126157
}

env.go

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const (
4242
envAwsSecretAccessKey = "AWS_SECRET_ACCESS_KEY" // #nosec G101
4343
envAwsSessionToken = "AWS_SESSION_TOKEN" // #nosec G101
4444
envAwsRoleArn = "AWS_ROLE_ARN"
45+
envAwsProfile = "AWS_PROFILE"
4546
)
4647

4748
// NotSupportedError represents an error indicating that the operation is not supported.
@@ -191,16 +192,33 @@ func getEcrToken(provider *ecrContext) (username, password string, err error) {
191192
return retry.AddWithMaxBackoffDelay(standardRetryer, time.Second)
192193
}
193194

195+
var extraOpts []func(*config.LoadOptions) error
196+
if profile := getProfile(provider.AccountID); profile != "" {
197+
// If a profile is specified, use it to load the AWS configuration
198+
if b, err := strconv.ParseBool(os.Getenv(envDebugMode)); err == nil && b {
199+
_, _ = fmt.Fprintf(os.Stderr, "AWS profile %q (Account: %s)\n", profile, provider.AccountID)
200+
}
201+
extraOpts = append(extraOpts, config.WithSharedConfigProfile(profile))
202+
} else {
203+
// If no profile is specified, use the ecrContext provider to load credentials
204+
extraOpts = append(extraOpts, config.WithCredentialsProvider(aws.NewCredentialsCache(provider)))
205+
}
206+
194207
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
195208
defer cancel()
196209
cfg, err := config.LoadDefaultConfig(ctx,
197-
config.WithRetryer(simpleRetryer),
198-
config.WithRegion(provider.Region),
199-
config.WithCredentialsProvider(aws.NewCredentialsCache(provider)))
210+
append(extraOpts, config.WithRetryer(simpleRetryer),
211+
config.WithRegion(provider.Region))...)
200212
if err != nil {
201213
return
202214
}
203215

216+
// Add a shared config profile if available
217+
//if profile := getProfile(provider.AccountID, cfg.ConfigSources...); profile != "" {
218+
// cfg.ConfigSources = append(cfg.ConfigSources, config.WithSharedConfigProfile(profile))
219+
//}
220+
221+
// If a role ARN is specified for the account, assume that role
204222
var roleArn string
205223
if roleArn = getRoleArn(provider.AccountID, cfg.ConfigSources...); roleArn != "" {
206224
stsSvc := sts.NewFromConfig(cfg)
@@ -243,6 +261,45 @@ func getEcrToken(provider *ecrContext) (username, password string, err error) {
243261
return
244262
}
245263

264+
// getProfile resolves an AWS profile name by checking, in order:
265+
// 1. Account-specific environment variable (AWS_PROFILE_<account>)
266+
// 2. Configuration sources (SharedConfigProfile or Profile)
267+
// 3. Default AWS_PROFILE environment variable
268+
//
269+
// Parameters:
270+
//
271+
// account - AWS account ID to look up account-specific profile name
272+
// configSources - Optional AWS configuration sources containing profile information
273+
//
274+
// Returns:
275+
//
276+
// profile - Resolved AWS profile name, empty string if none found
277+
func getProfile(account string, configSources ...any) (profile string) {
278+
// Check for account-specific profile environment variable
279+
val, found := os.LookupEnv(envAwsProfile + "_" + account)
280+
if found {
281+
return strings.TrimSpace(val)
282+
}
283+
284+
if len(configSources) == 0 {
285+
return os.Getenv("AWS_PROFILE")
286+
}
287+
288+
for _, x := range configSources {
289+
switch impl := x.(type) {
290+
case config.EnvConfig:
291+
if impl.SharedConfigProfile != "" {
292+
return strings.TrimSpace(impl.SharedConfigProfile)
293+
}
294+
case config.SharedConfig:
295+
if impl.Profile != "" {
296+
return strings.TrimSpace(impl.Profile)
297+
}
298+
}
299+
}
300+
return
301+
}
302+
246303
// getRoleArn retrieves the AWS role ARN for a specific account by checking environment variables and AWS configurations.
247304
// It checks the account-specific role ARN environment variable (AWS_ROLE_ARN_<account>). If not found,
248305
// then checks the standard AWS role ARN environment variable (AWS_ROLE_ARN) when no config sources are provided.

env_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,3 +321,46 @@ func TestGetRoleArn(t *testing.T) {
321321
})
322322
}
323323
}
324+
325+
func TestGetProfile(t *testing.T) {
326+
tests := []struct {
327+
name string
328+
inputEnv map[string]string
329+
expected string
330+
}{
331+
{
332+
name: "Standard environment variable",
333+
inputEnv: map[string]string{
334+
"AWS_PROFILE": "my-profile",
335+
},
336+
expected: "my-profile",
337+
},
338+
{
339+
name: "Suffixed environment variable",
340+
inputEnv: map[string]string{
341+
"AWS_PROFILE_12345": "my-profile",
342+
},
343+
expected: "my-profile",
344+
},
345+
{
346+
name: "Suffixed has higher priority",
347+
inputEnv: map[string]string{
348+
"AWS_PROFILE": "other-profile",
349+
"AWS_PROFILE_12345": "my-profile",
350+
},
351+
expected: "my-profile",
352+
},
353+
}
354+
355+
for _, tt := range tests {
356+
t.Run(tt.name, func(t *testing.T) {
357+
for k, v := range tt.inputEnv {
358+
t.Setenv(k, v)
359+
}
360+
actual := getProfile("12345")
361+
if actual != tt.expected {
362+
t.Errorf("GetProfile(<suffix>) actual = (%v), expected (%v)", actual, tt.expected)
363+
}
364+
})
365+
}
366+
}

provider_aws.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func (p *ecrContext) Retrieve(_ context.Context) (out aws.Credentials, err error
3636
// Diagnostic output
3737
if out.Source != "" {
3838
if b, err := strconv.ParseBool(os.Getenv(envDebugMode)); err == nil && b {
39-
_, _ = fmt.Fprintf(os.Stderr, "Authenticating access to '%s.dkr.ecr.%s.amazonaws.com/' with %q\n", p.AccountID, p.Region, out.Source)
39+
_, _ = fmt.Fprintf(os.Stderr, "Authenticating access to '%s.dkr.ecr.%s.amazonaws.com' with %q\n", p.AccountID, p.Region, out.Source)
4040
}
4141
}
4242
}()

0 commit comments

Comments
 (0)