// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package ecr provides a client to make API requests to Amazon EC2 Container Registry. package ecr import ( "encoding/base64" "errors" "fmt" "strings" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/arn" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/endpoints" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ecr" "github.com/aws/copilot-cli/internal/pkg/term/log" ) const ( urlFmtString = "%s.dkr.ecr.%s.amazonaws.com/%s" urlFmtStringForCN = "%s.dkr.ecr.%s.amazonaws.com.cn/%s" arnResourcePrefix = "repository/" batchDeleteLimit = 100 ) type api interface { DescribeImages(*ecr.DescribeImagesInput) (*ecr.DescribeImagesOutput, error) GetAuthorizationToken(*ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error) DescribeRepositories(*ecr.DescribeRepositoriesInput) (*ecr.DescribeRepositoriesOutput, error) BatchDeleteImage(*ecr.BatchDeleteImageInput) (*ecr.BatchDeleteImageOutput, error) } // ECR wraps an AWS ECR client. type ECR struct { client api } // New returns a ECR configured against the input session. func New(s *session.Session) ECR { return ECR{ client: ecr.New(s), } } // Auth returns the basic authentication credentials needed to push images. func (c ECR) Auth() (username string, password string, err error) { response, err := c.client.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{}) if err != nil { return "", "", fmt.Errorf("get ECR auth: %w", err) } authToken, err := base64.StdEncoding.DecodeString(*response.AuthorizationData[0].AuthorizationToken) if err != nil { return "", "", fmt.Errorf("decode auth token: %w", err) } tokenStrings := strings.Split(string(authToken), ":") return tokenStrings[0], tokenStrings[1], nil } // RepositoryURI returns the ECR repository URI. func (c ECR) RepositoryURI(name string) (string, error) { result, err := c.client.DescribeRepositories(&ecr.DescribeRepositoriesInput{ RepositoryNames: aws.StringSlice([]string{name}), }) if err != nil { return "", fmt.Errorf("ecr describe repository %s: %w", name, err) } foundRepositories := result.Repositories if len(foundRepositories) <= 0 { return "", errors.New("no repositories found") } repo := result.Repositories[0] return *repo.RepositoryUri, nil } // Image houses metadata for ECR repository images. type Image struct { Digest string } func (i Image) imageIdentifier() *ecr.ImageIdentifier { return &ecr.ImageIdentifier{ ImageDigest: aws.String(i.Digest), } } // ListImages calls the ECR DescribeImages API and returns a list of // Image metadata for images in the input ECR repository name. func (c ECR) ListImages(repoName string) ([]Image, error) { var images []Image resp, err := c.client.DescribeImages(&ecr.DescribeImagesInput{ RepositoryName: aws.String(repoName), }) if err != nil { return nil, fmt.Errorf("ecr repo %s describe images: %w", repoName, err) } for _, imageDetails := range resp.ImageDetails { images = append(images, Image{ Digest: *imageDetails.ImageDigest, }) } for resp.NextToken != nil { resp, err = c.client.DescribeImages(&ecr.DescribeImagesInput{ RepositoryName: aws.String(repoName), NextToken: resp.NextToken, }) if err != nil { return nil, fmt.Errorf("ecr repo %s describe images: %w", repoName, err) } for _, imageDetails := range resp.ImageDetails { images = append(images, Image{ Digest: *imageDetails.ImageDigest, }) } } return images, nil } // DeleteImages calls the ECR BatchDeleteImage API with the input image list and repository name. func (c ECR) DeleteImages(images []Image, repoName string) error { if len(images) == 0 { return nil } var imageIdentifiers []*ecr.ImageIdentifier for _, image := range images { imageIdentifiers = append(imageIdentifiers, image.imageIdentifier()) } var imageIdentifiersBatch [][]*ecr.ImageIdentifier for batchDeleteLimit < len(imageIdentifiers) { imageIdentifiers, imageIdentifiersBatch = imageIdentifiers[batchDeleteLimit:], append(imageIdentifiersBatch, imageIdentifiers[0:batchDeleteLimit]) } imageIdentifiersBatch = append(imageIdentifiersBatch, imageIdentifiers) for _, identifiers := range imageIdentifiersBatch { resp, err := c.client.BatchDeleteImage(&ecr.BatchDeleteImageInput{ RepositoryName: aws.String(repoName), ImageIds: identifiers, }) if resp != nil { for _, failure := range resp.Failures { log.Warningf("failed to delete %s:%s : %s %s\n", failure.ImageId.ImageDigest, failure.ImageId.ImageTag, failure.FailureCode, failure.FailureReason) } } if err != nil { return fmt.Errorf("ecr repo %s batch delete image: %w", repoName, err) } } return nil } // ClearRepository orchestrates a ListImages call followed by a DeleteImages // call to delete all images from the input ECR repository name. func (c ECR) ClearRepository(repoName string) error { images, err := c.ListImages(repoName) if err == nil { // TODO: add retry handling in case images are added to a repository after a call to ListImages return c.DeleteImages(images, repoName) } if isRepoNotFoundErr(errors.Unwrap(err)) { return nil } return err } // URIFromARN converts an ECR Repo ARN to a Repository URI func URIFromARN(repositoryARN string) (string, error) { repoARN, err := arn.Parse(repositoryARN) if err != nil { return "", fmt.Errorf("parsing repository ARN %s: %w", repositoryARN, err) } urlFmtStr := urlFmtString if repoARN.Partition == endpoints.AwsCnPartitionID { urlFmtStr = urlFmtStringForCN } // Repo ARNs look like arn:aws:ecr:region:012345678910:repository/test // so we have to strip the repository out. repoName := strings.TrimPrefix(repoARN.Resource, arnResourcePrefix) return fmt.Sprintf(urlFmtStr, repoARN.AccountID, repoARN.Region, repoName), nil } func isRepoNotFoundErr(err error) bool { aerr, ok := err.(awserr.Error) if !ok { return false } if aerr.Code() == "RepositoryNotFoundException" { return true } return false }