Created
December 9, 2020 09:47
-
-
Save nakabonne/40ed4d23314663ad8ce9430f684449f5 to your computer and use it in GitHub Desktop.
An ECR client to determine the latest tag of the given repository
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package main | |
import ( | |
"context" | |
"fmt" | |
"log" | |
"path" | |
"sort" | |
"github.com/aws/aws-sdk-go/aws" | |
"github.com/aws/aws-sdk-go/aws/awserr" | |
"github.com/aws/aws-sdk-go/aws/credentials" | |
"github.com/aws/aws-sdk-go/aws/session" | |
"github.com/aws/aws-sdk-go/service/ecr" | |
"go.uber.org/zap" | |
) | |
func main() { | |
var ( | |
repo = "nakabonne-test" | |
profile = "default" | |
region = "ap-northeast-1" | |
) | |
ctx, cancel := context.WithCancel(context.Background()) | |
defer cancel() | |
opts := []Option{ | |
WithProfile(profile), | |
WithRegion(region), | |
WithEnvCredentials(), | |
} | |
e, err := NewECR(opts...) | |
if err != nil { | |
log.Fatal(err) | |
} | |
i, err := e.GetLatestImage(ctx, &ImageName{Repo: repo}) | |
if err != nil { | |
log.Fatal(err) | |
} | |
fmt.Println("latest:", i) | |
} | |
// ImageName represents an untagged image. Note that images may have | |
// the domain omitted (e.g. Docker Hub). If they only have single path element, | |
// the prefix `library` is implied. | |
// | |
// Examples: | |
// - alpine | |
// - library/alpine | |
// - gcr.io/pipecd/helloworld | |
type ImageName struct { | |
Domain string | |
Repo string | |
} | |
func (i ImageName) String() string { | |
return path.Join(i.Domain, i.Repo) | |
} | |
// Name gives back just repository name without domain. | |
func (i ImageName) Name() string { | |
return i.Repo | |
} | |
// ImageRef represents a tagged image. The tag is allowed to be | |
// empty, though it is in general undefined what that means | |
// | |
// Examples: | |
// - alpine:3.0 | |
// - library/alpine:3.0 | |
// - gcr.io/pipecd/helloworld:0.1.0 | |
type ImageRef struct { | |
ImageName | |
Tag string | |
} | |
func (i ImageRef) String() string { | |
if i.Tag == "" { | |
return i.ImageName.String() | |
} | |
return fmt.Sprintf("%s:%s", i.ImageName.String(), i.Tag) | |
} | |
type ECR struct { | |
client *ecr.ECR | |
// indicates to retrieve credentials from the environment variables. | |
// These environment variables are used: | |
// - AWS_ACCESS_KEY_ID or AWS_ACCESS_KEY | |
// - AWS_SECRET_ACCESS_KEY or AWS_SECRET_KEY | |
useEnvCredentials bool | |
credentialsFile string | |
profile string | |
registryID string | |
region string | |
logger *zap.Logger | |
} | |
type Option func(*ECR) | |
func WithRegistryID(id string) Option { | |
return func(e *ECR) { | |
e.registryID = id | |
} | |
} | |
func WithCredentialsFile(path string) Option { | |
return func(e *ECR) { | |
e.credentialsFile = path | |
} | |
} | |
func WithEnvCredentials() Option { | |
return func(e *ECR) { | |
e.useEnvCredentials = true | |
} | |
} | |
func WithProfile(profile string) Option { | |
return func(e *ECR) { | |
e.profile = profile | |
} | |
} | |
func WithRegion(region string) Option { | |
return func(e *ECR) { | |
e.region = region | |
} | |
} | |
func WithLogger(logger *zap.Logger) Option { | |
return func(e *ECR) { | |
e.logger = logger | |
} | |
} | |
func NewECR(opts ...Option) (*ECR, error) { | |
e := &ECR{ | |
logger: zap.NewNop(), | |
} | |
for _, opt := range opts { | |
opt(e) | |
} | |
e.logger = e.logger.Named("ecr-provider") | |
if e.credentialsFile != "" && e.useEnvCredentials { | |
return nil, fmt.Errorf("both credentials file and environment variable are specified") | |
} | |
cfg := aws.NewConfig().WithRegion(e.region) | |
if e.useEnvCredentials { | |
cfg = cfg.WithCredentials(credentials.NewEnvCredentials()) | |
} | |
if e.credentialsFile != "" { | |
cfg = cfg.WithCredentials(credentials.NewSharedCredentials(e.credentialsFile, e.profile)) | |
} | |
sess := session.Must(session.NewSession()) | |
e.client = ecr.New(sess, cfg) | |
return e, nil | |
} | |
const maxResults = 1000 | |
func (e *ECR) GetLatestImage(ctx context.Context, image *ImageName) (*ImageRef, error) { | |
input := &ecr.ListImagesInput{ | |
RepositoryName: aws.String(image.Repo), | |
Filter: &ecr.ListImagesFilter{TagStatus: aws.String("TAGGED")}, | |
MaxResults: aws.Int64(maxResults), | |
} | |
if e.registryID != "" { | |
input.RegistryId = &e.registryID | |
} | |
imageIds := make([]*ecr.ImageIdentifier, 0) | |
err := e.client.ListImagesPagesWithContext(ctx, input, func(page *ecr.ListImagesOutput, lastPage bool) bool { | |
imageIds = append(imageIds, page.ImageIds...) | |
return true | |
}) | |
if err != nil { | |
if aerr, ok := err.(awserr.Error); ok { | |
switch aerr.Code() { | |
case ecr.ErrCodeServerException: | |
return nil, fmt.Errorf("server-side issue occured: %w", err) | |
case ecr.ErrCodeInvalidParameterException: | |
return nil, fmt.Errorf("invalid parameter given: %w", err) | |
case ecr.ErrCodeRepositoryNotFoundException: | |
return nil, fmt.Errorf("repository not found: %w", err) | |
default: | |
} | |
} | |
return nil, fmt.Errorf("unknow error given: %w", err) | |
} | |
if len(imageIds) == 0 { | |
return nil, fmt.Errorf("no ids found") | |
} | |
fmt.Println("all tags:") | |
for _, v := range imageIds { | |
fmt.Printf(" - %s\n", *v.ImageTag) | |
} | |
latestTag, err := e.latestByPushedAt(image.Repo, imageIds) | |
if err != nil { | |
return nil, fmt.Errorf("failed to determine the latest tag: %w", err) | |
} | |
return &ImageRef{ | |
ImageName: *image, | |
Tag: latestTag, | |
}, nil | |
} | |
// latestByPushedAt determines the latest tag by comparing the time pushed at. | |
func (e *ECR) latestByPushedAt(repo string, ids []*ecr.ImageIdentifier) (string, error) { | |
input := &ecr.DescribeImagesInput{ | |
Filter: &ecr.DescribeImagesFilter{TagStatus: aws.String("TAGGED")}, | |
ImageIds: ids, | |
RepositoryName: aws.String(repo), | |
} | |
if e.registryID != "" { | |
input.RegistryId = &e.registryID | |
} | |
res, err := e.client.DescribeImages(input) | |
if err != nil { | |
if aerr, ok := err.(awserr.Error); ok { | |
switch aerr.Code() { | |
case ecr.ErrCodeServerException: | |
return "", fmt.Errorf("server-side issue occured: %w", err) | |
case ecr.ErrCodeInvalidParameterException: | |
return "", fmt.Errorf("invalid parameter given: %w", err) | |
case ecr.ErrCodeRepositoryNotFoundException: | |
return "", fmt.Errorf("repository not found: %w", err) | |
case ecr.ErrCodeImageNotFoundException: | |
return "", fmt.Errorf("image not found: %w", err) | |
default: | |
} | |
} | |
return "", fmt.Errorf("unknow error given: %w", err) | |
} | |
if len(res.ImageDetails) == 0 { | |
return "", fmt.Errorf("no images found") | |
} | |
sort.SliceStable(res.ImageDetails, func(i, j int) bool { | |
l, r := res.ImageDetails[i], res.ImageDetails[j] | |
if l.ImagePushedAt == nil || r.ImagePushedAt == nil { | |
return l.ImagePushedAt == nil && r.ImagePushedAt != nil | |
} | |
return l.ImagePushedAt.After(*r.ImagePushedAt) | |
}) | |
if len(res.ImageDetails[0].ImageTags) == 0 { | |
return "", fmt.Errorf("no images tag is associated the image") | |
} | |
// NOTE: Even if the tags are different, they are managed as a single | |
// image if the images' sha256 digests are identical, so there may | |
// be multiple tags associated with it. | |
latest := *res.ImageDetails[0].ImageTags[0] | |
return latest, nil | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Step:
AWS_ACCESS_KEY_ID
andAWS_SECRET_ACCESS_KEY
environment variables