From 3760ec38cc338a909b84d9bb1f6345b0cc971087 Mon Sep 17 00:00:00 2001 From: Kent Rancourt Date: Sat, 25 May 2024 12:37:29 -0400 Subject: [PATCH] chore: switch from aws-sdk-go v1 to v2 (#2059) Signed-off-by: Kent Rancourt --- go.mod | 18 ++- go.sum | 32 +++- internal/credentials/credentials.go | 37 +++-- .../credentials/ecr/{ecr.go => access_key.go} | 77 ++++----- .../ecr/{ecr_test.go => access_key_test.go} | 27 ++-- internal/credentials/ecr/pod_identity.go | 146 ++++++++++++++++++ 6 files changed, 271 insertions(+), 66 deletions(-) rename internal/credentials/ecr/{ecr.go => access_key.go} (60%) rename internal/credentials/ecr/{ecr_test.go => access_key_test.go} (83%) create mode 100644 internal/credentials/ecr/pod_identity.go diff --git a/go.mod b/go.mod index dc9681df3..350a01669 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,11 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/Masterminds/semver/v3 v3.2.1 github.com/adrg/xdg v0.4.0 - github.com/aws/aws-sdk-go v1.43.16 + github.com/aws/aws-sdk-go-v2 v1.27.0 + github.com/aws/aws-sdk-go-v2/config v1.27.16 + github.com/aws/aws-sdk-go-v2/credentials v1.17.16 + github.com/aws/aws-sdk-go-v2/service/ecr v1.28.3 + github.com/aws/aws-sdk-go-v2/service/sts v1.28.10 github.com/bacongobbler/browser v1.1.0 github.com/bombsimon/logrusr/v4 v4.1.0 github.com/coreos/go-oidc/v3 v3.10.0 @@ -53,6 +57,18 @@ require ( sigs.k8s.io/yaml v1.4.0 ) +require ( + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.7 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.7 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.20.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.3 // indirect + github.com/aws/smithy-go v1.20.2 // indirect +) + require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect diff --git a/go.sum b/go.sum index 995b606c8..4ab9acc3d 100644 --- a/go.sum +++ b/go.sum @@ -27,8 +27,34 @@ github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/aws/aws-sdk-go v1.43.16 h1:Y7wBby44f+tINqJjw5fLH3vA+gFq4uMITIKqditwM14= -github.com/aws/aws-sdk-go v1.43.16/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= +github.com/aws/aws-sdk-go-v2 v1.27.0 h1:7bZWKoXhzI+mMR/HjdMx8ZCC5+6fY0lS5tr0bbgiLlo= +github.com/aws/aws-sdk-go-v2 v1.27.0/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= +github.com/aws/aws-sdk-go-v2/config v1.27.16 h1:knpCuH7laFVGYTNd99Ns5t+8PuRjDn4HnnZK48csipM= +github.com/aws/aws-sdk-go-v2/config v1.27.16/go.mod h1:vutqgRhDUktwSge3hrC3nkuirzkJ4E/mLj5GvI0BQas= +github.com/aws/aws-sdk-go-v2/credentials v1.17.16 h1:7d2QxY83uYl0l58ceyiSpxg9bSbStqBC6BeEeHEchwo= +github.com/aws/aws-sdk-go-v2/credentials v1.17.16/go.mod h1:Ae6li/6Yc6eMzysRL2BXlPYvnrLLBg3D11/AmOjw50k= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.3 h1:dQLK4TjtnlRGb0czOht2CevZ5l6RSyRWAnKeGd7VAFE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.3/go.mod h1:TL79f2P6+8Q7dTsILpiVST+AL9lkF6PPGI167Ny0Cjw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.7 h1:lf/8VTF2cM+N4SLzaYJERKEWAXq8MOMpZfU6wEPWsPk= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.7/go.mod h1:4SjkU7QiqK2M9oozyMzfZ/23LmUY+h3oFqhdeP5OMiI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.7 h1:4OYVp0705xu8yjdyoWix0r9wPIRXnIzzOoUpQVHIJ/g= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.7/go.mod h1:vd7ESTEvI76T2Na050gODNmNU7+OyKrIKroYTu4ABiI= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/service/ecr v1.28.3 h1:NsP8PA4Kw1sA6UKl3ZFRIcA9dWomePbmoRIvfOl+HKs= +github.com/aws/aws-sdk-go-v2/service/ecr v1.28.3/go.mod h1:X52zjAVRaXklEU1TE/wO8kyyJSr9cJx9ZsqliWbyRys= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.9 h1:Wx0rlZoEJR7JwlSZcHnEa7CNjrSIyVxMFWGAaXy4fJY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.9/go.mod h1:aVMHdE0aHO3v+f/iw01fmXV/5DbfQ3Bi9nN7nd9bE9Y= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.9 h1:aD7AGQhvPuAxlSUfo0CWU7s6FpkbyykMhGYMvlqTjVs= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.9/go.mod h1:c1qtZUWtygI6ZdvKppzCSXsDOq5I4luJPZ0Ud3juFCA= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.3 h1:Pav5q3cA260Zqez42T9UhIlsd9QeypszRPwC9LdSSsQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.3/go.mod h1:9lmoVDVLz/yUZwLaQ676TK02fhCu4+PgRSmMaKR1ozk= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.10 h1:69tpbPED7jKPyzMcrwSvhWcJ9bPnZsZs18NT40JwM0g= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.10/go.mod h1:0Aqn1MnEuitqfsCNyKsdKLhDUOr4txD/g19EfiUqgws= +github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= +github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/bacongobbler/browser v1.1.0 h1:6YTctUlzcApit1vpWgh+myjh8lQUyQRD2Ltoyvy2EoM= github.com/bacongobbler/browser v1.1.0/go.mod h1:T9AaY4DSJ61FNgVTlCP/FWPrJ36TMRwI0Z18eLZ3IKI= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= @@ -411,7 +437,6 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= @@ -439,7 +464,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go index c46b688c9..e81d01e56 100644 --- a/internal/credentials/credentials.go +++ b/internal/credentials/credentials.go @@ -71,10 +71,11 @@ type Database interface { // utilizes a Kubernetes controller runtime client to retrieve credentials // stored in Kubernetes Secrets. type kubernetesDatabase struct { - kargoClient client.Client - ecrHelper ecr.CredentialHelper - gcpHelper gcp.CredentialHelper - cfg KubernetesDatabaseConfig + kargoClient client.Client + ecrAccessKeyHelper ecr.AccessKeyCredentialHelper + ecrPodIdentityHelper ecr.PodIdentityCredentialHelper + gcpHelper gcp.CredentialHelper + cfg KubernetesDatabaseConfig } // KubernetesDatabaseConfig represents configuration for a Kubernetes based @@ -98,10 +99,11 @@ func NewKubernetesDatabase( cfg KubernetesDatabaseConfig, ) Database { return &kubernetesDatabase{ - kargoClient: kargoClient, - ecrHelper: ecr.NewCredentialHelper(), - gcpHelper: gcp.NewCredentialHelper(), - cfg: cfg, + kargoClient: kargoClient, + ecrAccessKeyHelper: ecr.NewAccessKeyCredentialHelper(), + ecrPodIdentityHelper: ecr.NewPodIdentityCredentialHelper(), + gcpHelper: gcp.NewCredentialHelper(), + cfg: cfg, } } @@ -151,11 +153,24 @@ func (k *kubernetesDatabase) Get( } } - if secret == nil { + if secret != nil { + if creds, err = k.secretToCreds(ctx, credType, secret); err != nil { + return creds, false, err + } + return creds, true, nil + } + + if credType != TypeImage { + // If we are not not looking for image repository credentials, there's + // nothing left to try. return creds, false, nil } - if creds, err = k.secretToCreds(ctx, credType, secret); err != nil { + // If we get to here, we have not found any secret that we can pick apart + // in any way, but we can still try to resolve a username and password via + // workload identity. + if creds.Username, creds.Password, err = + k.ecrPodIdentityHelper.GetUsernameAndPassword(ctx, repoURL, namespace); err != nil { return creds, false, err } @@ -242,7 +257,7 @@ func (k *kubernetesDatabase) secretToCreds( var username, password string var err error // Try AWS - if username, password, err = k.ecrHelper.GetUsernameAndPassword(secret); err != nil { + if username, password, err = k.ecrAccessKeyHelper.GetUsernameAndPassword(ctx, secret); err != nil { return Credentials{}, err } if username == "" { // Try GCP diff --git a/internal/credentials/ecr/ecr.go b/internal/credentials/ecr/access_key.go similarity index 60% rename from internal/credentials/ecr/ecr.go rename to internal/credentials/ecr/access_key.go index 326f554df..f04925537 100644 --- a/internal/credentials/ecr/ecr.go +++ b/internal/credentials/ecr/access_key.go @@ -1,16 +1,16 @@ package ecr import ( + "context" "crypto/sha256" "encoding/base64" "fmt" "strings" "time" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/credentials" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/ecr" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/ecr" "github.com/patrickmn/go-cache" corev1 "k8s.io/api/core/v1" ) @@ -21,43 +21,50 @@ const ( secretKey = "awsSecretAccessKey" ) -// CredentialHelper is an interface for components that can extract a username -// and password from a Secret containing an AWS region, access key id, and -// secret access key. -type CredentialHelper interface { +// AccessKeyCredentialHelper is an interface for components that can extract a +// username and password from a Secret containing an AWS region, access key id, +// and secret access key. +type AccessKeyCredentialHelper interface { // GetUsernameAndPassword extracts username and password (a token that lives // for 12 hours) from a Secret IF the Secret contains an AWS region, access // key id, and secret access key. If the Secret does not contain ANY of these // fields, this function will return empty strings and a nil error. If the // Secret contains some but not all of these fields, this function will return // an error. Implementations may cache the token for efficiency. - GetUsernameAndPassword(*corev1.Secret) (string, string, error) + GetUsernameAndPassword(context.Context, *corev1.Secret) (string, string, error) } -type credentialHelper struct { +type accessKeyCredentialHelper struct { tokenCache *cache.Cache // The following behaviors are overridable for testing purposes: - getAuthTokenFn func(string, string, string) (string, error) + getAuthTokenFn func( + ctx context.Context, + region string, + accessKeyID string, + secretAccessKey string, + ) (string, error) } -// NewCredentialHelper returns an implementation of the CredentialHelper -// interface that utilizes a cache to avoid unnecessary calls to AWS. -func NewCredentialHelper() CredentialHelper { - return &credentialHelper{ +// NewAccessKeyCredentialHelper returns an implementation of the +// AccessKeyCredentialHelper interface that utilizes a cache to avoid +// unnecessary calls to AWS. +func NewAccessKeyCredentialHelper() AccessKeyCredentialHelper { + a := &accessKeyCredentialHelper{ tokenCache: cache.New( // Tokens live for 12 hours. We'll hang on to them for 10. 10*time.Hour, // Default ttl for each entry time.Hour, // Cleanup interval ), - getAuthTokenFn: getAuthToken, } + a.getAuthTokenFn = a.getAuthToken + return a } -// GetUsernameAndPassword implements the CredentialHelper interface. -func (c *credentialHelper) GetUsernameAndPassword( - secret *corev1.Secret, +// GetUsernameAndPassword implements the AccessKeyCredentialHelper interface. +func (a *accessKeyCredentialHelper) GetUsernameAndPassword( + ctx context.Context, secret *corev1.Secret, ) (string, string, error) { region := string(secret.Data[regionKey]) accessKeyID := string(secret.Data[idKey]) @@ -74,25 +81,25 @@ func (c *credentialHelper) GetUsernameAndPassword( regionKey, idKey, secretKey, ) } - return c.getUsernameAndPassword(region, accessKeyID, secretAccessKey) + return a.getUsernameAndPassword(ctx, region, accessKeyID, secretAccessKey) } -func (c *credentialHelper) getUsernameAndPassword( - region, accessKeyID, secretAccessKey string, +func (a *accessKeyCredentialHelper) getUsernameAndPassword( + ctx context.Context, region, accessKeyID, secretAccessKey string, ) (string, string, error) { - cacheKey := tokenCacheKey(region, accessKeyID, secretAccessKey) + cacheKey := a.tokenCacheKey(region, accessKeyID, secretAccessKey) - if entry, exists := c.tokenCache.Get(cacheKey); exists { + if entry, exists := a.tokenCache.Get(cacheKey); exists { return decodeAuthToken(entry.(string)) // nolint: forcetypeassert } - encodedToken, err := c.getAuthTokenFn(region, accessKeyID, secretAccessKey) + encodedToken, err := a.getAuthTokenFn(ctx, region, accessKeyID, secretAccessKey) if err != nil { return "", "", fmt.Errorf("error getting ECR auth token: %w", err) } // Cache the encoded token - c.tokenCache.Set(cacheKey, encodedToken, cache.DefaultExpiration) + a.tokenCache.Set(cacheKey, encodedToken, cache.DefaultExpiration) return decodeAuthToken(encodedToken) } @@ -100,7 +107,7 @@ func (c *credentialHelper) getUsernameAndPassword( // tokenCacheKey returns a cache key for an ECR authorization token. The key is // a hash of the region, access key ID, and secret access key. Using a hash // ensures that the secret access key is not stored in plaintext in the cache. -func tokenCacheKey(region, accessKeyID, secretAccessKey string) string { +func (a *accessKeyCredentialHelper) tokenCacheKey(region, accessKeyID, secretAccessKey string) string { return fmt.Sprintf( "%x", sha256.Sum256([]byte( @@ -111,18 +118,14 @@ func tokenCacheKey(region, accessKeyID, secretAccessKey string) string { // getAuthToken returns an ECR authorization token by calling out to AWS with // the provided credentials. -func getAuthToken( - region, accessKeyID, secretAccessKey string, +func (a *accessKeyCredentialHelper) getAuthToken( + ctx context.Context, region, accessKeyID, secretAccessKey string, ) (string, error) { - sess, err := session.NewSession(&aws.Config{ - Region: aws.String(region), - Credentials: credentials.NewStaticCredentials(accessKeyID, secretAccessKey, ""), + svc := ecr.NewFromConfig(aws.Config{ + Region: region, + Credentials: credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, ""), }) - if err != nil { - return "", fmt.Errorf("error creating AWS session: %w", err) - } - svc := ecr.New(sess) - output, err := svc.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{}) + output, err := svc.GetAuthorizationToken(ctx, &ecr.GetAuthorizationTokenInput{}) if err != nil { return "", fmt.Errorf("error getting ECR authorization token: %w", err) } diff --git a/internal/credentials/ecr/ecr_test.go b/internal/credentials/ecr/access_key_test.go similarity index 83% rename from internal/credentials/ecr/ecr_test.go rename to internal/credentials/ecr/access_key_test.go index 7af3d7fec..fc4cb0a56 100644 --- a/internal/credentials/ecr/ecr_test.go +++ b/internal/credentials/ecr/access_key_test.go @@ -1,6 +1,7 @@ package ecr import ( + "context" "encoding/base64" "fmt" "testing" @@ -10,7 +11,7 @@ import ( corev1 "k8s.io/api/core/v1" ) -func TestGetUsernameAndPassword(t *testing.T) { +func TestAccessKeyCredentialHelper(t *testing.T) { const ( testRegion = "fake-region" testAccessKeyID = "fake-id" @@ -23,7 +24,7 @@ func TestGetUsernameAndPassword(t *testing.T) { warmTokenCache := cache.New(0, 0) warmTokenCache.Set( - tokenCacheKey(testRegion, testAccessKeyID, testSecretAccessKey), + (&accessKeyCredentialHelper{}).tokenCacheKey(testRegion, testAccessKeyID, testSecretAccessKey), testEncodedToken, cache.DefaultExpiration, ) @@ -31,13 +32,13 @@ func TestGetUsernameAndPassword(t *testing.T) { testCases := []struct { name string secret *corev1.Secret - helper CredentialHelper + helper AccessKeyCredentialHelper assertions func(t *testing.T, username, password string, err error) }{ { name: "no aws details provided", secret: &corev1.Secret{}, - helper: NewCredentialHelper(), + helper: NewAccessKeyCredentialHelper(), assertions: func(t *testing.T, username, password string, err error) { require.NoError(t, err) require.Empty(t, username) @@ -52,7 +53,7 @@ func TestGetUsernameAndPassword(t *testing.T) { secretKey: []byte(testSecretAccessKey), }, }, - helper: NewCredentialHelper(), + helper: NewAccessKeyCredentialHelper(), assertions: func(t *testing.T, _, _ string, err error) { require.ErrorContains(t, err, "must all be set or all be unset") }, @@ -65,7 +66,7 @@ func TestGetUsernameAndPassword(t *testing.T) { secretKey: []byte(testSecretAccessKey), }, }, - helper: NewCredentialHelper(), + helper: NewAccessKeyCredentialHelper(), assertions: func(t *testing.T, _, _ string, err error) { require.ErrorContains(t, err, "must all be set or all be unset") }, @@ -78,7 +79,7 @@ func TestGetUsernameAndPassword(t *testing.T) { idKey: []byte(testAccessKeyID), }, }, - helper: NewCredentialHelper(), + helper: NewAccessKeyCredentialHelper(), assertions: func(t *testing.T, _, _ string, err error) { require.ErrorContains(t, err, "must all be set or all be unset") }, @@ -92,7 +93,7 @@ func TestGetUsernameAndPassword(t *testing.T) { secretKey: []byte(testSecretAccessKey), }, }, - helper: &credentialHelper{ + helper: &accessKeyCredentialHelper{ tokenCache: warmTokenCache, }, assertions: func(t *testing.T, username, password string, err error) { @@ -110,9 +111,9 @@ func TestGetUsernameAndPassword(t *testing.T) { secretKey: []byte(testSecretAccessKey), }, }, - helper: &credentialHelper{ + helper: &accessKeyCredentialHelper{ tokenCache: cache.New(0, 0), - getAuthTokenFn: func(string, string, string) (string, error) { + getAuthTokenFn: func(context.Context, string, string, string) (string, error) { return "", fmt.Errorf("something went wrong") }, }, @@ -130,9 +131,9 @@ func TestGetUsernameAndPassword(t *testing.T) { secretKey: []byte(testSecretAccessKey), }, }, - helper: &credentialHelper{ + helper: &accessKeyCredentialHelper{ tokenCache: cache.New(0, 0), - getAuthTokenFn: func(string, string, string) (string, error) { + getAuthTokenFn: func(context.Context, string, string, string) (string, error) { return testEncodedToken, nil }, }, @@ -146,7 +147,7 @@ func TestGetUsernameAndPassword(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { username, password, err := - testCase.helper.GetUsernameAndPassword(testCase.secret) + testCase.helper.GetUsernameAndPassword(context.Background(), testCase.secret) testCase.assertions(t, username, password, err) }) } diff --git a/internal/credentials/ecr/pod_identity.go b/internal/credentials/ecr/pod_identity.go new file mode 100644 index 000000000..26342531b --- /dev/null +++ b/internal/credentials/ecr/pod_identity.go @@ -0,0 +1,146 @@ +package ecr + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "regexp" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials/stscreds" + "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/patrickmn/go-cache" + + "github.com/akuity/kargo/internal/logging" +) + +var ecrURLRegex = regexp.MustCompile(`^([0-9]{12})\.dkr\.ecr\.(.+)\.amazonaws\.com`) + +// PodIdentityCredentialHelper is an interface for components that can obtain a +// username and password for ECR using EKS Pod Identity. +type PodIdentityCredentialHelper interface { + GetUsernameAndPassword( + ctx context.Context, + repoURL string, + project string, + ) (string, string, error) +} + +type podIdentityCredentialHelper struct { + tokenCache *cache.Cache + + // The following behaviors are overridable for testing purposes: + + getAuthTokenFn func( + ctx context.Context, + accountID string, + region string, + project string, + ) (string, error) +} + +// NewPodIdentityCredentialHelper returns an implementation of the +// PodIdentityCredentialHelper interface that utilizes a cache to avoid +// unnecessary calls to AWS. +func NewPodIdentityCredentialHelper() PodIdentityCredentialHelper { + + p := &podIdentityCredentialHelper{ + tokenCache: cache.New( + // Tokens live for 12 hours. We'll hang on to them for 10. + 10*time.Hour, // Default ttl for each entry + time.Hour, // Cleanup interval + ), + } + p.getAuthTokenFn = p.getAuthToken + return p +} + +// GetUsernameAndPassword implements the PodIdentityCredentialHelper interface. +func (p *podIdentityCredentialHelper) GetUsernameAndPassword( + ctx context.Context, + repoURL string, + project string, +) (string, string, error) { + if os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") == "" { + // Don't even try if it looks like EKS Pod Identity isn't set up for this + // controller. + return "", "", nil + } + + matches := ecrURLRegex.FindStringSubmatch(repoURL) + if len(matches) != 3 { // This doesn't look like an ECR URL + return "", "", nil + } + // TODO: We actually might not want to get the account ID from the repoURL + // because the account ID in the repoURL may be for a different account from + // the one containing the Kargo controller's IAM role and the Project-specific + // IAM roles it assumes. (Access across accounts IS possible. It is just not + // clear to me yet where else I can get the correct account ID from without + // requiring it to be explicitly configured at install-time.) + accountID := matches[1] + region := matches[2] + + cacheKey := p.tokenCacheKey(region, project) + + if entry, exists := p.tokenCache.Get(cacheKey); exists { + return decodeAuthToken(entry.(string)) // nolint: forcetypeassert + } + + encodedToken, err := p.getAuthTokenFn(ctx, accountID, region, project) + if err != nil { + // This might mean the controller's IAM role isn't authorized to assume the + // project-specific IAM role, or that the project-specific IAM role doesn't + // have the necessary permissions to get an ECR auth token. We're making + // a choice to consider this the will of the AWS admins and not a controller + // error. We'll just log it and move on as if we found no credentials. + return "", "", fmt.Errorf("error getting ECR auth token: %w", err) + } + + // Cache the encoded token + p.tokenCache.Set(project, encodedToken, cache.DefaultExpiration) + + return decodeAuthToken(encodedToken) +} + +func (p *podIdentityCredentialHelper) tokenCacheKey(region, project string) string { + return fmt.Sprintf( + "%x", + sha256.Sum256([]byte( + fmt.Sprintf("%s:%s", region, project), + )), + ) +} + +// getAuthToken returns an ECR authorization token obtained by assuming a +// project-specific IAM role and using that to obtain a short-lived ECR access +// token. +func (p *podIdentityCredentialHelper) getAuthToken( + ctx context.Context, + accountID string, + region string, + project string, +) (string, error) { + logger := logging.LoggerFromContext(ctx) + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + logger.Error("error loading AWS config: %w", err) + return "", nil + } + ecrSvc := ecr.NewFromConfig(aws.Config{ + Region: region, + Credentials: stscreds.NewAssumeRoleProvider( + sts.NewFromConfig(cfg), + fmt.Sprintf("arn:aws:iam::%s:role/kargo-project-%s", accountID, project), + ), + }) + output, err := ecrSvc.GetAuthorizationToken(ctx, &ecr.GetAuthorizationTokenInput{}) + if err != nil { + logger.Error("error getting ECR authorization token: %w", err) + return "", nil + } + return *output.AuthorizationData[0].AuthorizationToken, nil +}