Skip to content

Commit 3b3b2e6

Browse files
authored
Merge pull request #13 from nicklaw5/limit-to-100
Limit to 100 image per invocation, other various improvements
2 parents 091e701 + 8d3d425 commit 3b3b2e6

3 files changed

Lines changed: 113 additions & 111 deletions

File tree

README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
1-
# THIS IS NOW OBSOLETE - REPLACED BY [AWS ECR LIFECYCLE POLICIES](http://docs.aws.amazon.com/AmazonECR/latest/userguide/LifecyclePolicies.html)
21
# Robin
32

3+
Note that [ECR Lifecycle Policies](http://docs.aws.amazon.com/AmazonECR/latest/userguide/LifecyclePolicies.html) may be a better fit for you use case.
4+
45
[![Build Status](https://travis-ci.org/nib-health-funds/robin.svg?branch=master)](https://travis-ci.org/nib-health-funds/robin)
56
[![Dependencies](https://david-dm.org/nib-health-funds/robin.svg)](https://david-dm.org/nib-health-funds/robin)
67

78
Batman's very capable side kick - deletes old ECR images.
8-
<center><img src="images/robin.jpg"</img></center>
9+
<center><img src="images/robin.jpg"></center>
910

10-
# Why Robin?
11+
## Why Robin?
1112

1213
Images stored in ECR incur monthly data storage charges, this means paying to store old images that are no longer in use. Also, AWS ECR has a default limit of 1000 images. Therefore, it is desirable to ensure the ECR repositories are kept clean of unused images.
1314

14-
# What we delete currently:
15+
## What we delete currently:
16+
17+
Per Lambda invocation:
18+
19+
- 100 images that are older than 30 days and that do not have tags that contain 'master'
1520

16-
- Images older than 30 days that do not have tags that contain 'master'
21+
If you need to delete more than 100 images, rather than complicating this script so that it can paginate
22+
through all pages of images, we suggest you simply run the lambda multiple times.
1723

18-
# Usage
24+
## Usage
1925

2026
1. Authenticate and get AWS credentials via your preferred CLI, you may need to export the environment variables directly
2127

@@ -50,10 +56,9 @@ $ npm run tail-logs
5056
```
5157

5258

53-
# TODO
59+
## TODO
5460

5561
- Only keep the last 10 master images (justification: we should be using the last images only, last 10 gives us something to rollback to if needed.)
5662
- Add some more documentation to this readme
5763
- Delete all untagged images
58-
- Make repo names configurable via env vars
5964
- Make tagging convention configurable

handler.js

Lines changed: 98 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -26,32 +26,31 @@ function getAllImages(ecr, registryId, repoName) {
2626
maxResults: 100
2727
};
2828

29-
/**
30-
* 'Follows' the result pagination in a sort of reduce method in which the results are continuely added to an array
31-
*/
32-
function followPages(ecrSvc, allImages, data) {
33-
const combinedImages = allImages.length === 0 ? [...data.imageDetails] : [...allImages, ...data.imageDetails];
34-
35-
if (data.nextToken) {
36-
params.nextToken = data.nextToken;
37-
return ecrSvc.describeImages(params)
38-
.promise()
39-
.then(res => followPages(ecrSvc, combinedImages, res));
40-
}
41-
42-
return Promise.resolve(combinedImages);
43-
}
44-
4529
return ecr.describeImages(params).promise()
46-
.then(data => followPages(ecr, [], data));
30+
.then(data => Promise.resolve(data.imageDetails));
4731
}
4832

49-
function buildReport(isDryRun, reposNotFound, reposWithUntaggedImages, reposWithDeletedImages) {
33+
function buildReport(
34+
isDryRun,
35+
reposNotFound,
36+
reposWithUntaggedImages,
37+
reposWithDeletedImagesDryRun,
38+
reposWithDeletedImages,
39+
reposWithImagesThatFailedToDelete
40+
) {
5041
const untaggedRepoKeys = Object.keys(reposWithUntaggedImages);
42+
const deletedDryRunRepoKeys = Object.keys(reposWithDeletedImagesDryRun);
5143
const deletedRepoKeys = Object.keys(reposWithDeletedImages);
52-
53-
if (reposNotFound.length === 0 && untaggedRepoKeys.length === 0 && deletedRepoKeys.length === 0) {
54-
return Promise.resolve('Robin ran but there was no vigilamnte justice was needed');
44+
const failedToDeletedRepoKeys = Object.keys(reposWithImagesThatFailedToDelete);
45+
46+
if (
47+
reposNotFound.length === 0
48+
&& untaggedRepoKeys.length === 0
49+
&& deletedDryRunRepoKeys === 0
50+
&& deletedRepoKeys.length === 0
51+
&& failedToDeletedRepoKeys === 0
52+
) {
53+
return 'Robin ran but there no vigilamnte justice was needed';
5554
}
5655

5756
const backticks = str => (`\`${str}\``);
@@ -60,32 +59,59 @@ function buildReport(isDryRun, reposNotFound, reposWithUntaggedImages, reposWith
6059
let text = 'Robin has attempted to clean up the streets!';
6160

6261
if (reposNotFound.length !== 0) {
63-
text += '\n\n\n====================================';
62+
text += '\n\n\n===================================================';
6463
text += `\nRepositories not found (${reposNotFound.length})${dryRunText}`;
65-
text += '\n====================================';
64+
text += '\n===================================================';
6665
reposNotFound.forEach(repoName => {
6766
text += `\n${backticks(repoName)}`;
6867
});
6968
}
7069

7170
if (untaggedRepoKeys.length !== 0) {
72-
text += '\n\n\n====================================';
71+
text += '\n\n\n===================================================';
7372
text += `\nRepositories with untagged images (${untaggedRepoKeys.length})${dryRunText}`;
74-
text += '\n====================================';
73+
text += '\n===================================================';
7574
untaggedRepoKeys.forEach(repoName => {
7675
text += `\n${backticks(repoName)} - ${reposWithUntaggedImages[repoName]} image${reposWithUntaggedImages[repoName] > 1 ? 's' : ''}`;
7776
});
7877
}
7978

80-
if (deletedRepoKeys.length !== 0) {
81-
text += '\n\n\n====================================';
82-
text += `\nRepositories with images deleted (${deletedRepoKeys.length})${dryRunText}`;
83-
text += '\n====================================';
84-
deletedRepoKeys.forEach(repoName => {
85-
text += `\n${backticks(repoName)} (${reposWithDeletedImages[repoName].length} tags): ${reposWithDeletedImages[repoName].join(', ')}`;
86-
});
79+
if (isDryRun) {
80+
text += '\n\n\n===================================================';
81+
text += `\nRepositories with images deleted (${deletedDryRunRepoKeys.length})${dryRunText}`;
82+
text += '\n===================================================';
83+
if (deletedDryRunRepoKeys.length === 0) {
84+
text += '\nNo images deleted';
85+
} else {
86+
deletedDryRunRepoKeys.forEach(repoName => {
87+
// eslint-disable-next-line max-len
88+
text += `\n${backticks(repoName)} (${reposWithDeletedImagesDryRun[repoName].length} tags): ${reposWithDeletedImagesDryRun[repoName].join(', ')}`;
89+
});
90+
}
91+
} else {
92+
text += '\n\n\n===================================================';
93+
text += `\nRepositories with images deleted (${deletedRepoKeys.length})`;
94+
text += '\n===================================================';
95+
if (deletedRepoKeys.length === 0) {
96+
text += '\nNo images deleted';
97+
} else {
98+
deletedRepoKeys.forEach(repoName => {
99+
text += `\n${backticks(repoName)} (${reposWithDeletedImages[repoName].length} tags): ${reposWithDeletedImages[repoName].join(', ')}`;
100+
});
101+
}
102+
103+
if (failedToDeletedRepoKeys.length !== 0) {
104+
text += '\n\n\n===================================================';
105+
text += `\nRepositories with images that failed deleted (${failedToDeletedRepoKeys.length})`;
106+
text += '\n===================================================';
107+
deletedRepoKeys.forEach(repoName => {
108+
// eslint-disable-next-line max-len
109+
text += `\n${backticks(repoName)} (${reposWithImagesThatFailedToDelete[repoName].length} tags): ${reposWithImagesThatFailedToDelete[repoName].join(', ')}`;
110+
});
111+
}
87112
}
88113

114+
89115
return text;
90116
}
91117

@@ -107,6 +133,8 @@ module.exports.cleanupImages = (event, context, callback) => {
107133
const reposNotFound = [];
108134
const reposWithUntaggedImages = {};
109135
const reposWithDeletedImages = {};
136+
const reposWithDeletedImagesDryRun = {};
137+
const reposWithImagesThatFailedToDelete = {};
110138

111139
console.log('Robin is dealing out some of his own justice...');
112140
console.log('Robin is using ECR Region: ', ecrRegion);
@@ -147,28 +175,47 @@ module.exports.cleanupImages = (event, context, callback) => {
147175
console.log('Images to delete: ', toDelete);
148176

149177
const convertedToDelete = toDelete.map(image => {
150-
if (typeof reposWithDeletedImages[repoName] === 'undefined') {
151-
reposWithDeletedImages[repoName] = [];
178+
if (isDryRun) {
179+
image.imageTags.forEach(tag => {
180+
if (typeof reposWithDeletedImagesDryRun[repoName] === 'undefined') {
181+
reposWithDeletedImagesDryRun[repoName] = [];
182+
}
183+
reposWithDeletedImagesDryRun[repoName].push(tag);
184+
});
152185
}
153186

154-
image.imageTags.forEach(tag => {
155-
reposWithDeletedImages[repoName].push(tag);
156-
});
157-
158187
return { imageDigest: image.imageDigest };
159188
});
160189

190+
if (isDryRun) {
191+
return Promise.resolve({ imageIds: [], failures: [] });
192+
}
193+
161194
const deleteParams = {
162195
registryId: registry,
163196
repositoryName: repoName,
164197
imageIds: convertedToDelete
165198
};
166199

167-
if (isDryRun) {
168-
return Promise.resolve({ imageIds: [], failures: []});
169-
}
170-
171-
return ecr.batchDeleteImage(deleteParams).promise();
200+
return ecr.batchDeleteImage(deleteParams).promise()
201+
.then(({ failures, imageIds }) => {
202+
console.log('failures: ', failures);
203+
console.log('imageIds: ', imageIds);
204+
205+
failures.forEach(({ imageId }) => {
206+
if (typeof reposWithImagesThatFailedToDelete[repoName] === 'undefined') {
207+
reposWithImagesThatFailedToDelete[repoName] = [];
208+
}
209+
reposWithImagesThatFailedToDelete[repoName].push(imageId.imageTag);
210+
});
211+
212+
imageIds.forEach(({ imageTag }) => {
213+
if (typeof reposWithDeletedImages[repoName] === 'undefined') {
214+
reposWithDeletedImages[repoName] = [];
215+
}
216+
reposWithDeletedImages[repoName].push(imageTag);
217+
});
218+
});
172219
})
173220
.catch(err => {
174221
if (err.code === 'RepositoryNotFoundException' && reposNotFound.indexOf(repoName) === -1) {
@@ -181,7 +228,14 @@ module.exports.cleanupImages = (event, context, callback) => {
181228

182229
return Promise.all(promises)
183230
.then(() => {
184-
const reportText = buildReport(isDryRun, reposNotFound, reposWithUntaggedImages, reposWithDeletedImages);
231+
const reportText = buildReport(
232+
isDryRun,
233+
reposNotFound,
234+
reposWithUntaggedImages,
235+
reposWithDeletedImagesDryRun,
236+
reposWithDeletedImages,
237+
reposWithImagesThatFailedToDelete
238+
);
185239

186240
// Log Results
187241
console.log(reportText.replace(/`/g, '')); // strip backticks when logging to CloudWatch (backticks are for Slack!)

handler.test.js

Lines changed: 2 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,15 @@ describe('cleanupImages', () => {
6868
const cleanup = proxyquire('./handler.js', mocks);
6969

7070
beforeEach(() => {
71+
process.env.DRY_RUN = 'true';
7172
process.env.REPO_NAMES = 'test-repo';
7273
process.env.AWS_ACCOUNT_ID = '1234567890';
7374

7475
sandbox.reset();
7576
});
7677

7778
it('Should not remove master images', done => {
79+
process.env.DRY_RUN = 'false';
7880
deleteStub.returns(deletePromise);
7981
cleanup.cleanupImages(null, null, () => {
8082
assert(describeImagesStub.called);
@@ -89,69 +91,10 @@ describe('cleanupImages', () => {
8991

9092
it('Should not call delete if dry run', done => {
9193
deleteStub.returns(deletePromise);
92-
process.env.DRY_RUN = true;
9394
cleanup.cleanupImages(null, null, () => {
9495
assert(describeImagesStub.called);
9596
assert(deleteStub.notCalled);
9697
done();
9798
});
9899
});
99-
100-
it('Should handle pagination using the nextToken', done => {
101-
102-
const stub = sandbox.stub();
103-
stub.onCall(0).returns({
104-
promise: () => Promise.resolve(
105-
{
106-
nextToken: 'next-please',
107-
imageDetails: [{
108-
registryId: '384553929753',
109-
repositoryName: 'test-repo-1',
110-
imageDigest: '4',
111-
imageTags: ['1.0.0-other', 'dont-ignore-this'],
112-
imageSizeInBytes: '1024',
113-
imagePushedAt: moment().add(-31, 'days')
114-
}]
115-
})
116-
});
117-
118-
stub.onCall(1).returns({
119-
promise: () => Promise.resolve(
120-
{
121-
nextToken: null,
122-
imageDetails: [{
123-
registryId: '384553929754',
124-
repositoryName: 'test-repo-2',
125-
imageDigest: '4',
126-
imageTags: ['1.0.0-other', 'dont-ignore-this'],
127-
imageSizeInBytes: '1024',
128-
imagePushedAt: moment().add(-31, 'days')
129-
}]
130-
})
131-
});
132-
133-
const cleanupImagesProxy = proxyquire('./handler.js', {
134-
'aws-sdk': {
135-
ECR: function () { // eslint-disable-line
136-
this.batchDeleteImage = deleteStub;
137-
this.describeImages = stub;
138-
}
139-
}
140-
});
141-
142-
process.env.DRY_RUN = false;
143-
144-
cleanupImagesProxy.cleanupImages(null, null, () => {
145-
const secondCall = stub.getCall(1);
146-
147-
assert(secondCall.calledWithMatch({
148-
registryId: '1234567890',
149-
repositoryName: 'test-repo',
150-
maxResults: 100,
151-
nextToken: 'next-please'
152-
}), 'describeImages was not called with the next token for the 2nd time');
153-
assert(stub.callCount === 2, 'describeImages was not called 2 times');
154-
done();
155-
});
156-
});
157100
});

0 commit comments

Comments
 (0)