Skip to content

Instantly share code, notes, and snippets.

@nakabonne
Created December 9, 2020 09:47
Show Gist options
  • Save nakabonne/40ed4d23314663ad8ce9430f684449f5 to your computer and use it in GitHub Desktop.
Save nakabonne/40ed4d23314663ad8ce9430f684449f5 to your computer and use it in GitHub Desktop.
An ECR client to determine the latest tag of the given repository
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
}
@nakabonne
Copy link
Author

Step:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment