Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions aws-janitor/resources/ecr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/*
Copyright 2025 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package resources

import (
"context"
"fmt"
"slices"
"time"

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

"github.com/sirupsen/logrus"
)

type ContainerImages struct{}

//nolint:gocognit // Just over the complexity threshold and splitting would make it less readable
func (ContainerImages) MarkAndSweep(opts Options, set *Set) error {
logger := logrus.WithField("options", opts)
if len(opts.CleanEcrRepositories) == 0 {
logger.Info("No ECR reposotories to clean provided, skipping ECR")
return nil
}

svc := ecr.NewFromConfig(*opts.Config, func(opt *ecr.Options) {
opt.Region = opts.Region
})
inp := &ecr.DescribeRepositoriesInput{
RegistryId: aws.String(opts.Account),
}

// DescribeImages requires a repository name, so we first must crawl all repositories
err := DescribeRepositoriesPages(svc, inp, func(repos *ecr.DescribeRepositoriesOutput) error {
for _, repo := range repos.Repositories {
if !slices.Contains(opts.CleanEcrRepositories, *repo.RepositoryName) {
continue
}

// BatchDeleteImage can only be called per-repo, so describe all the images in a repo, delete them, and repeat
var toDelete []*image
imageInp := &ecr.DescribeImagesInput{
RegistryId: repo.RegistryId,
RepositoryName: repo.RepositoryName,
}

imagesErr := DescribeImagesPages(svc, imageInp, func(images *ecr.DescribeImagesOutput) error {
for _, ecrImage := range images.ImageDetails {
i := image{
Registry: *ecrImage.RegistryId,
Region: opts.Region,
Repository: *ecrImage.RepositoryName,
Digest: *ecrImage.ImageDigest,
}

// ECR repos cannot have tags, so pass an empty object
if !set.Mark(opts, i, ecrImage.ImagePushedAt, Tags{}) {
continue
}
toDelete = append(toDelete, &i)
}
return nil
}, false /* continue on error */)
if imagesErr != nil {
logrus.Warningf("failed to get page: %v", imagesErr)
}

deleteReq := &ecr.BatchDeleteImageInput{
RegistryId: repo.RegistryId,
RepositoryName: repo.RepositoryName,
ImageIds: []types.ImageIdentifier{},
}
for n, image := range toDelete {
deleteReq.ImageIds = append(deleteReq.ImageIds, types.ImageIdentifier{
ImageDigest: aws.String(image.Digest),
})

// 100 images max: https://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_BatchDeleteImage.html
// If we've reached 100 images, or this is the last item in the toDelete slice, make the call to the AWS API
if len(deleteReq.ImageIds) == 100 || n == len(toDelete)-1 {
logger.Warningf("%s: deleting %d images", *repo.RepositoryArn, len(deleteReq.ImageIds))
if !opts.DryRun {
result, err := svc.BatchDeleteImage(context.TODO(), deleteReq)

if err != nil {
logger.Warningf("%s: delete API call failed: %v", *repo.RepositoryArn, err)
} else if len(result.Failures) > 0 {
logger.Warningf("%s: some images failed to delete: %T", *repo.RepositoryArn, result.Failures)
}
}
// After making delete call, reset to an empty set of images
deleteReq.ImageIds = []types.ImageIdentifier{}
}
}
}
return nil
}, false /* continue on error */)
if err != nil {
logrus.Warningf("failed to get page: %v", err)
}

return nil
}

func (ContainerImages) ListAll(opts Options) (*Set, error) {
set := NewSet(0)
if len(opts.CleanEcrRepositories) == 0 {
return set, nil
}
svc := ecr.NewFromConfig(*opts.Config, func(opt *ecr.Options) {
opt.Region = opts.Region
})
inp := &ecr.DescribeRepositoriesInput{
RegistryId: aws.String(opts.Account),
}

err := DescribeRepositoriesPages(svc, inp, func(repos *ecr.DescribeRepositoriesOutput) error {
for _, repo := range repos.Repositories {
if !slices.Contains(opts.CleanEcrRepositories, *repo.RepositoryName) {
continue
}

imageInp := &ecr.DescribeImagesInput{
RegistryId: repo.RegistryId,
RepositoryName: repo.RepositoryName,
}

err := DescribeImagesPages(svc, imageInp, func(images *ecr.DescribeImagesOutput) error {
now := time.Now()
for _, ecrImage := range images.ImageDetails {
key := image{
Registry: *ecrImage.RegistryId,
Region: opts.Region,
Repository: *ecrImage.RepositoryName,
Digest: *ecrImage.ImageDigest,
}.ResourceKey()

set.firstSeen[key] = now
}
return nil
}, true /* fail on error */)

if err != nil {
return err
}
}
return nil
}, true /* fail on error */)

return set, err
}

//nolint:nestif // Ifs are small and nesting cannot be reasonably avoided
func DescribeRepositoriesPages(svc *ecr.Client, input *ecr.DescribeRepositoriesInput, pageFunc func(repos *ecr.DescribeRepositoriesOutput) error, failOnError bool) error {
paginator := ecr.NewDescribeRepositoriesPaginator(svc, input)

for paginator.HasMorePages() {
page, err := paginator.NextPage(context.TODO())
if err != nil {
logrus.Warningf("failed to get page: %v", err)
if failOnError {
return err
}
} else {
err = pageFunc(page)
if err != nil {
logrus.Warningf("pageFunc failed: %v", err)
if failOnError {
return err
}
}
}
}
return nil
}

//nolint:nestif // Ifs are small and nesting cannot be reasonably avoided
func DescribeImagesPages(svc *ecr.Client, input *ecr.DescribeImagesInput, pageFunc func(images *ecr.DescribeImagesOutput) error, failOnError bool) error {
paginator := ecr.NewDescribeImagesPaginator(svc, input)

for paginator.HasMorePages() {
page, err := paginator.NextPage(context.TODO())
if err != nil {
logrus.Warningf("failed to get page: %v", err)
if failOnError {
return err
}
} else {
err = pageFunc(page)
if err != nil {
logrus.Warningf("pageFunc failed: %v", err)
if failOnError {
return err
}
}
}
}
return nil
}

type image struct {
Registry string
Region string
Repository string
Digest string
}

// Images don't have an ARN, only repositories do, so we use our own custom key format
func (i image) ARN() string {
return i.ResourceKey()
}

func (i image) ResourceKey() string {
return fmt.Sprintf("%s:%s/%s:%s", i.Registry, i.Region, i.Repository, i.Digest)
}
4 changes: 4 additions & 0 deletions aws-janitor/resources/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ type Options struct {
// If true, clean S3 Buckets.
EnableS3BucketsClean bool

// List of ECR repositories to clean (only cleans images inside).
CleanEcrRepositories []string

// Resource record set types that shoud not be deleted.
SkipResourceRecordSetTypes map[string]bool
}
Expand Down Expand Up @@ -104,6 +107,7 @@ var RegionalTypeList = []Type{
TargetGroups{},
KeyPairs{},
S3Bucket{},
ContainerImages{},
}

// Non-regional AWS resource types, in dependency order
Expand Down
3 changes: 3 additions & 0 deletions cmd/aws-janitor-boskos/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ var (
excludeTags common.CommaSeparatedStrings
includeTags common.CommaSeparatedStrings
skipResourceRecordSetTypes common.CommaSeparatedStrings
cleanEcrRepositories common.CommaSeparatedStrings

excludeTM resources.TagMatcher
includeTM resources.TagMatcher
Expand Down Expand Up @@ -93,6 +94,7 @@ func init() {
flag.Var(&includeTags, "include-tags",
"Resources must include all of these tags in order to be managed by the janitor. Given as a comma-separated list of tags in key[=value] format; excluding the value will match any tag with that key. Keys can be repeated.")
flag.Var(&skipResourceRecordSetTypes, "skip-resource-record-set-types", "A list of resource record types which should not be deleted, splitted using comma.")
flag.Var(&cleanEcrRepositories, "clean-ecr-repositories", "Comma-separated list of ECR repositories for which to clean images.")

prometheus.MustRegister(cleaningTimeHistogram)
prometheus.MustRegister(sweepsGauge)
Expand Down Expand Up @@ -218,6 +220,7 @@ func cleanResource(res *common.Resource) error {
SkipRoute53ManagementCheck: *skipRoute53ManagementCheck,
EnableDNSZoneClean: *enableDNSZoneClean,
EnableS3BucketsClean: *enableS3BucketsClean,
CleanEcrRepositories: cleanEcrRepositories,
SkipResourceRecordSetTypes: skipResourceRecordSetTypesSet,
}

Expand Down
3 changes: 3 additions & 0 deletions cmd/aws-janitor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ var (
excludeTags common.CommaSeparatedStrings
includeTags common.CommaSeparatedStrings
skipResourceRecordSetTypes common.CommaSeparatedStrings
cleanEcrRepositories common.CommaSeparatedStrings

sweepCount int

Expand All @@ -80,6 +81,7 @@ func init() {
flag.Var(&includeTags, "include-tags",
"Resources must include all of these tags in order to be managed by the janitor. Given as a comma-separated list of tags in key[=value] format; excluding the value will match any tag with that key. Keys can be repeated.")
flag.Var(&skipResourceRecordSetTypes, "skip-resource-record-set-types", "A list of resource record types which should not be deleted, splitted using comma.")
flag.Var(&cleanEcrRepositories, "clean-ecr-repositories", "Comma-separated list of ECR repositories for which to clean images.")
}

func main() {
Expand Down Expand Up @@ -168,6 +170,7 @@ func main() {
SkipRoute53ManagementCheck: *skipRoute53ManagementCheck,
EnableDNSZoneClean: *enableDNSZoneClean,
EnableS3BucketsClean: *enableS3BucketsClean,
CleanEcrRepositories: cleanEcrRepositories,
SkipResourceRecordSetTypes: skipResourceRecordSetTypesSet,
}

Expand Down
9 changes: 5 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ require (
github.com/IBM/go-sdk-core/v5 v5.19.1
github.com/IBM/platform-services-go-sdk v0.80.0
github.com/IBM/vpc-go-sdk v0.68.0
github.com/aws/aws-sdk-go-v2 v1.30.3
github.com/aws/aws-sdk-go-v2 v1.38.1
github.com/aws/aws-sdk-go-v2/config v1.27.27
github.com/aws/aws-sdk-go-v2/credentials v1.17.27
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.9
github.com/aws/aws-sdk-go-v2/service/autoscaling v1.43.3
github.com/aws/aws-sdk-go-v2/service/cloudformation v1.53.3
github.com/aws/aws-sdk-go-v2/service/ec2 v1.173.0
github.com/aws/aws-sdk-go-v2/service/ecr v1.49.2
github.com/aws/aws-sdk-go-v2/service/efs v1.31.3
github.com/aws/aws-sdk-go-v2/service/eks v1.47.0
github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing v1.26.3
Expand All @@ -24,7 +25,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2
github.com/aws/aws-sdk-go-v2/service/sqs v1.34.3
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3
github.com/aws/smithy-go v1.20.3
github.com/aws/smithy-go v1.22.5
github.com/fsnotify/fsnotify v1.6.0
github.com/go-test/deep v1.0.7
github.com/google/go-cmp v0.7.0
Expand Down Expand Up @@ -59,8 +60,8 @@ require (
github.com/aws/aws-sdk-go v1.44.72 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
Expand Down
18 changes: 10 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ github.com/aws/aws-sdk-go v1.19.18/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpi
github.com/aws/aws-sdk-go v1.19.45/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go v1.44.72 h1:i7J5XT7pjBjtl1OrdIhiQHzsG89wkZCcM1HhyK++3DI=
github.com/aws/aws-sdk-go v1.44.72/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
github.com/aws/aws-sdk-go-v2 v1.38.1 h1:j7sc33amE74Rz0M/PoCpsZQ6OunLqys/m5antM0J+Z8=
github.com/aws/aws-sdk-go-v2 v1.38.1/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90=
Expand All @@ -124,10 +124,10 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.9 h1:TC2vjvaAv1VNl9A0rm+SeuBjrzXnrlwk6Yop+gKRi38=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.9/go.mod h1:WPv2FRnkIOoDv/8j2gSUsI4qDc7392w5anFB/I89GZ8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4 h1:IdCLsiiIj5YJ3AFevsewURCPV+YWUlOW8JiPhoAy8vg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4/go.mod h1:l4bdfCD7XyyZA9BolKBo1eLqgaJxl0/x91PL4Yqe0ao=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4 h1:j7vjtr1YIssWQOMeOWRbh3z8g2oY/xPjnZH2gLY4sGw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4/go.mod h1:yDmJgqOiH4EA8Hndnv4KwAo8jCGTSnM5ASG1nBI+toA=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 h1:Z5r7SycxmSllHYmaAZPpmN8GviDrSGhMS6bldqtXZPw=
Expand All @@ -138,6 +138,8 @@ github.com/aws/aws-sdk-go-v2/service/cloudformation v1.53.3 h1:mIpL+FXa+2U6oc85b
github.com/aws/aws-sdk-go-v2/service/cloudformation v1.53.3/go.mod h1:lcQ7+K0Q9x0ozhjBwDfBkuY8qexSP/QXLgp0jj+/NZg=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.173.0 h1:ta62lid9JkIpKZtZZXSj6rP2AqY5x1qYGq53ffxqD9Q=
github.com/aws/aws-sdk-go-v2/service/ec2 v1.173.0/go.mod h1:o6QDjdVKpP5EF0dp/VlvqckzuSDATr1rLdHt3A5m0YY=
github.com/aws/aws-sdk-go-v2/service/ecr v1.49.2 h1:aFmDHNrMqJb7Um0wusnZ8lqDcYTf0+RXxSvmCuelBiM=
github.com/aws/aws-sdk-go-v2/service/ecr v1.49.2/go.mod h1:Knlx5anjbiHqbCdnOabD+soFqsJIx2RdKf5R9SoBuUg=
github.com/aws/aws-sdk-go-v2/service/efs v1.31.3 h1:vHNTbv0pFB/E19MokZcWAxZIggWgcLlcixNePBe6iZc=
github.com/aws/aws-sdk-go-v2/service/efs v1.31.3/go.mod h1:P1X7sDHKpqZCLac7bRsFF/EN2REOgmeKStQTa14FpEA=
github.com/aws/aws-sdk-go-v2/service/eks v1.47.0 h1:u0VeIQ02COfhmp37ub8zv29bdRtosCYzXoWd+QRebbY=
Expand Down Expand Up @@ -170,8 +172,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrA
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE=
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
Expand Down