Skip to content

Commit

Permalink
[Feat]: Support for AWS ECR Authentication with Temporary Tokens (#2907)
Browse files Browse the repository at this point in the history
feat: add support for aws ecr authentication

Signed-off-by: K Tamil Vanan <[email protected]>
  • Loading branch information
tamilhce authored Jan 26, 2025
1 parent cf8b20d commit d0de12d
Show file tree
Hide file tree
Showing 9 changed files with 384 additions and 30 deletions.
39 changes: 39 additions & 0 deletions examples/config-sync-ecr-credential-helper.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"distSpecVersion": "1.1.0",
"storage": {
"rootDirectory": "/tmp/zot",
"dedupe": false,
"storageDriver": {
"name": "s3",
"region": "REGION_NAME",
"bucket": "BUGKET_NAME",
"rootdirectory": "/ROOTDIR",
"secure": true,
"skipverify": false
}
},
"http": {
"address": "0.0.0.0",
"port": "8080"
},
"log": {
"level": "debug"
},
"extensions": {
"sync": {
"credentialsFile": "",
"DownloadDir": "/tmp/zot",
"registries": [
{
"urls": [
"https://ACCOUNTID.dkr.ecr.REGION.amazonaws.com"
],
"onDemand": true,
"maxRetries": 5,
"retryDelay": "2m",
"credentialHelper": "ecr"
}
]
}
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/aws/aws-sdk-go-v2/config v1.29.1
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.15.25
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.39.5
github.com/aws/aws-sdk-go-v2/service/ecr v1.36.6
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.13
github.com/aws/aws-secretsmanager-caching-go v1.2.0
github.com/aws/smithy-go v1.22.1
Expand Down Expand Up @@ -158,7 +159,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.24.12 // indirect
github.com/aws/aws-sdk-go-v2/service/ebs v1.25.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ec2 v1.193.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ecr v1.36.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.25.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.9 // indirect
Expand Down
19 changes: 10 additions & 9 deletions pkg/extensions/config/sync/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,16 @@ type Config struct {
}

type RegistryConfig struct {
URLs []string
PollInterval time.Duration
Content []Content
TLSVerify *bool
OnDemand bool
CertDir string
MaxRetries *int
RetryDelay *time.Duration
OnlySigned *bool
URLs []string
PollInterval time.Duration
Content []Content
TLSVerify *bool
OnDemand bool
CertDir string
MaxRetries *int
RetryDelay *time.Duration
OnlySigned *bool
CredentialHelper string
}

type Content struct {
Expand Down
195 changes: 195 additions & 0 deletions pkg/extensions/sync/ecr_credential_helper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
//go:build sync
// +build sync

package sync

import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"

"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ecr"

syncconf "zotregistry.dev/zot/pkg/extensions/config/sync"
"zotregistry.dev/zot/pkg/log"
)

// ECR tokens are valid for 12 hours. The expiryWindow variable is set to 1 hour,
// meaning if the remaining validity of the token is less than 1 hour, it will be considered expired.
const (
expiryWindow int = 1
ecrURLSplitPartsCount int = 6
mockExpiryDuration int = 12
usernameTokenParts int = 2
)

var (
errInvalidURLFormat = errors.New("invalid ECR URL is received")
errInvalidTokenFormat = errors.New("invalid token format received from ECR")
errUnableToLoadAWSConfig = errors.New("unable to load AWS config for region")
errUnableToGetECRAuthToken = errors.New("unable to get ECR authorization token for account")
errUnableToDecodeECRToken = errors.New("unable to decode ECR token")
errFailedToGetECRCredentials = errors.New("failed to get ECR credentials")
)

type ecrCredential struct {
username string
password string
expiry time.Time
account string
region string
}

type ecrCredentialsHelper struct {
credentials map[string]ecrCredential
log log.Logger
getCredentialsFunc func(string) (ecrCredential, error)
}

func NewECRCredentialHelper(log log.Logger, getCredentialsFunc func(string) (ecrCredential, error)) CredentialHelper {
return &ecrCredentialsHelper{
credentials: make(map[string]ecrCredential),
log: log,
getCredentialsFunc: getCredentialsFunc,
}
}

// extractAccountAndRegion extracts the account ID and region from the given ECR URL.
// Example URL format: account.dkr.ecr.region.amazonaws.com.
func extractAccountAndRegion(url string) (string, string, error) {
parts := strings.Split(url, ".")
if len(parts) < ecrURLSplitPartsCount {
return "", "", fmt.Errorf("%w: %s", errInvalidURLFormat, url)
}

accountID := parts[0] // First part is the account ID

region := parts[3] // Fourth part is the region

return accountID, region, nil
}

// getMockECRCredentials provides mock credentials for testing purposes.
func GetMockECRCredentials(remoteAddress string) (ecrCredential, error) {
// Extract account ID and region from the URL.
accountID, region, err := extractAccountAndRegion(remoteAddress)
if err != nil {
return ecrCredential{}, fmt.Errorf("%w %s: %w", errInvalidTokenFormat, remoteAddress, err)
}
expiry := time.Now().Add(time.Duration(mockExpiryDuration) * time.Hour)

return ecrCredential{
username: "mockUsername",
password: "mockPassword",
expiry: expiry,
account: accountID,
region: region,
}, nil
}

// getECRCredentials retrieves actual ECR credentials using AWS SDK.
func GetECRCredentials(remoteAddress string) (ecrCredential, error) {
// Extract account ID and region from the URL.
accountID, region, err := extractAccountAndRegion(remoteAddress)
if err != nil {
return ecrCredential{}, fmt.Errorf("%w %s: %w", errInvalidTokenFormat, remoteAddress, err)
}

// Load the AWS config for the specific region.
cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion(region))
if err != nil {
return ecrCredential{}, fmt.Errorf("%w %s: %w", errUnableToLoadAWSConfig, region, err)
}

// Create an ECR client
ecrClient := ecr.NewFromConfig(cfg)

// Fetch the ECR authorization token.
ecrAuth, err := ecrClient.GetAuthorizationToken(context.TODO(), &ecr.GetAuthorizationTokenInput{
RegistryIds: []string{accountID}, // Filter by the account ID.
})
if err != nil {
return ecrCredential{}, fmt.Errorf("%w %s: %w", errUnableToGetECRAuthToken, accountID, err)
}

// Decode the base64-encoded ECR token.
authToken := *ecrAuth.AuthorizationData[0].AuthorizationToken

decodedToken, err := base64.StdEncoding.DecodeString(authToken)
if err != nil {
return ecrCredential{}, fmt.Errorf("%w: %w", errUnableToDecodeECRToken, err)
}

// Split the decoded token into username and password (username is "AWS").
tokenParts := strings.Split(string(decodedToken), ":")
if len(tokenParts) != usernameTokenParts {
return ecrCredential{}, fmt.Errorf("%w", errInvalidTokenFormat)
}

expiry := *ecrAuth.AuthorizationData[0].ExpiresAt
username := tokenParts[0]
password := tokenParts[1]

return ecrCredential{username: username, password: password, expiry: expiry, account: accountID, region: region}, nil
}

// GetECRCredentials retrieves the ECR credentials (username and password) from AWS ECR.
func (credHelper *ecrCredentialsHelper) GetCredentials(urls []string) (syncconf.CredentialsFile, error) {
ecrCredentials := make(syncconf.CredentialsFile)

for _, url := range urls {
remoteAddress := StripRegistryTransport(url)

// Use the injected credential retrieval function.
ecrCred, err := credHelper.getCredentialsFunc(remoteAddress)
if err != nil {
return syncconf.CredentialsFile{}, fmt.Errorf("%w %s: %w", errFailedToGetECRCredentials, url, err)
}
// Store the credentials in the map using the base URL as the key.
ecrCredentials[remoteAddress] = syncconf.Credentials{
Username: ecrCred.username,
Password: ecrCred.password,
}
credHelper.credentials[remoteAddress] = ecrCred
}

return ecrCredentials, nil
}

// AreCredentialsValid checks if the credentials for a given remote address are still valid.
func (credHelper *ecrCredentialsHelper) AreCredentialsValid(remoteAddress string) bool {
expiry := credHelper.credentials[remoteAddress].expiry
expiryDuration := time.Duration(expiryWindow) * time.Hour

if time.Until(expiry) <= expiryDuration {
credHelper.log.Info().
Str("url", remoteAddress).
Msg("the credentials are close to expiring")

return false
}

credHelper.log.Info().
Str("url", remoteAddress).
Msg("the credentials are valid")

return true
}

// RefreshCredentials refreshes the ECR credentials for the given remote address.
func (credHelper *ecrCredentialsHelper) RefreshCredentials(
remoteAddress string,
) (syncconf.Credentials, error) {
credHelper.log.Info().Str("url", remoteAddress).Msg("refreshing the ECR credentials")

ecrCred, err := credHelper.getCredentialsFunc(remoteAddress)
if err != nil {
return syncconf.Credentials{}, fmt.Errorf("%w %s: %w", errFailedToGetECRCredentials, remoteAddress, err)
}

return syncconf.Credentials{Username: ecrCred.username, Password: ecrCred.password}, nil
}
7 changes: 7 additions & 0 deletions pkg/extensions/sync/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ func NewRemoteRegistry(client *client.Client, logger log.Logger) Remote {
return registry
}

func (registry *RemoteRegistry) SetUpstreamAuthConfig(username, password string) {
registry.context.DockerAuthConfig = &types.DockerAuthConfig{
Username: username,
Password: password,
}
}

func (registry *RemoteRegistry) GetContext() *types.SystemContext {
return registry.context
}
Expand Down
Loading

0 comments on commit d0de12d

Please sign in to comment.