aws-amicleaner 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +9 -0
- package/README.md +72 -0
- package/index.js +144 -0
- package/lib.js +203 -0
- package/package.json +38 -0
- package/prettytable.js +241 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) widdix GmbH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
To clean up your AWS AMIs:
|
|
2
|
+
1. Include AMIs by name or tag.
|
|
3
|
+
2. Exclude AMIs in use, younger than N days, or the newest N images.
|
|
4
|
+
3. Manually confirm the list of AMIs for deletion.
|
|
5
|
+
|
|
6
|
+
## Examples
|
|
7
|
+
|
|
8
|
+
To delete all AMIs in eu-west-1 where the name starts with amiprefix-, are older than 5 days, and not the newest 3 images, run:
|
|
9
|
+
```bash
|
|
10
|
+
aws-amicleaner --region eu-west-1 --include-name 'amiprefix-*' --exclude-newest 3 --exclude-days 5 --exclude-in-use --verbose
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
A typical confirmation screen:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
+-----------+-------------+----------------------+--------------------------+---------+-----------------+-------------------------+
|
|
17
|
+
| Region | ID | Name | Creation Date | Delete? | Include reasons | Exclude reasons |
|
|
18
|
+
+-----------+-------------+----------------------+--------------------------+---------+-----------------+-------------------------+
|
|
19
|
+
| eu-west-1 | ami-0a...72 | amiprefix-1685107232 | 2023-05-27T13:32:21.000Z | no | name match | days not passed, newest |
|
|
20
|
+
| eu-west-1 | ami-02...3f | amiprefix-1685103569 | 2023-05-27T12:30:50.000Z | no | name match | days not passed, newest |
|
|
21
|
+
| eu-west-1 | ami-09...d5 | amiprefix-1685095689 | 2023-05-27T10:19:59.000Z | no | name match | days not passed, newest |
|
|
22
|
+
| eu-west-1 | ami-0f...c7 | amiprefix-1685039741 | 2023-05-26T18:47:37.000Z | no | name match | days not passed |
|
|
23
|
+
| eu-west-1 | ami-0f...f0 | amiprefix-1685018189 | 2023-05-26T12:49:02.000Z | no | name match | days not passed |
|
|
24
|
+
| eu-west-1 | ami-06...a8 | amiprefix-1685015512 | 2023-05-26T12:04:39.000Z | no | name match | days not passed |
|
|
25
|
+
| eu-west-1 | ami-04...44 | amiprefix-1684998014 | 2023-05-25T07:14:42.000Z | yes | name match | |
|
|
26
|
+
| eu-west-1 | ami-02...6e | amiprefix-1684954911 | 2023-05-24T19:15:37.000Z | yes | name match | |
|
|
27
|
+
| eu-west-1 | ami-0e...da | amiprefix-1684952424 | 2023-05-24T18:32:06.000Z | yes | name match | |
|
|
28
|
+
| eu-west-1 | ami-0b...54 | amiprefix-1684949922 | 2023-05-24T17:50:26.000Z | yes | name match | |
|
|
29
|
+
| eu-west-1 | ami-0b...2c | amiprefix-1684937102 | 2023-05-24T14:17:53.000Z | yes | name match | |
|
|
30
|
+
| eu-west-1 | ami-0e...aa | amiprefix-1684915092 | 2023-05-24T08:09:42.000Z | yes | name match | |
|
|
31
|
+
+-----------+-------------+----------------------+--------------------------+---------+-----------------+-------------------------+
|
|
32
|
+
|
|
33
|
+
Do you want to continue and remove 6 AMIs [y/N] ? :
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
To delete all AMIs in eu-west-* (eu-west-1, eu-west-2, eu-west-3) tagged with CostCenter=X342-*-1111, are older than 7 days (default), are not the newest 5 images (default), and are not in use (default), run:
|
|
37
|
+
```bash
|
|
38
|
+
aws-amicleaner --region 'eu-west-*' --include-tag-key CostCenter --include-tag-value 'X342-*-1111'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Run the command without confirmation (useful in scripts):
|
|
42
|
+
```bash
|
|
43
|
+
aws-amicleaner --region 'eu-west-*' --include-tag-key CostCenter --include-tag-value 'X342-*-1111' --force-delete
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
To disable the defaults, run:
|
|
47
|
+
```bash
|
|
48
|
+
aws-amicleaner --include-name 'amiprefix-*' --exclude-newest 0 --exclude-days 0 --no-exclude-in-use --no-verbose
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Arguments
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
-h, --help show this help message and exit
|
|
55
|
+
--region REGION The AWS region, e.g. us-east-1, arg can be used more than once, wildcard * supported
|
|
56
|
+
--include-name INCLUDENAME
|
|
57
|
+
The name that must be present, wildcard * supported
|
|
58
|
+
--include-tag-key INCLUDETAGKEY
|
|
59
|
+
The tag key that must be present
|
|
60
|
+
--include-tag-value INCLUDETAGVALUE
|
|
61
|
+
The tag value (for the tag key) that must be present, wildcard * supported
|
|
62
|
+
--exclude-newest EXCLUDENEWEST
|
|
63
|
+
Exclude the newest N AMIs
|
|
64
|
+
--exclude-days EXCLUDEDAYS
|
|
65
|
+
Exclude AMIs from deletion that are younger than N days
|
|
66
|
+
--exclude-in-use, --no-exclude-in-use
|
|
67
|
+
Exclude AMIs from deletion that are in use by EC2 instances, ASGs, Launch Configurations, and Launch Templates (default: true)
|
|
68
|
+
-f, --force-delete, --no-force-delete
|
|
69
|
+
Skip confirmation before deletion (default: false)
|
|
70
|
+
--verbose, --no-verbose
|
|
71
|
+
Display additional information (default: true)
|
|
72
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const { createInterface } = require('node:readline');
|
|
2
|
+
const { ArgumentParser, BooleanOptionalAction } = require('argparse');
|
|
3
|
+
const { fetchAMIs, deleteAMI, fetchRegions} = require('./lib.js');
|
|
4
|
+
const pLimit = require('p-limit');
|
|
5
|
+
|
|
6
|
+
const AWS = require('aws-sdk');
|
|
7
|
+
const PrettyTable = require('./prettytable.js');
|
|
8
|
+
|
|
9
|
+
const rl = createInterface({
|
|
10
|
+
input: process.stdin,
|
|
11
|
+
output: process.stdout,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
async function input(query) {
|
|
15
|
+
return new Promise(resolve => {
|
|
16
|
+
rl.question(query, answer => resolve(answer));
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function run({
|
|
21
|
+
regions: rawRegions,
|
|
22
|
+
includeName,
|
|
23
|
+
includeTagKey,
|
|
24
|
+
includeTagValue,
|
|
25
|
+
excludeNewest,
|
|
26
|
+
excludeInUse,
|
|
27
|
+
excludeDays,
|
|
28
|
+
forceDelete,
|
|
29
|
+
verbose
|
|
30
|
+
}) {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
|
|
33
|
+
const ec2 = {};
|
|
34
|
+
const autoscaling = {};
|
|
35
|
+
const ec2Client = (region) => {
|
|
36
|
+
if (!(region in ec2)) {
|
|
37
|
+
ec2[region] = new AWS.EC2({apiVersion: '2016-11-15', region});
|
|
38
|
+
}
|
|
39
|
+
return ec2[region];
|
|
40
|
+
};
|
|
41
|
+
const autoscalingClient = (region) => {
|
|
42
|
+
if (!(region in autoscaling)) {
|
|
43
|
+
autoscaling[region] = new AWS.AutoScaling({apiVersion: '2011-01-01', region});
|
|
44
|
+
}
|
|
45
|
+
return autoscaling[region];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let amis = [];
|
|
49
|
+
|
|
50
|
+
const regions = await fetchRegions(ec2Client('us-east-1'), rawRegions);
|
|
51
|
+
for (const region of regions) {
|
|
52
|
+
let regionalAMIs = await fetchAMIs(now, ec2Client(region), autoscalingClient(region), includeName, includeTagKey, includeTagValue, excludeNewest, excludeInUse, excludeDays);
|
|
53
|
+
regionalAMIs = regionalAMIs.map(ami => ({
|
|
54
|
+
region,
|
|
55
|
+
...ami
|
|
56
|
+
}));
|
|
57
|
+
amis = [...amis, ...regionalAMIs];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (verbose === true) {
|
|
61
|
+
const pt = new PrettyTable();
|
|
62
|
+
pt.sortTable('Name');
|
|
63
|
+
pt.create(['Region', 'ID', 'Name', 'Creation Date', 'Delete?', 'Include reasons', 'Exclude reasons'], amis.map(ami => [
|
|
64
|
+
ami.region,
|
|
65
|
+
ami.id,
|
|
66
|
+
ami.name,
|
|
67
|
+
new Date(ami.creationDate).toISOString(),
|
|
68
|
+
(ami.included === true && ami.excluded === false) ? 'yes' : 'no',
|
|
69
|
+
(ami.included === true) ? ami.includeReasons.join(', ') : '',
|
|
70
|
+
(ami.excluded === true) ? ami.excludeReasons.join(', ') : ''
|
|
71
|
+
]));
|
|
72
|
+
pt.print();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
amis = amis.filter(ami => ami.included === true && ami.excluded === false);
|
|
76
|
+
|
|
77
|
+
if (amis.length === 0) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let del = false;
|
|
82
|
+
if (forceDelete === true) {
|
|
83
|
+
del = true;
|
|
84
|
+
} else {
|
|
85
|
+
const answer = await input(`Do you want to continue and remove ${amis.length} AMIs [y/N] ? : `);
|
|
86
|
+
del = answer === 'y';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (del === true) {
|
|
90
|
+
const limit = pLimit(5);
|
|
91
|
+
await Promise.all(amis.map(ami => limit(() => deleteAMI(ec2Client(ami.region), ami))));
|
|
92
|
+
} else {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readArgs() {
|
|
98
|
+
const parser = new ArgumentParser({
|
|
99
|
+
prog: 'aws-amicleaner',
|
|
100
|
+
description: 'To clean up your AWS AMIs: First, include AMIs by name or tag. Second, exclude AMIs in use, younger than N days, or the newest N images. Third, manually confirm the list of AMIs for deletion.',
|
|
101
|
+
usage: `To delete all AMIs where the name starts with amiprefix-, are older than 5 days, and not the newest 3 images, run:
|
|
102
|
+
aws-amicleaner --include-name 'amiprefix-*' --exclude-newest 3 --exclude-days 5 --exclude-in-use --verbose
|
|
103
|
+
|
|
104
|
+
To delete all AMIs tagged with CostCenter=X342-*-1111, are older than 7 days (default), are not the newest 5 images (default), and are not in use (default), run:
|
|
105
|
+
aws-amicleaner --include-tag-key CostCenter --include-tag-value 'X342-*-1111'
|
|
106
|
+
|
|
107
|
+
Run the command without confirmation (useful in scripts):
|
|
108
|
+
aws-amicleaner --include-tag-key CostCenter --include-tag-value 'X342-*-1111' --force-delete
|
|
109
|
+
|
|
110
|
+
To disable the defaults, run:
|
|
111
|
+
aws-amicleaner --include-name 'amiprefix-*' --exclude-newest 0 --exclude-days 0 --no-exclude-in-use --no-verbose
|
|
112
|
+
`
|
|
113
|
+
});
|
|
114
|
+
parser.add_argument('--region', {dest: 'regions', type: 'string', action: 'append', default: [], help: 'The AWS region, e.g. us-east-1, arg can be used more than once, wildcard * supported'});
|
|
115
|
+
parser.add_argument('--include-name', {dest: 'includeName', type: 'string', help: 'The name that must be present, wildcard * supported'});
|
|
116
|
+
parser.add_argument('--include-tag-key', {dest: 'includeTagKey', type: 'string', help: 'The tag key that must be present'});
|
|
117
|
+
parser.add_argument('--include-tag-value', {dest: 'includeTagValue', type: 'string', help: 'The tag value (for the tag key) that must be present, wildcard * supported'});
|
|
118
|
+
parser.add_argument('--exclude-newest', {dest: 'excludeNewest', type: 'int', default: 5, help: 'Exclude the newest N AMIs'});
|
|
119
|
+
parser.add_argument('--exclude-days', {dest: 'excludeDays', type: 'int', default: 7, help: 'Exclude AMIs from deletion that are younger than N days'});
|
|
120
|
+
parser.add_argument('--exclude-in-use', {dest: 'excludeInUse', default: true, action: BooleanOptionalAction, help: 'Exclude AMIs from deletion that are in use by EC2 instances, ASGs, Launch Configurations, and Launch Templates'});
|
|
121
|
+
parser.add_argument('-f', '--force-delete', {dest: 'forceDelete', default: false, action: BooleanOptionalAction, help: 'Skip confirmation before deletion'});
|
|
122
|
+
parser.add_argument('--verbose', {dest: 'verbose', default: true, action: BooleanOptionalAction, help: 'Display additional information'});
|
|
123
|
+
const args = parser.parse_args();
|
|
124
|
+
|
|
125
|
+
if (args.includeName === undefined && args.includeTagKey === undefined) {
|
|
126
|
+
throw new Error('--include-name or --include-tag-key missing');
|
|
127
|
+
}
|
|
128
|
+
if (args.includeName !== undefined && args.includeTagKey !== undefined) {
|
|
129
|
+
throw new Error('Use either --include-name or --include-tag-key');
|
|
130
|
+
}
|
|
131
|
+
if (args.includeTagKey !== undefined && args.includeTagValue === undefined) {
|
|
132
|
+
throw new Error('--include-tag-value missing');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return args;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const args = readArgs();
|
|
139
|
+
|
|
140
|
+
if (args.verbose === true) {
|
|
141
|
+
console.log('args', args);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
run(args).then(() => process.exit(0));
|
package/lib.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
const wildcard = require('wildcard');
|
|
2
|
+
const pLimit = require('p-limit');
|
|
3
|
+
|
|
4
|
+
const MAX_ITEMS_PER_LAUNCH_CONFIGURATION_PAGE = 50;
|
|
5
|
+
|
|
6
|
+
function mapAMI(raw) {
|
|
7
|
+
return {
|
|
8
|
+
id: raw.ImageId,
|
|
9
|
+
name: raw.Name,
|
|
10
|
+
creationDate: Date.parse(raw.CreationDate),
|
|
11
|
+
tags: raw.Tags.reduce((acc, {Key: key, Value: value}) => {
|
|
12
|
+
acc[key] = value;
|
|
13
|
+
return acc;
|
|
14
|
+
}, {}),
|
|
15
|
+
blockDeviceMappings: raw.BlockDeviceMappings.map(raw => ({snapshotId: raw.Ebs.SnapshotId})),
|
|
16
|
+
excluded: false,
|
|
17
|
+
excludeReasons: [],
|
|
18
|
+
included: false,
|
|
19
|
+
includeReasons: []
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function fetchRegions(ec2, rawRegions) {
|
|
24
|
+
const regions = new Set();
|
|
25
|
+
|
|
26
|
+
if (rawRegions.length === 0) {
|
|
27
|
+
regions.add(undefined);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
rawRegions.filter(region => !region.includes('*')).forEach(region => regions.add(region));
|
|
31
|
+
|
|
32
|
+
const rawRegionsWithWildcard = rawRegions.filter(region => region.includes('*'));
|
|
33
|
+
if (rawRegionsWithWildcard.length !== 0) {
|
|
34
|
+
const {Regions} = await ec2.describeRegions({}).promise();
|
|
35
|
+
rawRegionsWithWildcard.forEach(rawRegionWithWildcard => {
|
|
36
|
+
wildcard(rawRegionWithWildcard, Regions.map(r => r.RegionName)).forEach(region => regions.add(region));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return regions;
|
|
41
|
+
}
|
|
42
|
+
exports.fetchRegions = fetchRegions;
|
|
43
|
+
|
|
44
|
+
async function fetchInUseAMIIDs(ec2, autoscaling) {
|
|
45
|
+
const inUseAMIIDs = new Set();
|
|
46
|
+
|
|
47
|
+
for await (const reservation of (async function*() {
|
|
48
|
+
let nextToken = '';
|
|
49
|
+
while (nextToken !== undefined) {
|
|
50
|
+
const {Reservations: reservations, NextToken} = await ec2.describeInstances({
|
|
51
|
+
NextToken: (nextToken === '') ? undefined : nextToken,
|
|
52
|
+
Filters: [{
|
|
53
|
+
Name: 'instance-state-name',
|
|
54
|
+
Values: [
|
|
55
|
+
'pending',
|
|
56
|
+
'running',
|
|
57
|
+
'shutting-down',
|
|
58
|
+
'stopping',
|
|
59
|
+
'stopped'
|
|
60
|
+
]
|
|
61
|
+
}]
|
|
62
|
+
}).promise();
|
|
63
|
+
yield* reservations;
|
|
64
|
+
nextToken = NextToken;
|
|
65
|
+
}
|
|
66
|
+
})()) {
|
|
67
|
+
reservation.Instances.forEach(instance => inUseAMIIDs.add(instance.ImageId));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const asgs = [];
|
|
71
|
+
for await (const asg of (async function*() {
|
|
72
|
+
let nextToken = '';
|
|
73
|
+
while (nextToken !== undefined) {
|
|
74
|
+
const {AutoScalingGroups, NextToken} = await autoscaling.describeAutoScalingGroups({
|
|
75
|
+
NextToken: (nextToken === '') ? undefined : nextToken
|
|
76
|
+
}).promise();
|
|
77
|
+
yield* AutoScalingGroups;
|
|
78
|
+
nextToken = NextToken;
|
|
79
|
+
}
|
|
80
|
+
})()) {
|
|
81
|
+
asgs.push(asg);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// in use by ASG -> Launch Configuration
|
|
85
|
+
const inUseLCNames = asgs.filter(asg => 'LaunchConfigurationName' in asg).map(asg => asg.LaunchConfigurationName);
|
|
86
|
+
if (inUseLCNames.length > 0) {
|
|
87
|
+
for (let i = 0; i < Math.ceil(inUseLCNames.length/MAX_ITEMS_PER_LAUNCH_CONFIGURATION_PAGE); i++) {
|
|
88
|
+
const {LaunchConfigurations: lcs} = await autoscaling.describeLaunchConfigurations({
|
|
89
|
+
LaunchConfigurationNames: inUseLCNames.slice(i*MAX_ITEMS_PER_LAUNCH_CONFIGURATION_PAGE, (i+1)*MAX_ITEMS_PER_LAUNCH_CONFIGURATION_PAGE)
|
|
90
|
+
}).promise();
|
|
91
|
+
lcs.forEach(lc => inUseAMIIDs.add(lc.ImageId));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const inUseLTs = [
|
|
96
|
+
...asgs.filter(asg => 'LaunchTemplate' in asg).map(asg => ({id: asg.LaunchTemplate.LaunchTemplateId, version: asg.LaunchTemplate.Version})),
|
|
97
|
+
...asgs.filter(asg => 'MixedInstancesPolicy' in asg).map(asg => ({id: asg.MixedInstancesPolicy.LaunchTemplate.LaunchTemplateSpecification.LaunchTemplateId, version: asg.MixedInstancesPolicy.LaunchTemplate.LaunchTemplateSpecification.Version}))
|
|
98
|
+
];
|
|
99
|
+
const limit = pLimit(5);
|
|
100
|
+
await Promise.all(
|
|
101
|
+
inUseLTs.map(({id, version}) => limit(() =>
|
|
102
|
+
ec2.describeLaunchTemplateVersions({
|
|
103
|
+
LaunchTemplateId: id,
|
|
104
|
+
Versions: [version]
|
|
105
|
+
}).promise().then(data => data.LaunchTemplateVersions[0].LaunchTemplateData.ImageId))
|
|
106
|
+
)
|
|
107
|
+
).then(amiIDs => amiIDs.forEach(amiID => inUseAMIIDs.add(amiID)));
|
|
108
|
+
|
|
109
|
+
return inUseAMIIDs;
|
|
110
|
+
}
|
|
111
|
+
exports.fetchInUseAMIIDs = fetchInUseAMIIDs;
|
|
112
|
+
|
|
113
|
+
async function fetchAMIs(now, ec2, autoscaling, includeName, includeTagKey, includeTagValue, excludeNewest, excludeInUse, excludeDays) {
|
|
114
|
+
let amis = [];
|
|
115
|
+
for await (const rawAMI of (async function*() {
|
|
116
|
+
let nextToken = '';
|
|
117
|
+
while (nextToken !== undefined) {
|
|
118
|
+
const params = {
|
|
119
|
+
Owners: ['self']
|
|
120
|
+
};
|
|
121
|
+
if (includeTagKey !== undefined) {
|
|
122
|
+
params.Filters = [{
|
|
123
|
+
Name: 'tag-key',
|
|
124
|
+
Values: [includeTagKey]
|
|
125
|
+
}];
|
|
126
|
+
}
|
|
127
|
+
if (nextToken !== '') {
|
|
128
|
+
params.NextToken = nextToken;
|
|
129
|
+
}
|
|
130
|
+
const {Images, NextToken} = await ec2.describeImages(params).promise();
|
|
131
|
+
yield* Images;
|
|
132
|
+
nextToken = NextToken;
|
|
133
|
+
}
|
|
134
|
+
})()) {
|
|
135
|
+
amis.push(mapAMI(rawAMI));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (includeName !== undefined) {
|
|
139
|
+
amis = amis.filter(ami => wildcard(includeName, ami.name)).map(ami => {
|
|
140
|
+
ami.included = true;
|
|
141
|
+
ami.includeReasons.push('name match');
|
|
142
|
+
return ami;
|
|
143
|
+
});
|
|
144
|
+
} else if (includeTagKey !== undefined) {
|
|
145
|
+
amis = amis.filter(ami => wildcard(includeTagValue, ami.tags[includeTagKey])).map(ami => {
|
|
146
|
+
ami.included = true;
|
|
147
|
+
ami.includeReasons.push('tag match');
|
|
148
|
+
return ami;
|
|
149
|
+
});
|
|
150
|
+
} else {
|
|
151
|
+
throw new Error('no include defined');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (excludeInUse === true) {
|
|
155
|
+
const inUseAMIIDs = await fetchInUseAMIIDs(ec2, autoscaling);
|
|
156
|
+
amis = amis.map(ami => {
|
|
157
|
+
if (inUseAMIIDs.has(ami.id)) {
|
|
158
|
+
ami.excluded = true;
|
|
159
|
+
ami.excludeReasons.push('in use');
|
|
160
|
+
}
|
|
161
|
+
return ami;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (excludeDays > 0) {
|
|
166
|
+
const ts = now-(excludeDays*24*60*60*1000);
|
|
167
|
+
amis = amis.map(ami => {
|
|
168
|
+
if (ami.creationDate > ts) {
|
|
169
|
+
ami.excluded = true;
|
|
170
|
+
ami.excludeReasons.push('days not passed');
|
|
171
|
+
}
|
|
172
|
+
return ami;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (excludeNewest > 0) {
|
|
178
|
+
amis = amis.sort((a, b) => b.creationDate-a.creationDate).map((ami, i) => {
|
|
179
|
+
if (i < excludeNewest) {
|
|
180
|
+
ami.excluded = true;
|
|
181
|
+
ami.excludeReasons.push('newest');
|
|
182
|
+
}
|
|
183
|
+
return ami;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return amis;
|
|
188
|
+
}
|
|
189
|
+
exports.fetchAMIs = fetchAMIs;
|
|
190
|
+
|
|
191
|
+
async function deleteAMI(ec2, ami) {
|
|
192
|
+
await ec2.deregisterImage({
|
|
193
|
+
ImageId: ami.id
|
|
194
|
+
}).promise();
|
|
195
|
+
console.log(`AMI ${ami.id} deregistered`);
|
|
196
|
+
for (const blockDevice of ami.blockDeviceMappings) {
|
|
197
|
+
await ec2.deleteSnapshot({
|
|
198
|
+
SnapshotId: blockDevice.snapshotId
|
|
199
|
+
}).promise();
|
|
200
|
+
console.log(`snapshot ${blockDevice.snapshotId} of AMI ${ami.id} deleted`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
exports.deleteAMI = deleteAMI;
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "aws-amicleaner",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "To clean up your AWS AMIs: First, include AMIs by name or tag. Second, exclude AMIs in use, younger than N days, or the newest N images. Third, manually confirm the list of AMIs for deletion.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "eslint . && c8 mocha"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/widdix/aws-amicleaner.git"
|
|
12
|
+
},
|
|
13
|
+
"author": "Michael Wittig <michael@widdix.de>",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/widdix/aws-amicleaner/issues"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/widdix/aws-amicleaner#readme",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"aws-sdk": "2.1387.0",
|
|
21
|
+
"p-limit": "3.1.0",
|
|
22
|
+
"wildcard": "2.0.1"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"c8": "7.13.0",
|
|
26
|
+
"eslint": "8.41.0",
|
|
27
|
+
"mocha": "10.2.0"
|
|
28
|
+
},
|
|
29
|
+
"mocha": {
|
|
30
|
+
"timeout": 10000
|
|
31
|
+
},
|
|
32
|
+
"c8": {
|
|
33
|
+
"reporter": [
|
|
34
|
+
"text",
|
|
35
|
+
"html"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
}
|
package/prettytable.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Not used via module prettytabl;e becausde of error:
|
|
2
|
+
// Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './lib/sync' is not defined by "exports" in /Users/michael/Projects/widdix/aws-amicleaner/node_modules/csv-parse/package.json
|
|
3
|
+
// at new NodeError (node:internal/errors:387:5)
|
|
4
|
+
// at throwExportsNotFound (node:internal/modules/esm/resolve:365:9)
|
|
5
|
+
// at packageExportsResolve (node:internal/modules/esm/resolve:649:3)
|
|
6
|
+
// at resolveExports (node:internal/modules/cjs/loader:554:36)
|
|
7
|
+
// at Function.Module._findPath (node:internal/modules/cjs/loader:594:31)
|
|
8
|
+
// at Function.Module._resolveFilename (node:internal/modules/cjs/loader:1012:27)
|
|
9
|
+
// at Function.Module._load (node:internal/modules/cjs/loader:871:27)
|
|
10
|
+
// at Module.require (node:internal/modules/cjs/loader:1098:19)
|
|
11
|
+
// at require (node:internal/modules/cjs/helpers:108:18)
|
|
12
|
+
// at Object.<anonymous> (/Users/michael/Projects/widdix/aws-amicleaner/node_modules/prettytable/prettytable.js:1:13) {
|
|
13
|
+
// code: 'ERR_PACKAGE_PATH_NOT_EXPORTED'
|
|
14
|
+
// }
|
|
15
|
+
|
|
16
|
+
// Source https://github.com/jyotiska/prettytable
|
|
17
|
+
|
|
18
|
+
// The MIT License (MIT)
|
|
19
|
+
//
|
|
20
|
+
// Copyright (c) 2016 Jyotiska NK
|
|
21
|
+
//
|
|
22
|
+
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
23
|
+
// of this software and associated documentation files (the "Software"), to deal
|
|
24
|
+
// in the Software without restriction, including without limitation the rights
|
|
25
|
+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
26
|
+
// copies of the Software, and to permit persons to whom the Software is
|
|
27
|
+
// furnished to do so, subject to the following conditions:
|
|
28
|
+
//
|
|
29
|
+
// The above copyright notice and this permission notice shall be included in all
|
|
30
|
+
// copies or substantial portions of the Software.
|
|
31
|
+
//
|
|
32
|
+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
33
|
+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
34
|
+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
35
|
+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
36
|
+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
37
|
+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
38
|
+
// SOFTWARE.
|
|
39
|
+
|
|
40
|
+
var PrettyTable = function () {
|
|
41
|
+
// Skeleton structure of table with list of column names, row and max width of each column element
|
|
42
|
+
this.table = {
|
|
43
|
+
'columnNames': [],
|
|
44
|
+
'rows': [],
|
|
45
|
+
'maxWidth': []
|
|
46
|
+
};
|
|
47
|
+
this.version = '0.3.1';
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Define list of columns for the table
|
|
51
|
+
PrettyTable.prototype.fieldNames = function (names) {
|
|
52
|
+
this.table.columnNames = names;
|
|
53
|
+
for (var i = 0; i < names.length; i++) {
|
|
54
|
+
this.table.maxWidth.push(names[i].length);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Add a single row to the table
|
|
59
|
+
PrettyTable.prototype.addRow = function (row) {
|
|
60
|
+
this.table.rows.push(row);
|
|
61
|
+
for (var i = 0; i < row.length; i++) {
|
|
62
|
+
if (row[i].toString().length > this.table.maxWidth[i]) {
|
|
63
|
+
this.table.maxWidth[i] = row[i].toString().length;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Single function to create table when headers and array of rows passed
|
|
69
|
+
PrettyTable.prototype.create = function (headers, rows) {
|
|
70
|
+
// Add table headers
|
|
71
|
+
this.fieldNames(headers);
|
|
72
|
+
|
|
73
|
+
// Add rows one by one
|
|
74
|
+
for (var i = 0; i < rows.length; i++) {
|
|
75
|
+
this.addRow(rows[i]);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Convert the table to string
|
|
80
|
+
PrettyTable.prototype.toString = function () {
|
|
81
|
+
var finalTable = '';
|
|
82
|
+
var columnString = '| ';
|
|
83
|
+
var rowString = '';
|
|
84
|
+
var lengthDifference = '';
|
|
85
|
+
|
|
86
|
+
// Draw a line based on the max width of each column and return
|
|
87
|
+
var drawLine = function (table) {
|
|
88
|
+
var finalLine = '+';
|
|
89
|
+
for (var i = 0; i < table.maxWidth.length; i++) {
|
|
90
|
+
finalLine += Array(table.maxWidth[i] + 3).join('-') + '+';
|
|
91
|
+
}
|
|
92
|
+
return finalLine;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// If no columns present, return empty string
|
|
96
|
+
if (this.table.columnNames.length === 0) {
|
|
97
|
+
return finalTable;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Create the table header from column list
|
|
101
|
+
for (var i = 0; i < this.table.columnNames.length; i++) {
|
|
102
|
+
columnString += this.table.columnNames[i];
|
|
103
|
+
// Adjust for max width of the column and pad spaces
|
|
104
|
+
if (this.table.columnNames[i].length < this.table.maxWidth[i]) {
|
|
105
|
+
lengthDifference = this.table.maxWidth[i] - this.table.columnNames[i].length;
|
|
106
|
+
columnString += Array(lengthDifference + 1).join(' ');
|
|
107
|
+
}
|
|
108
|
+
columnString += ' | ';
|
|
109
|
+
}
|
|
110
|
+
finalTable += drawLine(this.table) + '\n';
|
|
111
|
+
finalTable += columnString + '\n';
|
|
112
|
+
finalTable += drawLine(this.table) + '\n';
|
|
113
|
+
|
|
114
|
+
// Construct the table body
|
|
115
|
+
for (i = 0; i < this.table.rows.length; i++) {
|
|
116
|
+
var tempRowString = '| ';
|
|
117
|
+
for (var k = 0; k < this.table.rows[i].length; k++) {
|
|
118
|
+
tempRowString += this.table.rows[i][k];
|
|
119
|
+
// Adjust max width of each cell and pad spaces as necessary
|
|
120
|
+
if (this.table.rows[i][k].toString().length < this.table.maxWidth[k]) {
|
|
121
|
+
lengthDifference = this.table.maxWidth[k] - this.table.rows[i][k].toString().length;
|
|
122
|
+
tempRowString += Array(lengthDifference + 1).join(' ');
|
|
123
|
+
}
|
|
124
|
+
tempRowString += ' | ';
|
|
125
|
+
}
|
|
126
|
+
rowString += tempRowString + '\n';
|
|
127
|
+
}
|
|
128
|
+
// Remove newline from the end of the table string
|
|
129
|
+
rowString = rowString.slice(0, -1);
|
|
130
|
+
// Append to the final table string
|
|
131
|
+
finalTable += rowString + '\n';
|
|
132
|
+
// Draw last line and return
|
|
133
|
+
finalTable += drawLine(this.table) + '\n';
|
|
134
|
+
return finalTable;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Write the table string to the console
|
|
138
|
+
PrettyTable.prototype.print = function () {
|
|
139
|
+
console.log(this.toString());
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Write the table string to the console as HTML table formats
|
|
143
|
+
PrettyTable.prototype.html = function (attributes) {
|
|
144
|
+
// If attributes provided, add them as inline properties, else create default table tag
|
|
145
|
+
var htmlTable = '';
|
|
146
|
+
if (typeof attributes == 'undefined') {
|
|
147
|
+
htmlTable = '<table>';
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
var attributeList = [];
|
|
151
|
+
for (var key in attributes) {
|
|
152
|
+
attributeList.push(key + '=\'' + attributes[key] + '\'');
|
|
153
|
+
}
|
|
154
|
+
var attributeString = attributeList.join(' ');
|
|
155
|
+
htmlTable = '<table ' + attributeString + '>';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Define the table headers in <thead> from table column list
|
|
159
|
+
var tableHead = '<thead><tr>';
|
|
160
|
+
for (var i = 0; i < this.table.columnNames.length; i++) {
|
|
161
|
+
var headerString = '<th>' + this.table.columnNames[i] + '</th>';
|
|
162
|
+
tableHead += headerString;
|
|
163
|
+
}
|
|
164
|
+
tableHead += '</tr></thead>';
|
|
165
|
+
htmlTable += tableHead;
|
|
166
|
+
|
|
167
|
+
// Construct the table body from the array of rows
|
|
168
|
+
var tableBody = '<tbody>';
|
|
169
|
+
for (i = 0; i < this.table.rows.length; i++) {
|
|
170
|
+
var rowData = '<tr>';
|
|
171
|
+
for (var k = 0; k < this.table.rows[i].length; k++) {
|
|
172
|
+
var cellData = '<td>' + this.table.rows[i][k] + '</td>';
|
|
173
|
+
rowData += cellData;
|
|
174
|
+
}
|
|
175
|
+
rowData += '</tr>';
|
|
176
|
+
tableBody += rowData;
|
|
177
|
+
}
|
|
178
|
+
// Close all tags and return
|
|
179
|
+
tableBody += '</tbody>';
|
|
180
|
+
htmlTable += tableBody;
|
|
181
|
+
htmlTable += '</table>';
|
|
182
|
+
|
|
183
|
+
return htmlTable;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Sort the table given a column in ascending or descending order
|
|
187
|
+
PrettyTable.prototype.sortTable = function (colname, reverse) {
|
|
188
|
+
// Find the index of the column given the name
|
|
189
|
+
var colindex = this.table.columnNames.indexOf(colname);
|
|
190
|
+
|
|
191
|
+
// Comparator method which takes the column index and sort direction
|
|
192
|
+
var Comparator = function ( a, b) {
|
|
193
|
+
if (typeof reverse === 'boolean' && reverse === true) {
|
|
194
|
+
if (a[colindex] < b[colindex]) {
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
else if (a[colindex] > b[colindex]) {
|
|
198
|
+
return -1;
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
if (a[colindex] < b[colindex]) {
|
|
206
|
+
return -1;
|
|
207
|
+
}
|
|
208
|
+
else if (a[colindex] > b[colindex]) {
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
// Sort array of table rows
|
|
217
|
+
this.table.rows = this.table.rows.sort(Comparator);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// Delete a single row from the table given row number
|
|
221
|
+
PrettyTable.prototype.deleteRow = function (rownum) {
|
|
222
|
+
if (rownum <= this.table.rows.length && rownum > 0) {
|
|
223
|
+
this.table.rows.splice(rownum - 1, 1);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Clear the contents from the table, but keep columns and structure
|
|
228
|
+
PrettyTable.prototype.clearTable = function () {
|
|
229
|
+
this.table.rows = [];
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Delete the entire table
|
|
233
|
+
PrettyTable.prototype.deleteTable = function () {
|
|
234
|
+
this.table = {
|
|
235
|
+
'columnNames': [],
|
|
236
|
+
'rows': [],
|
|
237
|
+
'maxWidth': []
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
module.exports = PrettyTable;
|