Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): garbage collect ecr assets (under --unstable flag) #31841

Merged
merged 27 commits into from
Oct 26, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a17c682
refactor s3 gc into its own function
kaizencc Oct 21, 2024
97a0fc0
grab repository name and get total images
kaizencc Oct 21, 2024
7fdd0dd
get deletabes/taggables/untaggables
kaizencc Oct 21, 2024
1865e9b
delete works too
kaizencc Oct 21, 2024
34b1db0
refactor test suite
kaizencc Oct 22, 2024
84329f0
unit tests and refactoring
kaizencc Oct 22, 2024
cde556d
finish unit test
kaizencc Oct 22, 2024
d82d349
3 integ tests working
kaizencc Oct 22, 2024
dc86f76
another test
kaizencc Oct 22, 2024
5998025
Merge branch 'main' into conroy/ecrgc
kaizencc Oct 22, 2024
e90aa1a
readme updates
kaizencc Oct 22, 2024
97fa8bf
Merge branch 'conroy/ecrgc' of https://github.com/aws/aws-cdk into co…
kaizencc Oct 22, 2024
75c1d79
minor
kaizencc Oct 22, 2024
e45acdf
readme update
kaizencc Oct 23, 2024
5163f4c
unique tags and ignore failures
kaizencc Oct 23, 2024
6f61cc6
differet tags
kaizencc Oct 23, 2024
494d189
Merge branch 'main' into conroy/ecrgc
kaizencc Oct 23, 2024
7fe9336
proper tagging ecr
kaizencc Oct 23, 2024
457e0ea
Merge branch 'conroy/ecrgc' of https://github.com/aws/aws-cdk into co…
kaizencc Oct 23, 2024
ae26d87
Merge branch 'main' into conroy/ecrgc
kaizencc Oct 23, 2024
5bb09be
change up how image tags are created
kaizencc Oct 24, 2024
0878e53
Merge branch 'main' into conroy/ecrgc
kaizencc Oct 24, 2024
d99ec7e
Merge branch 'conroy/ecrgc' of https://github.com/aws/aws-cdk into co…
kaizencc Oct 24, 2024
2d565fb
Merge branch 'main' into conroy/ecrgc
kaizencc Oct 24, 2024
c930fb7
Merge branch 'main' into conroy/ecrgc
kaizencc Oct 25, 2024
ade9b4d
Merge branch 'main' into conroy/ecrgc
kaizencc Oct 25, 2024
8e2d92d
merge issues
kaizencc Oct 25, 2024
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
14 changes: 13 additions & 1 deletion packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable no-console */
import * as assert from 'assert';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
Expand Down Expand Up @@ -544,6 +545,17 @@ export class TestFixture extends ShellHelper {
return JSON.parse(fs.readFileSync(templatePath, { encoding: 'utf-8' }).toString());
}

public async bootstrapRepoName(): Promise<string> {
await ensureBootstrapped(this);

const response = await this.aws.cloudFormation.send(new DescribeStacksCommand({}));

const stack = (response.Stacks ?? [])
.filter((s) => s.StackName && s.StackName == this.bootstrapStackName);
assert(stack.length == 1);
return outputFromStack('ImageRepositoryName', stack[0]) ?? '';
}

public get bootstrapStackName() {
return this.fullStackName('bootstrap-stack');
}
Expand All @@ -569,7 +581,7 @@ export class TestFixture extends ShellHelper {
}

/**
* Cleanup leftover stacks and buckets
* Cleanup leftover stacks and bootstrapped resources
*/
public async dispose(success: boolean) {
const stacksToDelete = await this.deleteableStacks(this.stackNamePrefix);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,19 @@ class DockerStack extends cdk.Stack {
}
}

class DockerInUseStack extends cdk.Stack {
constructor(parent, id, props) {
super(parent, id, props);

// Use the docker file in a lambda otherwise it will not be referenced in the template
const fn = new lambda.Function(this, 'my-function', {
code: lambda.Code.fromAssetImage(path.join(__dirname, 'docker')),
runtime: lambda.Runtime.FROM_IMAGE,
handler: lambda.Handler.FROM_IMAGE,
});
}
}

class DockerStackWithCustomFile extends cdk.Stack {
constructor(parent, id, props) {
super(parent, id, props);
Expand Down Expand Up @@ -814,6 +827,7 @@ switch (stackSet) {
new EcsHotswapStack(app, `${stackPrefix}-ecs-hotswap`);
new AppSyncHotswapStack(app, `${stackPrefix}-appsync-hotswap`);
new DockerStack(app, `${stackPrefix}-docker`);
new DockerInUseStack(app, `${stackPrefix}-docker-in-use`);
new DockerStackWithCustomFile(app, `${stackPrefix}-docker-with-custom-file`);

new NotificationArnPropStack(app, `${stackPrefix}-notification-arn-prop`, {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { BatchGetImageCommand, ListImagesCommand, PutImageCommand } from '@aws-sdk/client-ecr';
import { GetObjectTaggingCommand, ListObjectsV2Command, PutObjectTaggingCommand } from '@aws-sdk/client-s3';
import { integTest, randomString, withoutBootstrap } from '../../lib';

const S3_ISOLATED_TAG = 'aws-cdk:isolated';
const ECR_ISOLATED_TAG = 'aws-cdk.isolated';

jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime

integTest(
'Garbage Collection deletes unused assets',
'Garbage Collection deletes unused s3 objects',
withoutBootstrap(async (fixture) => {
const toolkitStackName = fixture.bootstrapStackName;
const bootstrapBucketName = `aws-cdk-garbage-collect-integ-test-bckt-${randomString()}`;
Expand Down Expand Up @@ -50,7 +54,50 @@ integTest(
);

integTest(
'Garbage Collection keeps in use assets',
'Garbage Collection deletes unused ecr images',
withoutBootstrap(async (fixture) => {
const toolkitStackName = fixture.bootstrapStackName;

await fixture.cdkBootstrapModern({
toolkitStackName,
});

const repoName = await fixture.bootstrapRepoName();

await fixture.cdkDeploy('docker-in-use', {
options: [
'--context', `@aws-cdk/core:bootstrapQualifier=${fixture.qualifier}`,
'--toolkit-stack-name', toolkitStackName,
'--force',
],
});
fixture.log('Setup complete!');

await fixture.cdkDestroy('docker-in-use', {
options: [
'--context', `@aws-cdk/core:bootstrapQualifier=${fixture.qualifier}`,
'--toolkit-stack-name', toolkitStackName,
'--force',
],
});

await fixture.cdkGarbageCollect({
rollbackBufferDays: 0,
type: 'ecr',
bootstrapStackName: toolkitStackName,
});
fixture.log('Garbage collection complete!');

// assert that the bootstrap repository is empty
await fixture.aws.ecr.send(new ListImagesCommand({ repositoryName: repoName }))
.then((result) => {
expect(result.imageIds).toEqual([]);
});
}),
);

integTest(
'Garbage Collection keeps in use s3 objects',
withoutBootstrap(async (fixture) => {
const toolkitStackName = fixture.bootstrapStackName;
const bootstrapBucketName = `aws-cdk-garbage-collect-integ-test-bckt-${randomString()}`;
Expand Down Expand Up @@ -97,7 +144,50 @@ integTest(
);

integTest(
'Garbage Collection tags unused assets',
'Garbage Collection keeps in use ecr images',
withoutBootstrap(async (fixture) => {
const toolkitStackName = fixture.bootstrapStackName;

await fixture.cdkBootstrapModern({
toolkitStackName,
});

const repoName = await fixture.bootstrapRepoName();

await fixture.cdkDeploy('docker-in-use', {
options: [
'--context', `@aws-cdk/core:bootstrapQualifier=${fixture.qualifier}`,
'--toolkit-stack-name', toolkitStackName,
'--force',
],
});
fixture.log('Setup complete!');

await fixture.cdkGarbageCollect({
rollbackBufferDays: 0,
type: 'ecr',
bootstrapStackName: toolkitStackName,
});
fixture.log('Garbage collection complete!');

// assert that the bootstrap repository is empty
await fixture.aws.ecr.send(new ListImagesCommand({ repositoryName: repoName }))
.then((result) => {
expect(result.imageIds).toHaveLength(1);
});

await fixture.cdkDestroy('docker-in-use', {
options: [
'--context', `@aws-cdk/core:bootstrapQualifier=${fixture.qualifier}`,
'--toolkit-stack-name', toolkitStackName,
'--force',
],
});
}),
);

integTest(
'Garbage Collection tags unused s3 objects',
withoutBootstrap(async (fixture) => {
const toolkitStackName = fixture.bootstrapStackName;
const bootstrapBucketName = `aws-cdk-garbage-collect-integ-test-bckt-${randomString()}`;
Expand Down Expand Up @@ -142,11 +232,62 @@ integTest(
const tags = await fixture.aws.s3.send(new GetObjectTaggingCommand({ Bucket: bootstrapBucketName, Key: key }));
expect(tags.TagSet).toHaveLength(1);
});

await fixture.cdkDestroy('lambda', {
options: [
'--context', `bootstrapBucket=${bootstrapBucketName}`,
'--context', `@aws-cdk/core:bootstrapQualifier=${fixture.qualifier}`,
'--toolkit-stack-name', toolkitStackName,
'--force',
],
});
}),
);

integTest(
'Garbage Collection tags unused ecr images',
withoutBootstrap(async (fixture) => {
const toolkitStackName = fixture.bootstrapStackName;

await fixture.cdkBootstrapModern({
toolkitStackName,
});

const repoName = await fixture.bootstrapRepoName();

await fixture.cdkDeploy('docker-in-use', {
options: [
'--context', `@aws-cdk/core:bootstrapQualifier=${fixture.qualifier}`,
'--toolkit-stack-name', toolkitStackName,
'--force',
],
});
fixture.log('Setup complete!');

await fixture.cdkDestroy('docker-in-use', {
options: [
'--context', `@aws-cdk/core:bootstrapQualifier=${fixture.qualifier}`,
'--toolkit-stack-name', toolkitStackName,
'--force',
],
});

await fixture.cdkGarbageCollect({
rollbackBufferDays: 100, // this will ensure that we do not delete assets immediately (and just tag them)
type: 'ecr',
bootstrapStackName: toolkitStackName,
});
fixture.log('Garbage collection complete!');

await fixture.aws.ecr.send(new ListImagesCommand({ repositoryName: repoName }))
.then((result) => {
expect(result.imageIds).toHaveLength(2); // the second tag comes in as a second 'id'
});
}),
);

integTest(
'Garbage Collection untags in-use assets',
'Garbage Collection untags in-use s3 objects',
withoutBootstrap(async (fixture) => {
const toolkitStackName = fixture.bootstrapStackName;
const bootstrapBucketName = `aws-cdk-garbage-collect-integ-test-bckt-${randomString()}`;
Expand Down Expand Up @@ -175,7 +316,7 @@ integTest(
Key: key,
Tagging: {
TagSet: [{
Key: 'aws-cdk:isolated',
Key: S3_ISOLATED_TAG,
Value: '12345',
}, {
Key: 'bogus',
Expand All @@ -200,3 +341,52 @@ integTest(
}]);
}),
);

integTest(
'Garbage Collection untags in-use ecr images',
withoutBootstrap(async (fixture) => {
const toolkitStackName = fixture.bootstrapStackName;

await fixture.cdkBootstrapModern({
toolkitStackName,
});

const repoName = await fixture.bootstrapRepoName();

await fixture.cdkDeploy('docker-in-use', {
options: [
'--context', `@aws-cdk/core:bootstrapQualifier=${fixture.qualifier}`,
'--toolkit-stack-name', toolkitStackName,
'--force',
],
});
fixture.log('Setup complete!');

// Artificially add tagging to the asset in the bootstrap bucket
const imageIds = await fixture.aws.ecr.send(new ListImagesCommand({ repositoryName: repoName }));
const digest = imageIds.imageIds![0].imageDigest;
const imageManifests = await fixture.aws.ecr.send(new BatchGetImageCommand({ repositoryName: repoName, imageIds: [{ imageDigest: digest }] }));
const manifest = imageManifests.images![0].imageManifest;
await fixture.aws.ecr.send(new PutImageCommand({ repositoryName: repoName, imageManifest: manifest, imageDigest: digest, imageTag: `${ECR_ISOLATED_TAG}-12345` }));

await fixture.cdkGarbageCollect({
rollbackBufferDays: 100, // this will ensure that we do not delete assets immediately (and just tag them)
type: 'ecr',
bootstrapStackName: toolkitStackName,
});
fixture.log('Garbage collection complete!');

await fixture.aws.ecr.send(new ListImagesCommand({ repositoryName: repoName }))
.then((result) => {
expect(result.imageIds).toHaveLength(1); // the second tag has been removed
});

await fixture.cdkDestroy('docker-in-use', {
options: [
'--context', `@aws-cdk/core:bootstrapQualifier=${fixture.qualifier}`,
'--toolkit-stack-name', toolkitStackName,
'--force',
],
});
}),
);
31 changes: 20 additions & 11 deletions packages/aws-cdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -896,28 +896,37 @@ CDK Garbage Collection.

> [!CAUTION]
> CDK Garbage Collection is under development and therefore must be opted in via the `--unstable` flag: `cdk gc --unstable=gc`.
>
> [!WARNING]
> `cdk gc` currently only supports garbage collecting S3 Assets. You must specify `cdk gc --unstable=gc --type=s3` as ECR asset garbage collection has not yet been implemented.

`cdk gc` garbage collects unused S3 assets from your bootstrap bucket via the following mechanism:
`cdk gc` garbage collects unused assets from your bootstrap bucket via the following mechanism:

- for each object in the bootstrap S3 Bucket, check to see if it is referenced in any existing CloudFormation templates
- if not, it is treated as unused and gc will either tag it or delete it, depending on your configuration.

The high-level mechanism works identically for unused assets in bootstrapped ECR Repositories.

The most basic usage looks like this:

```console
cdk gc --unstable=gc
```

This will garbage collect all unused assets in all environments of the existing CDK App.

To specify one type of asset, use the `type` option (options are `all`, `s3`, `ecr`):

```console
cdk gc --unstable=gc --type=s3
```

This will garbage collect S3 assets from the current bootstrapped environment(s) and immediately delete them. Note that, since the default bootstrap S3 Bucket is versioned, object deletion will be handled by the lifecycle
Otherwise `cdk gc` defaults to collecting assets in both the bootstrapped S3 Bucket and ECR Repository.

`cdk gc` will garbage collect S3 and ECR assets from the current bootstrapped environment(s) and immediately delete them. Note that, since the default bootstrap S3 Bucket is versioned, object deletion will be handled by the lifecycle
policy on the bucket.

Before we begin to delete your assets, you will be prompted:

```console
cdk gc --unstable=gc --type=s3
cdk gc --unstable=gc

Found X objects to delete based off of the following criteria:
- objects have been isolated for > 0 days
Expand All @@ -926,11 +935,11 @@ Found X objects to delete based off of the following criteria:
Delete this batch (yes/no/delete-all)?
```

Since it's quite possible that the bootstrap bucket has many objects, we work in batches of 1000 objects. To skip the
prompt either reply with `delete-all`, or use the `--confirm=false` option.
Since it's quite possible that the bootstrap bucket has many objects, we work in batches of 1000 objects or 100 images.
To skip the prompt either reply with `delete-all`, or use the `--confirm=false` option.

```console
cdk gc --unstable=gc --type=s3 --confirm=false
cdk gc --unstable=gc --confirm=false
```

If you are concerned about deleting assets too aggressively, there are multiple levers you can configure:
Expand All @@ -946,7 +955,7 @@ When using `created-buffer-days`, we simply filter out any assets that have not
of days.

```console
cdk gc --unstable=gc --type=s3 --rollback-buffer-days=30 --created-buffer-days=1
cdk gc --unstable=gc --rollback-buffer-days=30 --created-buffer-days=1
```

You can also configure the scope that `cdk gc` performs via the `--action` option. By default, all actions
Expand All @@ -957,7 +966,7 @@ are performed, but you can specify `print`, `tag`, or `delete-tagged`.
- `delete-tagged` deletes assets that have been tagged for longer than the buffer days, but does not tag newly unused assets.

```console
cdk gc --unstable=gc --type=s3 --action=delete-tagged --rollback-buffer-days=30
cdk gc --unstable=gc --action=delete-tagged --rollback-buffer-days=30
```

This will delete assets that have been unused for >30 days, but will not tag additional assets.
Expand Down
2 changes: 1 addition & 1 deletion packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Tag } from '../../cdk-toolkit';

export const BUCKET_NAME_OUTPUT = 'BucketName';
export const REPOSITORY_NAME_OUTPUT = 'RepositoryName';
export const REPOSITORY_NAME_OUTPUT = 'ImageRepositoryName';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was wrong and was not used anywhere

export const BUCKET_DOMAIN_NAME_OUTPUT = 'BucketDomainName';
export const BOOTSTRAP_VERSION_OUTPUT = 'BootstrapVersion';
export const BOOTSTRAP_VERSION_RESOURCE = 'CdkBootstrapVersion';
Expand Down
Loading