Skip to content

Commit 860380e

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

File tree

6 files changed

+256
-12
lines changed

6 files changed

+256
-12
lines changed

aws-janitor/resources/ecr.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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+
"slices"
23+
"time"
24+
25+
"github.com/aws/aws-sdk-go-v2/aws"
26+
"github.com/aws/aws-sdk-go-v2/service/ecr"
27+
"github.com/aws/aws-sdk-go-v2/service/ecr/types"
28+
29+
"github.com/sirupsen/logrus"
30+
)
31+
32+
type ContainerImages struct{}
33+
34+
//nolint:gocognit // Just over the complexity threshold and splitting would make it less readable
35+
func (ContainerImages) MarkAndSweep(opts Options, set *Set) error {
36+
logger := logrus.WithField("options", opts)
37+
if len(opts.CleanEcrRepositories) == 0 {
38+
logger.Info("No ECR reposotories to clean provided, skipping ECR")
39+
return nil
40+
}
41+
42+
svc := ecr.NewFromConfig(*opts.Config, func(opt *ecr.Options) {
43+
opt.Region = opts.Region
44+
})
45+
inp := &ecr.DescribeRepositoriesInput{
46+
RegistryId: aws.String(opts.Account),
47+
}
48+
49+
// DescribeImages requires a repository name, so we first must crawl all repositories
50+
err := DescribeRepositoriesPages(svc, inp, func(repos *ecr.DescribeRepositoriesOutput) error {
51+
for _, repo := range repos.Repositories {
52+
if !slices.Contains(opts.CleanEcrRepositories, *repo.RepositoryName) {
53+
continue
54+
}
55+
56+
// BatchDeleteImage can only be called per-repo, so describe all the images in a repo, delete them, and repeat
57+
var toDelete []*image
58+
imageInp := &ecr.DescribeImagesInput{
59+
RegistryId: repo.RegistryId,
60+
RepositoryName: repo.RepositoryName,
61+
}
62+
63+
imagesErr := DescribeImagesPages(svc, imageInp, func(images *ecr.DescribeImagesOutput) error {
64+
for _, ecrImage := range images.ImageDetails {
65+
i := image{
66+
Registry: *ecrImage.RegistryId,
67+
Region: opts.Region,
68+
Repository: *ecrImage.RepositoryName,
69+
Digest: *ecrImage.ImageDigest,
70+
}
71+
72+
// ECR repos cannot have tags, so pass an empty object
73+
if !set.Mark(opts, i, ecrImage.ImagePushedAt, Tags{}) {
74+
continue
75+
}
76+
toDelete = append(toDelete, &i)
77+
}
78+
return nil
79+
}, false /* continue on error */)
80+
if imagesErr != nil {
81+
logrus.Warningf("failed to get page: %v", imagesErr)
82+
}
83+
84+
deleteReq := &ecr.BatchDeleteImageInput{
85+
RegistryId: repo.RegistryId,
86+
RepositoryName: repo.RepositoryName,
87+
ImageIds: []types.ImageIdentifier{},
88+
}
89+
for n, image := range toDelete {
90+
deleteReq.ImageIds = append(deleteReq.ImageIds, types.ImageIdentifier{
91+
ImageDigest: aws.String(image.Digest),
92+
})
93+
94+
// 100 images max: https://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_BatchDeleteImage.html
95+
// If we've reached 100 images, or this is the last item in the toDelete slice, make the call to the AWS API
96+
if len(deleteReq.ImageIds) == 100 || n == len(toDelete)-1 {
97+
logger.Warningf("%s: deleting %d images", *repo.RepositoryArn, len(deleteReq.ImageIds))
98+
if !opts.DryRun {
99+
result, err := svc.BatchDeleteImage(context.TODO(), deleteReq)
100+
101+
if err != nil {
102+
logger.Warningf("%s: delete API call failed: %v", *repo.RepositoryArn, err)
103+
} else if len(result.Failures) > 0 {
104+
logger.Warningf("%s: some images failed to delete: %T", *repo.RepositoryArn, result.Failures)
105+
}
106+
}
107+
// After making delete call, reset to an empty set of images
108+
deleteReq.ImageIds = []types.ImageIdentifier{}
109+
}
110+
}
111+
}
112+
return nil
113+
}, false /* continue on error */)
114+
if err != nil {
115+
logrus.Warningf("failed to get page: %v", err)
116+
}
117+
118+
return nil
119+
}
120+
121+
func (ContainerImages) ListAll(opts Options) (*Set, error) {
122+
set := NewSet(0)
123+
if len(opts.CleanEcrRepositories) == 0 {
124+
return set, nil
125+
}
126+
svc := ecr.NewFromConfig(*opts.Config, func(opt *ecr.Options) {
127+
opt.Region = opts.Region
128+
})
129+
inp := &ecr.DescribeRepositoriesInput{
130+
RegistryId: aws.String(opts.Account),
131+
}
132+
133+
err := DescribeRepositoriesPages(svc, inp, func(repos *ecr.DescribeRepositoriesOutput) error {
134+
for _, repo := range repos.Repositories {
135+
if !slices.Contains(opts.CleanEcrRepositories, *repo.RepositoryName) {
136+
continue
137+
}
138+
139+
imageInp := &ecr.DescribeImagesInput{
140+
RegistryId: repo.RegistryId,
141+
RepositoryName: repo.RepositoryName,
142+
}
143+
144+
err := DescribeImagesPages(svc, imageInp, func(images *ecr.DescribeImagesOutput) error {
145+
now := time.Now()
146+
for _, ecrImage := range images.ImageDetails {
147+
key := image{
148+
Registry: *ecrImage.RegistryId,
149+
Region: opts.Region,
150+
Repository: *ecrImage.RepositoryName,
151+
Digest: *ecrImage.ImageDigest,
152+
}.ResourceKey()
153+
154+
set.firstSeen[key] = now
155+
}
156+
return nil
157+
}, true /* fail on error */)
158+
159+
if err != nil {
160+
return err
161+
}
162+
}
163+
return nil
164+
}, true /* fail on error */)
165+
166+
return set, err
167+
}
168+
169+
//nolint:nestif // Ifs are small and nesting cannot be reasonably avoided
170+
func DescribeRepositoriesPages(svc *ecr.Client, input *ecr.DescribeRepositoriesInput, pageFunc func(repos *ecr.DescribeRepositoriesOutput) error, failOnError bool) error {
171+
paginator := ecr.NewDescribeRepositoriesPaginator(svc, input)
172+
173+
for paginator.HasMorePages() {
174+
page, err := paginator.NextPage(context.TODO())
175+
if err != nil {
176+
logrus.Warningf("failed to get page: %v", err)
177+
if failOnError {
178+
return err
179+
}
180+
} else {
181+
err = pageFunc(page)
182+
if err != nil {
183+
logrus.Warningf("pageFunc failed: %v", err)
184+
if failOnError {
185+
return err
186+
}
187+
}
188+
}
189+
}
190+
return nil
191+
}
192+
193+
//nolint:nestif // Ifs are small and nesting cannot be reasonably avoided
194+
func DescribeImagesPages(svc *ecr.Client, input *ecr.DescribeImagesInput, pageFunc func(images *ecr.DescribeImagesOutput) error, failOnError bool) error {
195+
paginator := ecr.NewDescribeImagesPaginator(svc, input)
196+
197+
for paginator.HasMorePages() {
198+
page, err := paginator.NextPage(context.TODO())
199+
if err != nil {
200+
logrus.Warningf("failed to get page: %v", err)
201+
if failOnError {
202+
return err
203+
}
204+
} else {
205+
err = pageFunc(page)
206+
if err != nil {
207+
logrus.Warningf("pageFunc failed: %v", err)
208+
if failOnError {
209+
return err
210+
}
211+
}
212+
}
213+
}
214+
return nil
215+
}
216+
217+
type image struct {
218+
Registry string
219+
Region string
220+
Repository string
221+
Digest string
222+
}
223+
224+
// Images don't have an ARN, only repositories do, so we use our own custom key format
225+
func (i image) ARN() string {
226+
return i.ResourceKey()
227+
}
228+
229+
func (i image) ResourceKey() string {
230+
return fmt.Sprintf("%s:%s/%s:%s", i.Registry, i.Region, i.Repository, i.Digest)
231+
}

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+
// List of ECR repositories to clean (only cleans images inside).
62+
CleanEcrRepositories []string
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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ var (
6464
excludeTags common.CommaSeparatedStrings
6565
includeTags common.CommaSeparatedStrings
6666
skipResourceRecordSetTypes common.CommaSeparatedStrings
67+
cleanEcrRepositories common.CommaSeparatedStrings
6768

6869
excludeTM resources.TagMatcher
6970
includeTM resources.TagMatcher
@@ -93,6 +94,7 @@ func init() {
9394
flag.Var(&includeTags, "include-tags",
9495
"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.")
9596
flag.Var(&skipResourceRecordSetTypes, "skip-resource-record-set-types", "A list of resource record types which should not be deleted, splitted using comma.")
97+
flag.Var(&cleanEcrRepositories, "clean-ecr-repositories", "Comma-separated list of ECR repositories for which to clean images.")
9698

9799
prometheus.MustRegister(cleaningTimeHistogram)
98100
prometheus.MustRegister(sweepsGauge)
@@ -218,6 +220,7 @@ func cleanResource(res *common.Resource) error {
218220
SkipRoute53ManagementCheck: *skipRoute53ManagementCheck,
219221
EnableDNSZoneClean: *enableDNSZoneClean,
220222
EnableS3BucketsClean: *enableS3BucketsClean,
223+
CleanEcrRepositories: cleanEcrRepositories,
221224
SkipResourceRecordSetTypes: skipResourceRecordSetTypesSet,
222225
}
223226

cmd/aws-janitor/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ var (
5959
excludeTags common.CommaSeparatedStrings
6060
includeTags common.CommaSeparatedStrings
6161
skipResourceRecordSetTypes common.CommaSeparatedStrings
62+
cleanEcrRepositories common.CommaSeparatedStrings
6263

6364
sweepCount int
6465

@@ -80,6 +81,7 @@ func init() {
8081
flag.Var(&includeTags, "include-tags",
8182
"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.")
8283
flag.Var(&skipResourceRecordSetTypes, "skip-resource-record-set-types", "A list of resource record types which should not be deleted, splitted using comma.")
84+
flag.Var(&cleanEcrRepositories, "clean-ecr-repositories", "Comma-separated list of ECR repositories for which to clean images.")
8385
}
8486

8587
func main() {
@@ -168,6 +170,7 @@ func main() {
168170
SkipRoute53ManagementCheck: *skipRoute53ManagementCheck,
169171
EnableDNSZoneClean: *enableDNSZoneClean,
170172
EnableS3BucketsClean: *enableS3BucketsClean,
173+
CleanEcrRepositories: cleanEcrRepositories,
171174
SkipResourceRecordSetTypes: skipResourceRecordSetTypesSet,
172175
}
173176

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)