Skip to content

Commit 4cb0d19

Browse files
committed
Add support for cleaning ECR images in aws-janitor
Signed-off-by: Connor Catlett <[email protected]>
1 parent e9e5322 commit 4cb0d19

File tree

6 files changed

+241
-12
lines changed

6 files changed

+241
-12
lines changed

aws-janitor/resources/ecr.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package resources
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"time"
23+
24+
"github.com/aws/aws-sdk-go-v2/aws"
25+
"github.com/aws/aws-sdk-go-v2/service/ecr"
26+
"github.com/aws/aws-sdk-go-v2/service/ecr/types"
27+
28+
"github.com/sirupsen/logrus"
29+
)
30+
31+
type ContainerImages struct{}
32+
33+
//nolint:gocognit // Just over the complexity threshold and splitting would make it less readable
34+
func (ContainerImages) MarkAndSweep(opts Options, set *Set) error {
35+
if !opts.EnableECRImagesClean {
36+
return nil
37+
}
38+
logger := logrus.WithField("options", opts)
39+
svc := ecr.NewFromConfig(*opts.Config, func(opt *ecr.Options) {
40+
opt.Region = opts.Region
41+
})
42+
inp := &ecr.DescribeRepositoriesInput{
43+
RegistryId: aws.String(opts.Account),
44+
}
45+
46+
// DescribeImages requires a repository name, so we first must crawl all repositories
47+
err := DescribeRepositoriesPages(svc, inp, func(repos *ecr.DescribeRepositoriesOutput) error {
48+
for _, repo := range repos.Repositories {
49+
// BatchDeleteImage can only be called per-repo, so describe all the images in a repo, delete them, and repeat
50+
var toDelete []*image
51+
imageInp := &ecr.DescribeImagesInput{
52+
RegistryId: repo.RegistryId,
53+
RepositoryName: repo.RepositoryName,
54+
}
55+
56+
err := DescribeImagesPages(svc, imageInp, func(images *ecr.DescribeImagesOutput) error {
57+
for _, ecrImage := range images.ImageDetails {
58+
i := image{
59+
Registry: *ecrImage.RegistryId,
60+
Region: opts.Region,
61+
Repository: *ecrImage.RepositoryName,
62+
Digest: *ecrImage.ImageDigest,
63+
}
64+
65+
// ECR repos cannot have tags, so pass an empty object
66+
if !set.Mark(opts, i, ecrImage.ImagePushedAt, Tags{}) {
67+
continue
68+
}
69+
toDelete = append(toDelete, &i)
70+
}
71+
return nil
72+
}, false /* continue on error */)
73+
if err != nil {
74+
logrus.Warningf("failed to get page: %v", err)
75+
}
76+
77+
deleteReq := &ecr.BatchDeleteImageInput{
78+
RegistryId: repo.RegistryId,
79+
RepositoryName: repo.RepositoryName,
80+
ImageIds: []types.ImageIdentifier{},
81+
}
82+
for n, image := range toDelete {
83+
deleteReq.ImageIds = append(deleteReq.ImageIds, types.ImageIdentifier{
84+
ImageDigest: aws.String(image.Digest),
85+
})
86+
87+
// 100 images max: https://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_BatchDeleteImage.html
88+
// If we've reached 100 images, or this is the last item in the toDelete slice, make the call to the AWS API
89+
if len(deleteReq.ImageIds) == 100 || n == len(toDelete)-1 {
90+
logger.Warningf("%s: deleting %d images", *repo.RepositoryArn, len(deleteReq.ImageIds))
91+
if !opts.DryRun {
92+
result, err := svc.BatchDeleteImage(context.TODO(), deleteReq)
93+
94+
if err != nil {
95+
logger.Warningf("%s: delete API call failed: %v", *repo.RepositoryArn, err)
96+
} else if len(result.Failures) > 0 {
97+
logger.Warningf("%s: some images failed to delete: %T", *repo.RepositoryArn, result.Failures)
98+
}
99+
}
100+
}
101+
}
102+
}
103+
return nil
104+
}, false /* continue on error */)
105+
if err != nil {
106+
logrus.Warningf("failed to get page: %v", err)
107+
}
108+
109+
return nil
110+
}
111+
112+
func (ContainerImages) ListAll(opts Options) (*Set, error) {
113+
set := NewSet(0)
114+
if !opts.EnableECRImagesClean {
115+
return set, nil
116+
}
117+
svc := ecr.NewFromConfig(*opts.Config, func(opt *ecr.Options) {
118+
opt.Region = opts.Region
119+
})
120+
inp := &ecr.DescribeRepositoriesInput{
121+
RegistryId: aws.String(opts.Account),
122+
}
123+
124+
err := DescribeRepositoriesPages(svc, inp, func(repos *ecr.DescribeRepositoriesOutput) error {
125+
for _, repo := range repos.Repositories {
126+
imageInp := &ecr.DescribeImagesInput{
127+
RegistryId: repo.RegistryId,
128+
RepositoryName: repo.RepositoryName,
129+
}
130+
131+
err := DescribeImagesPages(svc, imageInp, func(images *ecr.DescribeImagesOutput) error {
132+
now := time.Now()
133+
for _, ecrImage := range images.ImageDetails {
134+
key := image{
135+
Registry: *ecrImage.RegistryId,
136+
Region: opts.Region,
137+
Repository: *ecrImage.RepositoryName,
138+
Digest: *ecrImage.ImageDigest,
139+
}.ResourceKey()
140+
141+
set.firstSeen[key] = now
142+
}
143+
return nil
144+
}, true /* fail on error */)
145+
146+
if err != nil {
147+
return err
148+
}
149+
}
150+
return nil
151+
}, true /* fail on error */)
152+
153+
return set, err
154+
}
155+
156+
//nolint:nestif // Ifs are small and nesting cannot be reasonably avoided
157+
func DescribeRepositoriesPages(svc *ecr.Client, input *ecr.DescribeRepositoriesInput, pageFunc func(repos *ecr.DescribeRepositoriesOutput) error, failOnError bool) error {
158+
paginator := ecr.NewDescribeRepositoriesPaginator(svc, input)
159+
160+
for paginator.HasMorePages() {
161+
page, err := paginator.NextPage(context.TODO())
162+
if err != nil {
163+
logrus.Warningf("failed to get page: %v", err)
164+
if failOnError {
165+
return err
166+
}
167+
} else {
168+
err = pageFunc(page)
169+
if err != nil {
170+
logrus.Warningf("pageFunc failed: %v", err)
171+
if failOnError {
172+
return err
173+
}
174+
}
175+
}
176+
}
177+
return nil
178+
}
179+
180+
//nolint:nestif // Ifs are small and nesting cannot be reasonably avoided
181+
func DescribeImagesPages(svc *ecr.Client, input *ecr.DescribeImagesInput, pageFunc func(images *ecr.DescribeImagesOutput) error, failOnError bool) error {
182+
paginator := ecr.NewDescribeImagesPaginator(svc, input)
183+
184+
for paginator.HasMorePages() {
185+
page, err := paginator.NextPage(context.TODO())
186+
if err != nil {
187+
logrus.Warningf("failed to get page: %v", err)
188+
if failOnError {
189+
return err
190+
}
191+
} else {
192+
err = pageFunc(page)
193+
if err != nil {
194+
logrus.Warningf("pageFunc failed: %v", err)
195+
if failOnError {
196+
return err
197+
}
198+
}
199+
}
200+
}
201+
return nil
202+
}
203+
204+
type image struct {
205+
Registry string
206+
Region string
207+
Repository string
208+
Digest string
209+
}
210+
211+
// Images don't have an ARN, only repositories do, so we use our own custom key format
212+
func (i image) ARN() string {
213+
return i.ResourceKey()
214+
}
215+
216+
func (i image) ResourceKey() string {
217+
return fmt.Sprintf("%s:%s/%s:%s", i.Registry, i.Region, i.Repository, i.Digest)
218+
}

aws-janitor/resources/list.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ type Options struct {
5858
// If true, clean S3 Buckets.
5959
EnableS3BucketsClean bool
6060

61+
// If true, clean ECR images.
62+
EnableECRImagesClean bool
63+
6164
// Resource record set types that shoud not be deleted.
6265
SkipResourceRecordSetTypes map[string]bool
6366
}
@@ -104,6 +107,7 @@ var RegionalTypeList = []Type{
104107
TargetGroups{},
105108
KeyPairs{},
106109
S3Bucket{},
110+
ContainerImages{},
107111
}
108112

109113
// Non-regional AWS resource types, in dependency order

cmd/aws-janitor-boskos/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ var (
6060
skipRoute53ManagementCheck = flag.Bool("skip-route53-management-check", false, "If true, skip managed zone check and managed resource name check.")
6161
enableDNSZoneClean = flag.Bool("enable-dns-zone-clean", false, "If true, clean DNS zones.")
6262
enableS3BucketsClean = flag.Bool("enable-s3-buckets-clean", false, "If true, clean S3 buckets.")
63+
enableECRImagesClean = flag.Bool("enable-ecr-images-clean", false, "If true, clean ECR images.")
6364

6465
excludeTags common.CommaSeparatedStrings
6566
includeTags common.CommaSeparatedStrings
@@ -218,6 +219,7 @@ func cleanResource(res *common.Resource) error {
218219
SkipRoute53ManagementCheck: *skipRoute53ManagementCheck,
219220
EnableDNSZoneClean: *enableDNSZoneClean,
220221
EnableS3BucketsClean: *enableS3BucketsClean,
222+
EnableECRImagesClean: *enableECRImagesClean,
221223
SkipResourceRecordSetTypes: skipResourceRecordSetTypesSet,
222224
}
223225

cmd/aws-janitor/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ var (
5555
skipRoute53ManagementCheck = flag.Bool("skip-route53-management-check", false, "If true, skip managed zone check and managed resource name check.")
5656
enableDNSZoneClean = flag.Bool("enable-dns-zone-clean", false, "If true, clean DNS zones.")
5757
enableS3BucketsClean = flag.Bool("enable-s3-buckets-clean", false, "If true, clean S3 buckets.")
58+
enableECRImagesClean = flag.Bool("enable-ecr-images-clean", false, "If true, clean ECR images.")
5859

5960
excludeTags common.CommaSeparatedStrings
6061
includeTags common.CommaSeparatedStrings
@@ -168,6 +169,7 @@ func main() {
168169
SkipRoute53ManagementCheck: *skipRoute53ManagementCheck,
169170
EnableDNSZoneClean: *enableDNSZoneClean,
170171
EnableS3BucketsClean: *enableS3BucketsClean,
172+
EnableECRImagesClean: *enableECRImagesClean,
171173
SkipResourceRecordSetTypes: skipResourceRecordSetTypesSet,
172174
}
173175

go.mod

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ require (
77
github.com/IBM/go-sdk-core/v5 v5.19.1
88
github.com/IBM/platform-services-go-sdk v0.80.0
99
github.com/IBM/vpc-go-sdk v0.68.0
10-
github.com/aws/aws-sdk-go-v2 v1.30.3
10+
github.com/aws/aws-sdk-go-v2 v1.38.1
1111
github.com/aws/aws-sdk-go-v2/config v1.27.27
1212
github.com/aws/aws-sdk-go-v2/credentials v1.17.27
1313
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.9
1414
github.com/aws/aws-sdk-go-v2/service/autoscaling v1.43.3
1515
github.com/aws/aws-sdk-go-v2/service/cloudformation v1.53.3
1616
github.com/aws/aws-sdk-go-v2/service/ec2 v1.173.0
17+
github.com/aws/aws-sdk-go-v2/service/ecr v1.49.2
1718
github.com/aws/aws-sdk-go-v2/service/efs v1.31.3
1819
github.com/aws/aws-sdk-go-v2/service/eks v1.47.0
1920
github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing v1.26.3
@@ -24,7 +25,7 @@ require (
2425
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2
2526
github.com/aws/aws-sdk-go-v2/service/sqs v1.34.3
2627
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3
27-
github.com/aws/smithy-go v1.20.3
28+
github.com/aws/smithy-go v1.22.5
2829
github.com/fsnotify/fsnotify v1.6.0
2930
github.com/go-test/deep v1.0.7
3031
github.com/google/go-cmp v0.7.0
@@ -59,8 +60,8 @@ require (
5960
github.com/aws/aws-sdk-go v1.44.72 // indirect
6061
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
6162
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
62-
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
63-
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
63+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4 // indirect
64+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4 // indirect
6465
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
6566
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 // indirect
6667
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect

go.sum

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ github.com/aws/aws-sdk-go v1.19.18/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpi
112112
github.com/aws/aws-sdk-go v1.19.45/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
113113
github.com/aws/aws-sdk-go v1.44.72 h1:i7J5XT7pjBjtl1OrdIhiQHzsG89wkZCcM1HhyK++3DI=
114114
github.com/aws/aws-sdk-go v1.44.72/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
115-
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
116-
github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
115+
github.com/aws/aws-sdk-go-v2 v1.38.1 h1:j7sc33amE74Rz0M/PoCpsZQ6OunLqys/m5antM0J+Z8=
116+
github.com/aws/aws-sdk-go-v2 v1.38.1/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
117117
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
118118
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
119119
github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90=
@@ -124,10 +124,10 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk
124124
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU=
125125
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.9 h1:TC2vjvaAv1VNl9A0rm+SeuBjrzXnrlwk6Yop+gKRi38=
126126
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.9/go.mod h1:WPv2FRnkIOoDv/8j2gSUsI4qDc7392w5anFB/I89GZ8=
127-
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU=
128-
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8=
129-
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI=
130-
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM=
127+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4 h1:IdCLsiiIj5YJ3AFevsewURCPV+YWUlOW8JiPhoAy8vg=
128+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4/go.mod h1:l4bdfCD7XyyZA9BolKBo1eLqgaJxl0/x91PL4Yqe0ao=
129+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4 h1:j7vjtr1YIssWQOMeOWRbh3z8g2oY/xPjnZH2gLY4sGw=
130+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4/go.mod h1:yDmJgqOiH4EA8Hndnv4KwAo8jCGTSnM5ASG1nBI+toA=
131131
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
132132
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
133133
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 h1:Z5r7SycxmSllHYmaAZPpmN8GviDrSGhMS6bldqtXZPw=
@@ -138,6 +138,8 @@ github.com/aws/aws-sdk-go-v2/service/cloudformation v1.53.3 h1:mIpL+FXa+2U6oc85b
138138
github.com/aws/aws-sdk-go-v2/service/cloudformation v1.53.3/go.mod h1:lcQ7+K0Q9x0ozhjBwDfBkuY8qexSP/QXLgp0jj+/NZg=
139139
github.com/aws/aws-sdk-go-v2/service/ec2 v1.173.0 h1:ta62lid9JkIpKZtZZXSj6rP2AqY5x1qYGq53ffxqD9Q=
140140
github.com/aws/aws-sdk-go-v2/service/ec2 v1.173.0/go.mod h1:o6QDjdVKpP5EF0dp/VlvqckzuSDATr1rLdHt3A5m0YY=
141+
github.com/aws/aws-sdk-go-v2/service/ecr v1.49.2 h1:aFmDHNrMqJb7Um0wusnZ8lqDcYTf0+RXxSvmCuelBiM=
142+
github.com/aws/aws-sdk-go-v2/service/ecr v1.49.2/go.mod h1:Knlx5anjbiHqbCdnOabD+soFqsJIx2RdKf5R9SoBuUg=
141143
github.com/aws/aws-sdk-go-v2/service/efs v1.31.3 h1:vHNTbv0pFB/E19MokZcWAxZIggWgcLlcixNePBe6iZc=
142144
github.com/aws/aws-sdk-go-v2/service/efs v1.31.3/go.mod h1:P1X7sDHKpqZCLac7bRsFF/EN2REOgmeKStQTa14FpEA=
143145
github.com/aws/aws-sdk-go-v2/service/eks v1.47.0 h1:u0VeIQ02COfhmp37ub8zv29bdRtosCYzXoWd+QRebbY=
@@ -170,8 +172,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrA
170172
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw=
171173
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE=
172174
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
173-
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
174-
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
175+
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
176+
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
175177
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
176178
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
177179
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=

0 commit comments

Comments
 (0)