Skip to content

Commit

Permalink
feat(cli): garbage collect ecr assets (under --unstable flag) (#31841)
Browse files Browse the repository at this point in the history
Follow up to #31611 which introduced S3 Asset Garbage Collection

## ECR Asset Garbage Collection

`cdk gc` now collects ECR assets.

```bash
cdk gc aws://0123456789012/us-east-1 \
  --unstable='gc' \
  --type='ecr'
```

or 

```bash
cdk gc aws://0123456789012/us-east-1 \
  --unstable='gc' \
  --type='all'
```

all other options are duplicated from s3.


----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
kaizencc authored Oct 26, 2024
1 parent cb3ecfe commit da85e54
Show file tree
Hide file tree
Showing 11 changed files with 1,075 additions and 202 deletions.
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
14 changes: 14 additions & 0 deletions packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js
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: `0-${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';
export const BUCKET_DOMAIN_NAME_OUTPUT = 'BucketDomainName';
export const BOOTSTRAP_VERSION_OUTPUT = 'BootstrapVersion';
export const BOOTSTRAP_VERSION_RESOURCE = 'CdkBootstrapVersion';
Expand Down
Loading

0 comments on commit da85e54

Please sign in to comment.