diff --git a/cli.go b/cli.go index eca2c3f..1936bb0 100644 --- a/cli.go +++ b/cli.go @@ -79,7 +79,7 @@ func (c *PlanCLI) Option() *Option { Scan: c.Scan, ScannedFiles: c.ScannedFiles, Delete: false, - Repository: c.Repository, + Repository: RepositoryName(c.Repository), } } @@ -96,7 +96,7 @@ func (c *DeleteCLI) Option() *Option { ScannedFiles: c.ScannedFiles, Delete: true, Force: c.Force, - Repository: c.Repository, + Repository: RepositoryName(c.Repository), } } @@ -139,7 +139,7 @@ func (c *CLI) Run(ctx context.Context) error { switch c.command { case "generate": - return c.app.GenerateConfig(ctx, c.Config, c.Generate.Option()) + return c.app.GenerateConfig(ctx, c.Config) case "scan": return c.app.Run(ctx, c.Config, c.Scan.Option()) case "plan": diff --git a/ecrm.go b/ecrm.go index c6b36f5..4d67e5d 100644 --- a/ecrm.go +++ b/ecrm.go @@ -2,15 +2,10 @@ package ecrm import ( "context" - "encoding/json" "errors" "fmt" - "io" "log" - "os" - "sort" "strings" - "time" "github.com/Songmu/prompter" "github.com/aws/aws-sdk-go-v2/aws" @@ -19,9 +14,6 @@ import ( "github.com/aws/aws-sdk-go-v2/service/ecr" ecrTypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" "github.com/samber/lo" - - oci "github.com/google/go-containerregistry/pkg/v1" - ociTypes "github.com/google/go-containerregistry/pkg/v1/types" ) const ( @@ -38,31 +30,6 @@ type App struct { region string } -type Option struct { - ScanOnly bool - Scan bool - Delete bool - Force bool - Repository string - OutputFile string - Format outputFormat - ScannedFiles []string -} - -func (opt *Option) Validate() error { - if len(opt.ScannedFiles) == 0 && !opt.Scan { - return fmt.Errorf("no --scanned-files and --no-scan provided. specify at least one") - } - return nil -} - -func (opt *Option) OutputWriter() (io.WriteCloser, error) { - if opt.OutputFile == "" || opt.OutputFile == "-" { - return os.Stdout, nil - } - return os.Create(opt.OutputFile) -} - func New(ctx context.Context) (*App, error) { cfg, err := awsConfig.LoadDefaultConfig(ctx) if err != nil { @@ -77,139 +44,79 @@ func New(ctx context.Context) (*App, error) { func (app *App) Run(ctx context.Context, path string, opt *Option) error { if err := opt.Validate(); err != nil { - return err + return fmt.Errorf("invalid option: %w", err) } c, err := LoadConfig(path) if err != nil { - return err + return fmt.Errorf("failed to load config: %w", err) } scanner := NewScanner(app.awsCfg) if err := scanner.LoadFiles(opt.ScannedFiles); err != nil { - return err + return fmt.Errorf("failed to load scanned image URIs: %w", err) } if opt.Scan { if err := scanner.Scan(ctx, c); err != nil { - return err + return fmt.Errorf("failed to scan: %w", err) } } log.Println("[info] total", len(scanner.Images), "image URIs in use") if opt.ScanOnly { - w, err := opt.OutputWriter() - if err != nil { - return fmt.Errorf("failed to open output: %w", err) - } - defer w.Close() - if err := scanner.Save(w); err != nil { - return err - } - return nil + return ShowScanResult(scanner, opt) } - return app.DoDelete(ctx, c, opt, scanner.Images) -} - -func (app *App) DoDelete(ctx context.Context, c *Config, opt *Option, keepImages Images) error { - log.Println("[info] finding expired images") - // find candidates to delete - candidates, err := app.scanRepositories(ctx, c.Repositories, keepImages, opt) + planner := NewPlanner(app.awsCfg) + sums, candidates, err := planner.Plan(ctx, c.Repositories, scanner.Images, opt.Repository) if err != nil { - return err + return fmt.Errorf("failed to plan: %w", err) + } + if err := ShowSummary(sums, opt); err != nil { + return fmt.Errorf("failed to show summary: %w", err) } + if !opt.Delete { return nil } - for name, ids := range candidates { - if err := app.DeleteImages(ctx, name, ids, opt); err != nil { - return err + for _, name := range candidates.RepositoryNames() { + if err := app.DeleteImages(ctx, name, candidates[name], opt.Force); err != nil { + return fmt.Errorf("failed to delete images: %w", err) } } - return nil } -func (app *App) repositories(ctx context.Context) ([]ecrTypes.Repository, error) { - repos := make([]ecrTypes.Repository, 0) - p := ecr.NewDescribeRepositoriesPaginator(app.ecr, &ecr.DescribeRepositoriesInput{}) - for p.HasMorePages() { - repo, err := p.NextPage(ctx) - if err != nil { - return nil, err - } - repos = append(repos, repo.Repositories...) +func ShowScanResult(s *Scanner, opt *Option) error { + w, err := opt.OutputWriter() + if err != nil { + return fmt.Errorf("failed to open output: %w", err) } - return repos, nil + defer w.Close() + if err := s.Save(w); err != nil { + return fmt.Errorf("failed to save scanned image URIs: %w", err) + } + return nil } -type deletableImageIDs map[RepositoryName][]ecrTypes.ImageIdentifier - -// scanRepositories scans repositories and find expired images -// keepImages is a set of images in use by ECS tasks / task definitions / lambda functions -// so that they are not deleted -func (app *App) scanRepositories(ctx context.Context, rcs []*RepositoryConfig, keepImages Images, opt *Option) (deletableImageIDs, error) { - idsMaps := make(deletableImageIDs) - sums := SummaryTable{} - in := &ecr.DescribeRepositoriesInput{} - if opt.Repository != "" { - in.RepositoryNames = []string{opt.Repository} - } - p := ecr.NewDescribeRepositoriesPaginator(app.ecr, in) - for p.HasMorePages() { - repos, err := p.NextPage(ctx) - if err != nil { - return nil, err - } - REPO: - for _, repo := range repos.Repositories { - name := RepositoryName(*repo.RepositoryName) - var rc *RepositoryConfig - for _, _rc := range rcs { - if _rc.MatchName(name) { - rc = _rc - break - } - } - if rc == nil { - continue REPO - } - imageIDs, sum, err := app.unusedImageIdentifiers(ctx, name, rc, keepImages) - if err != nil { - return nil, err - } - sums = append(sums, sum...) - idsMaps[name] = imageIDs - } - } - sort.SliceStable(sums, func(i, j int) bool { - return sums[i].Repo < sums[j].Repo - }) - log.Printf("[info] output summary to %s as %s", opt.OutputFile, opt.Format) +func ShowSummary(s SummaryTable, opt *Option) error { w, err := opt.OutputWriter() if err != nil { - return nil, fmt.Errorf("failed to open output: %w", err) + return fmt.Errorf("failed to open output: %w", err) } defer w.Close() - if err := sums.print(w, opt.Format); err != nil { - return nil, err - } - return idsMaps, nil + return s.Print(w, opt.Format) } const batchDeleteImageIdsLimit = 100 const batchGetImageLimit = 100 // DeleteImages deletes images from the repository -func (app *App) DeleteImages(ctx context.Context, repo RepositoryName, ids []ecrTypes.ImageIdentifier, opt *Option) error { +func (app *App) DeleteImages(ctx context.Context, repo RepositoryName, ids []ecrTypes.ImageIdentifier, force bool) error { if len(ids) == 0 { log.Println("[info] no need to delete images on", repo) return nil } - if !opt.Delete { - log.Printf("[notice] Expired %d image(s) found on %s. Run delete command to delete them.", len(ids), repo) - return nil - } - if !opt.Force { + if !force { if !prompter.YN(fmt.Sprintf("Do you delete %d images on %s?", len(ids), repo), false) { return errors.New("aborted") } @@ -236,182 +143,9 @@ func (app *App) DeleteImages(ctx context.Context, repo RepositoryName, ids []ecr return nil } -// unusedImageIdentifiers finds image identifiers(by image digests) from the repository. -func (app *App) unusedImageIdentifiers(ctx context.Context, repo RepositoryName, rc *RepositoryConfig, keepImages Images) ([]ecrTypes.ImageIdentifier, RepoSummary, error) { - sums := NewRepoSummary(repo) - images, imageIndexes, sociIndexes, idByTags, err := app.listImageDetails(ctx, repo) - if err != nil { - return nil, sums, err - } - log.Printf("[info] %s has %d images, %d image indexes, %d soci indexes", repo, len(images), len(imageIndexes), len(sociIndexes)) - expiredIds := make([]ecrTypes.ImageIdentifier, 0) - expiredImageIndexes := newSet() - var keepCount int64 -IMAGE: - for _, d := range images { - tag, tagged := imageTag(d) - displayName := string(repo) + ":" + tag - sums.Add(d) - - // Check if the image is in use (digest) - imageURISha256 := ImageURI(fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com/%s@%s", *d.RegistryId, app.region, *d.RepositoryName, *d.ImageDigest)) - log.Printf("[debug] checking %s", imageURISha256) - if keepImages.Contains(imageURISha256) { - log.Printf("[info] %s@%s is in used, keep it", repo, *d.ImageDigest) - continue IMAGE - } - - // Check if the image is in use or conditions (tag) - for _, tag := range d.ImageTags { - if rc.MatchTag(tag) { - log.Printf("[info] image %s:%s is matched by tag condition, keep it", repo, tag) - continue IMAGE - } - imageURI := ImageURI(fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com/%s:%s", *d.RegistryId, app.region, *d.RepositoryName, tag)) - log.Printf("[debug] checking %s", imageURI) - if keepImages.Contains(imageURI) { - log.Printf("[info] image %s:%s is in used, keep it", repo, tag) - continue IMAGE - } - } - - // Check if the image is expired - pushedAt := *d.ImagePushedAt - if !rc.IsExpired(pushedAt) { - log.Printf("[info] image %s is not expired, keep it", displayName) - continue IMAGE - } - - if tagged { - keepCount++ - if keepCount <= rc.KeepCount { - log.Printf("[info] image %s is in keep_count %d <= %d, keep it", displayName, keepCount, rc.KeepCount) - continue IMAGE - } - } - - // Don't match any conditions, so expired - log.Printf("[notice] image %s is expired %s %s", displayName, *d.ImageDigest, pushedAt.Format(time.RFC3339)) - expiredIds = append(expiredIds, ecrTypes.ImageIdentifier{ImageDigest: d.ImageDigest}) - sums.Expire(d) - - tagSha256 := strings.Replace(*d.ImageDigest, "sha256:", "sha256-", 1) - if _, found := idByTags[tagSha256]; found { - expiredImageIndexes.add(tagSha256) - } - } - -IMAGE_INDEX: - for _, d := range imageIndexes { - log.Printf("[debug] is an image index %s", *d.ImageDigest) - sums.Add(d) - for _, tag := range d.ImageTags { - if expiredImageIndexes.contains(tag) { - log.Printf("[notice] %s:%s is expired (image index)", repo, tag) - sums.Expire(d) - expiredIds = append(expiredIds, ecrTypes.ImageIdentifier{ImageDigest: d.ImageDigest}) - continue IMAGE_INDEX - } - } - } - - sociIds, err := app.findSociIndex(ctx, repo, expiredImageIndexes.members()) - if err != nil { - return nil, sums, err - } - -SOCI_INDEX: - for _, d := range sociIndexes { - log.Printf("[debug] is soci index %s", *d.ImageDigest) - sums.Add(d) - for _, id := range sociIds { - if aws.ToString(id.ImageDigest) == aws.ToString(d.ImageDigest) { - log.Printf("[notice] %s@%s is expired (soci index)", repo, *d.ImageDigest) - sums.Expire(d) - expiredIds = append(expiredIds, ecrTypes.ImageIdentifier{ImageDigest: d.ImageDigest}) - continue SOCI_INDEX - } - } - } - - return expiredIds, sums, nil -} - -func (app *App) listImageDetails(ctx context.Context, repo RepositoryName) ([]ecrTypes.ImageDetail, []ecrTypes.ImageDetail, []ecrTypes.ImageDetail, map[string]ecrTypes.ImageIdentifier, error) { - var images, imageIndexes, sociIndexes []ecrTypes.ImageDetail - foundTags := make(map[string]ecrTypes.ImageIdentifier, 0) - - p := ecr.NewDescribeImagesPaginator(app.ecr, &ecr.DescribeImagesInput{ - RepositoryName: aws.String(string(repo)), - }) - for p.HasMorePages() { - imgs, err := p.NextPage(ctx) - if err != nil { - return nil, nil, nil, nil, err - } - for _, img := range imgs.ImageDetails { - if isContainerImage(img) { - images = append(images, img) - } else if isImageIndex(img) { - imageIndexes = append(imageIndexes, img) - } else if isSociIndex(img) { - sociIndexes = append(sociIndexes, img) - } - for _, tag := range img.ImageTags { - foundTags[tag] = ecrTypes.ImageIdentifier{ImageDigest: img.ImageDigest} - } - } - } - - sort.SliceStable(images, func(i, j int) bool { - return images[i].ImagePushedAt.After(*images[j].ImagePushedAt) - }) - sort.SliceStable(imageIndexes, func(i, j int) bool { - return imageIndexes[i].ImagePushedAt.After(*imageIndexes[j].ImagePushedAt) - }) - sort.SliceStable(sociIndexes, func(i, j int) bool { - return sociIndexes[i].ImagePushedAt.After(*sociIndexes[j].ImagePushedAt) - }) - return images, imageIndexes, sociIndexes, foundTags, nil -} - -func (app *App) findSociIndex(ctx context.Context, repo RepositoryName, imageTags []string) ([]ecrTypes.ImageIdentifier, error) { - ids := make([]ecrTypes.ImageIdentifier, 0, len(imageTags)) - - for _, c := range lo.Chunk(imageTags, batchGetImageLimit) { - imageIds := make([]ecrTypes.ImageIdentifier, 0, len(c)) - for _, tag := range c { - imageIds = append(imageIds, ecrTypes.ImageIdentifier{ImageTag: aws.String(tag)}) - } - res, err := app.ecr.BatchGetImage(ctx, &ecr.BatchGetImageInput{ - ImageIds: imageIds, - RepositoryName: aws.String(string(repo)), - AcceptedMediaTypes: []string{ - string(ociTypes.OCIManifestSchema1), - string(ociTypes.DockerManifestSchema1), - string(ociTypes.DockerManifestSchema2), - }, - }) - if err != nil { - return nil, err - } - for _, img := range res.Images { - if img.ImageManifest == nil { - continue - } - var m oci.IndexManifest - if err := json.Unmarshal([]byte(*img.ImageManifest), &m); err != nil { - log.Printf("[warn] failed to parse manifest: %s %s", *img.ImageManifest, err) - continue - } - for _, d := range m.Manifests { - if d.ArtifactType == MediaTypeSociIndex { - ids = append(ids, ecrTypes.ImageIdentifier{ImageDigest: aws.String(d.Digest.String())}) - } - } - } - } - return ids, nil +func (app *App) GenerateConfig(ctx context.Context, path string) error { + g := NewGenerator(app.awsCfg) + return g.GenerateConfig(ctx, path) } func imageTag(d ecrTypes.ImageDetail) (string, bool) { diff --git a/generate.go b/generate.go index 8d70ca4..0f8b043 100644 --- a/generate.go +++ b/generate.go @@ -12,6 +12,9 @@ import ( "strings" "github.com/Songmu/prompter" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecr" + ecrTypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" "github.com/aws/aws-sdk-go-v2/service/ecs" "github.com/aws/aws-sdk-go-v2/service/lambda" "github.com/goccy/go-yaml" @@ -27,18 +30,28 @@ func nameToPattern(s string) string { return s } -func (app *App) GenerateConfig(ctx context.Context, configFile string, opt *Option) error { +type Generator struct { + awsCfg aws.Config +} + +func NewGenerator(cfg aws.Config) *Generator { + return &Generator{ + awsCfg: cfg, + } +} + +func (g *Generator) GenerateConfig(ctx context.Context, configFile string) error { config := Config{} - if err := app.generateClusterConfig(ctx, &config); err != nil { + if err := g.generateClusterConfig(ctx, &config); err != nil { return err } - if err := app.generateTaskdefConfig(ctx, &config); err != nil { + if err := g.generateTaskdefConfig(ctx, &config); err != nil { return err } - if err := app.generateLambdaConfig(ctx, &config); err != nil { + if err := g.generateLambdaConfig(ctx, &config); err != nil { return err } - if err := app.generateRepositoryConfig(ctx, &config); err != nil { + if err := g.generateRepositoryConfig(ctx, &config); err != nil { return err } @@ -61,8 +74,8 @@ func (app *App) GenerateConfig(ctx context.Context, configFile string, opt *Opti return nil } -func (app *App) generateClusterConfig(ctx context.Context, config *Config) error { - clusters, err := clusterArns(ctx, ecs.NewFromConfig(app.awsCfg)) +func (g *Generator) generateClusterConfig(ctx context.Context, config *Config) error { + clusters, err := clusterArns(ctx, ecs.NewFromConfig(g.awsCfg)) if err != nil { return err } @@ -91,8 +104,8 @@ func (app *App) generateClusterConfig(ctx context.Context, config *Config) error return nil } -func (app *App) generateTaskdefConfig(ctx context.Context, config *Config) error { - taskdefs, err := taskDefinitionFamilies(ctx, ecs.NewFromConfig(app.awsCfg)) +func (g *Generator) generateTaskdefConfig(ctx context.Context, config *Config) error { + taskdefs, err := taskDefinitionFamilies(ctx, ecs.NewFromConfig(g.awsCfg)) if err != nil { return err } @@ -123,8 +136,8 @@ func (app *App) generateTaskdefConfig(ctx context.Context, config *Config) error return nil } -func (app *App) generateLambdaConfig(ctx context.Context, config *Config) error { - lambdas, err := lambdaFunctions(ctx, lambda.NewFromConfig(app.awsCfg)) +func (g *Generator) generateLambdaConfig(ctx context.Context, config *Config) error { + lambdas, err := lambdaFunctions(ctx, lambda.NewFromConfig(g.awsCfg)) if err != nil { return err } @@ -156,8 +169,8 @@ func (app *App) generateLambdaConfig(ctx context.Context, config *Config) error return nil } -func (app *App) generateRepositoryConfig(ctx context.Context, config *Config) error { - repos, err := app.repositories(ctx) +func (g *Generator) generateRepositoryConfig(ctx context.Context, config *Config) error { + repos, err := g.repositories(ctx) if err != nil { return err } @@ -189,3 +202,16 @@ func (app *App) generateRepositoryConfig(ctx context.Context, config *Config) er }) return nil } + +func (g *Generator) repositories(ctx context.Context) ([]ecrTypes.Repository, error) { + repos := make([]ecrTypes.Repository, 0) + p := ecr.NewDescribeRepositoriesPaginator(ecr.NewFromConfig(g.awsCfg), &ecr.DescribeRepositoriesInput{}) + for p.HasMorePages() { + repo, err := p.NextPage(ctx) + if err != nil { + return nil, err + } + repos = append(repos, repo.Repositories...) + } + return repos, nil +} diff --git a/option.go b/option.go new file mode 100644 index 0000000..9fec84e --- /dev/null +++ b/option.go @@ -0,0 +1,39 @@ +package ecrm + +import ( + "fmt" + "io" + "os" +) + +type Option struct { + ScanOnly bool + Scan bool + Delete bool + Force bool + Repository RepositoryName + OutputFile string + Format outputFormat + ScannedFiles []string +} + +func (opt *Option) Validate() error { + if len(opt.ScannedFiles) == 0 && !opt.Scan { + return fmt.Errorf("no --scanned-files and --no-scan provided. specify at least one") + } + return nil +} + +// NopCloserWriter is a writer that does nothing on Close +type NopCloserWriter struct { + io.Writer +} + +func (NopCloserWriter) Close() error { return nil } + +func (opt *Option) OutputWriter() (io.WriteCloser, error) { + if opt.OutputFile == "" || opt.OutputFile == "-" { + return NopCloserWriter{os.Stdout}, nil + } + return os.Create(opt.OutputFile) +} diff --git a/planner.go b/planner.go new file mode 100644 index 0000000..c3eac9a --- /dev/null +++ b/planner.go @@ -0,0 +1,260 @@ +package ecrm + +import ( + "context" + "encoding/json" + "fmt" + "log" + "sort" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecr" + ecrTypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" + oci "github.com/google/go-containerregistry/pkg/v1" + ociTypes "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/samber/lo" +) + +type Planner struct { + ecr *ecr.Client + region string +} + +func NewPlanner(cfg aws.Config) *Planner { + return &Planner{ + ecr: ecr.NewFromConfig(cfg), + region: cfg.Region, + } +} + +type DeletableImageIDs map[RepositoryName][]ecrTypes.ImageIdentifier + +func (d DeletableImageIDs) RepositoryNames() []RepositoryName { + names := lo.Keys(d) + sort.Slice(names, func(i, j int) bool { + return names[i] < names[j] + }) + return names +} + +// Plan scans repositories and find expired images, and returns a summary table and a map of deletable image identifiers. +// +// keepImages is a set of images in use by ECS tasks / task definitions / lambda functions +// so that they are not deleted +func (p *Planner) Plan(ctx context.Context, rcs []*RepositoryConfig, keepImages Images, repo RepositoryName) (SummaryTable, DeletableImageIDs, error) { + idsMaps := make(DeletableImageIDs) + sums := SummaryTable{} + in := &ecr.DescribeRepositoriesInput{} + if repo != "" { + in.RepositoryNames = []string{string(repo)} + } + pager := ecr.NewDescribeRepositoriesPaginator(p.ecr, in) + for pager.HasMorePages() { + repos, err := pager.NextPage(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to describe repositories: %w", err) + } + REPO: + for _, repo := range repos.Repositories { + name := RepositoryName(*repo.RepositoryName) + var rc *RepositoryConfig + for _, _rc := range rcs { + if _rc.MatchName(name) { + rc = _rc + break + } + } + if rc == nil { + continue REPO + } + imageIDs, sum, err := p.unusedImageIdentifiers(ctx, name, rc, keepImages) + if err != nil { + return nil, nil, fmt.Errorf("failed to find unused image identifiers: %w", err) + } + sums = append(sums, sum...) + idsMaps[name] = imageIDs + } + } + sums.Sort() + return sums, idsMaps, nil +} + +// unusedImageIdentifiers finds image identifiers(by image digests) from the repository. +func (p *Planner) unusedImageIdentifiers(ctx context.Context, repo RepositoryName, rc *RepositoryConfig, keepImages Images) ([]ecrTypes.ImageIdentifier, RepoSummary, error) { + sums := NewRepoSummary(repo) + images, imageIndexes, sociIndexes, idByTags, err := p.listImageDetails(ctx, repo) + if err != nil { + return nil, sums, err + } + log.Printf("[info] %s has %d images, %d image indexes, %d soci indexes", repo, len(images), len(imageIndexes), len(sociIndexes)) + expiredIds := make([]ecrTypes.ImageIdentifier, 0) + expiredImageIndexes := newSet() + var keepCount int64 +IMAGE: + for _, d := range images { + tag, tagged := imageTag(d) + displayName := string(repo) + ":" + tag + sums.Add(d) + + // Check if the image is in use (digest) + imageURISha256 := ImageURI(fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com/%s@%s", *d.RegistryId, p.region, *d.RepositoryName, *d.ImageDigest)) + log.Printf("[debug] checking %s", imageURISha256) + if keepImages.Contains(imageURISha256) { + log.Printf("[info] %s@%s is in used, keep it", repo, *d.ImageDigest) + continue IMAGE + } + + // Check if the image is in use or conditions (tag) + for _, tag := range d.ImageTags { + if rc.MatchTag(tag) { + log.Printf("[info] image %s:%s is matched by tag condition, keep it", repo, tag) + continue IMAGE + } + imageURI := ImageURI(fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com/%s:%s", *d.RegistryId, p.region, *d.RepositoryName, tag)) + log.Printf("[debug] checking %s", imageURI) + if keepImages.Contains(imageURI) { + log.Printf("[info] image %s:%s is in used, keep it", repo, tag) + continue IMAGE + } + } + + // Check if the image is expired + pushedAt := *d.ImagePushedAt + if !rc.IsExpired(pushedAt) { + log.Printf("[info] image %s is not expired, keep it", displayName) + continue IMAGE + } + + if tagged { + keepCount++ + if keepCount <= rc.KeepCount { + log.Printf("[info] image %s is in keep_count %d <= %d, keep it", displayName, keepCount, rc.KeepCount) + continue IMAGE + } + } + + // Don't match any conditions, so expired + log.Printf("[notice] image %s is expired %s %s", displayName, *d.ImageDigest, pushedAt.Format(time.RFC3339)) + expiredIds = append(expiredIds, ecrTypes.ImageIdentifier{ImageDigest: d.ImageDigest}) + sums.Expire(d) + + tagSha256 := strings.Replace(*d.ImageDigest, "sha256:", "sha256-", 1) + if _, found := idByTags[tagSha256]; found { + expiredImageIndexes.add(tagSha256) + } + } + +IMAGE_INDEX: + for _, d := range imageIndexes { + log.Printf("[debug] is an image index %s", *d.ImageDigest) + sums.Add(d) + for _, tag := range d.ImageTags { + if expiredImageIndexes.contains(tag) { + log.Printf("[notice] %s:%s is expired (image index)", repo, tag) + sums.Expire(d) + expiredIds = append(expiredIds, ecrTypes.ImageIdentifier{ImageDigest: d.ImageDigest}) + continue IMAGE_INDEX + } + } + } + + sociIds, err := p.findSociIndex(ctx, repo, expiredImageIndexes.members()) + if err != nil { + return nil, sums, fmt.Errorf("failed to find soci index: %w", err) + } + +SOCI_INDEX: + for _, d := range sociIndexes { + log.Printf("[debug] is soci index %s", *d.ImageDigest) + sums.Add(d) + for _, id := range sociIds { + if aws.ToString(id.ImageDigest) == aws.ToString(d.ImageDigest) { + log.Printf("[notice] %s@%s is expired (soci index)", repo, *d.ImageDigest) + sums.Expire(d) + expiredIds = append(expiredIds, ecrTypes.ImageIdentifier{ImageDigest: d.ImageDigest}) + continue SOCI_INDEX + } + } + } + + return expiredIds, sums, nil +} + +func (p *Planner) listImageDetails(ctx context.Context, repo RepositoryName) ([]ecrTypes.ImageDetail, []ecrTypes.ImageDetail, []ecrTypes.ImageDetail, map[string]ecrTypes.ImageIdentifier, error) { + var images, imageIndexes, sociIndexes []ecrTypes.ImageDetail + foundTags := make(map[string]ecrTypes.ImageIdentifier, 0) + + pager := ecr.NewDescribeImagesPaginator(p.ecr, &ecr.DescribeImagesInput{ + RepositoryName: aws.String(string(repo)), + }) + for pager.HasMorePages() { + imgs, err := pager.NextPage(ctx) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to describe images: %w", err) + } + for _, img := range imgs.ImageDetails { + if isContainerImage(img) { + images = append(images, img) + } else if isImageIndex(img) { + imageIndexes = append(imageIndexes, img) + } else if isSociIndex(img) { + sociIndexes = append(sociIndexes, img) + } + for _, tag := range img.ImageTags { + foundTags[tag] = ecrTypes.ImageIdentifier{ImageDigest: img.ImageDigest} + } + } + } + + sort.SliceStable(images, func(i, j int) bool { + return images[i].ImagePushedAt.After(*images[j].ImagePushedAt) + }) + sort.SliceStable(imageIndexes, func(i, j int) bool { + return imageIndexes[i].ImagePushedAt.After(*imageIndexes[j].ImagePushedAt) + }) + sort.SliceStable(sociIndexes, func(i, j int) bool { + return sociIndexes[i].ImagePushedAt.After(*sociIndexes[j].ImagePushedAt) + }) + return images, imageIndexes, sociIndexes, foundTags, nil +} + +func (p *Planner) findSociIndex(ctx context.Context, repo RepositoryName, imageTags []string) ([]ecrTypes.ImageIdentifier, error) { + ids := make([]ecrTypes.ImageIdentifier, 0, len(imageTags)) + + for _, c := range lo.Chunk(imageTags, batchGetImageLimit) { + imageIds := make([]ecrTypes.ImageIdentifier, 0, len(c)) + for _, tag := range c { + imageIds = append(imageIds, ecrTypes.ImageIdentifier{ImageTag: aws.String(tag)}) + } + res, err := p.ecr.BatchGetImage(ctx, &ecr.BatchGetImageInput{ + ImageIds: imageIds, + RepositoryName: aws.String(string(repo)), + AcceptedMediaTypes: []string{ + string(ociTypes.OCIManifestSchema1), + string(ociTypes.DockerManifestSchema1), + string(ociTypes.DockerManifestSchema2), + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to batch get image: %w", err) + } + for _, img := range res.Images { + if img.ImageManifest == nil { + continue + } + var m oci.IndexManifest + if err := json.Unmarshal([]byte(*img.ImageManifest), &m); err != nil { + log.Printf("[warn] failed to parse manifest: %s %s", *img.ImageManifest, err) + continue + } + for _, d := range m.Manifests { + if d.ArtifactType == MediaTypeSociIndex { + ids = append(ids, ecrTypes.ImageIdentifier{ImageDigest: aws.String(d.Digest.String())}) + } + } + } + } + return ids, nil +} diff --git a/summary.go b/summary.go index 96c7728..041389c 100644 --- a/summary.go +++ b/summary.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "log" + "sort" "strings" "github.com/aws/aws-sdk-go-v2/aws" @@ -120,7 +121,13 @@ const ( type SummaryTable []*Summary -func (s *SummaryTable) print(w io.Writer, format outputFormat) error { +func (s SummaryTable) Sort() { + sort.SliceStable(s, func(i, j int) bool { + return s[i].Repo < s[j].Repo + }) +} + +func (s *SummaryTable) Print(w io.Writer, format outputFormat) error { switch format { case formatTable: return s.printTable(w)