Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 26 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 @@ -864,6 +864,24 @@ class MultipleDockerAssetsStack extends cdk.Stack {
}
}

/**
* A stack that uses the same docker image as another stack, to test that the
* same image asset can be published to multiple destinations without rebuilding.
*/
class DockerMultiDestStack extends cdk.Stack {
constructor(parent, id, props) {
super(parent, id, props);

new docker.DockerImageAsset(this, 'image', {
directory: path.join(__dirname, 'docker')
});

new cdk.CfnResource(this, 'Handle', {
type: 'AWS::CloudFormation::WaitConditionHandle'
});
}
}

/**
* A stack that will never succeed deploying (done in a way that CDK cannot detect but CFN will complain about)
*/
Expand Down Expand Up @@ -1081,6 +1099,14 @@ switch (stackSet) {
new DockerInUseStack(app, `${stackPrefix}-docker-in-use`);
new DockerStackWithCustomFile(app, `${stackPrefix}-docker-with-custom-file`);
new MultipleDockerAssetsStack(app, `${stackPrefix}-multiple-docker-assets`);
new DockerMultiDestStack(app, `${stackPrefix}-docker-multi-dest-1`, { env: defaultEnv });
new DockerMultiDestStack(app, `${stackPrefix}-docker-multi-dest-2`, { env: defaultEnv });

if (process.env.CDK_SECONDARY_REGION) {
const secondaryEnv = { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_SECONDARY_REGION };
new DockerMultiDestStack(app, `${stackPrefix}-docker-multi-region-1`, { env: defaultEnv });
new DockerMultiDestStack(app, `${stackPrefix}-docker-multi-region-2`, { env: secondaryEnv });
}

new NotificationArnsStack(app, `${stackPrefix}-notification-arns`);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { integTest, withDefaultFixture } from '../../../lib';

integTest(
'deploy same docker asset to multiple stacks',
withDefaultFixture(async (fixture) => {
await fixture.cdkDeploy(['docker-multi-dest-1', 'docker-multi-dest-2']);
}),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { integTest, withDefaultFixture } from '../../../lib';

integTest(
'deploy same docker asset to multiple regions',
withDefaultFixture(async (fixture) => {
const primaryRegion = fixture.aws.region;
const availableRegions = (process.env.AWS_REGIONS ?? 'us-east-1,us-west-2').split(',');
const secondaryRegion = availableRegions.find((r) => r !== primaryRegion) ?? 'us-west-2';

// Bootstrap the secondary region
const account = await fixture.aws.account();
await fixture.cdk(['bootstrap', '--bootstrap-kms-key-id', 'AWS_MANAGED_KEY', `aws://${account}/${secondaryRegion}`], {
modEnv: { CDK_NEW_BOOTSTRAP: '1' },
});

// Deploy both stacks — same docker source, different target regions
await fixture.cdkDeploy(['docker-multi-region-1', 'docker-multi-region-2'], {
modEnv: { CDK_SECONDARY_REGION: secondaryRegion },
});
}),
);
19 changes: 19 additions & 0 deletions packages/@aws-cdk/cdk-assets-lib/lib/private/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,25 @@ export class Docker {
await this.execute(['tag', sourceTag, targetTag]);
}

/**
* Find a local image tagged with the given imageTag (the tag portion after the colon).
* Returns the full `repository:tag` string, or undefined if not found.
*/
public async findImageByTag(imageTag: string): Promise<string | undefined> {
try {
const configArgs = this.configDir ? ['--config', this.configDir] : [];
const shellEventPublisher = shellEventPublisherFromEventEmitter(this.eventEmitter);
const output = await shell(
[getDockerCmd(), ...configArgs, 'images', '--format', '{{.Repository}}:{{.Tag}}', '--filter', `reference=*/*:${imageTag}`],
{ shellEventPublisher, subprocessOutputDestination: 'ignore' },
);
const firstLine = output.trim().split('\n')[0]?.trim();
return firstLine || undefined;
} catch {
return undefined;
}
}

public async push(options: PushOptions) {
await this.execute(['push', options.tag], {
subprocessOutputDestination: this.subprocessOutputDestination,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface ContainerImageAssetHandlerInit {

export class ContainerImageAssetHandler implements IAssetHandler {
private init?: ContainerImageAssetHandlerInit;
private buildCompleted = false;

constructor(
private readonly workDir: string,
Expand Down Expand Up @@ -60,6 +61,7 @@ export class ContainerImageAssetHandler implements IAssetHandler {
}

await dockerForBuilding.tag(localTagName, initOnce.imageUri);
this.buildCompleted = true;
}

public async isPublished(): Promise<boolean> {
Expand Down Expand Up @@ -93,6 +95,20 @@ export class ContainerImageAssetHandler implements IAssetHandler {
return;
}

// When the same image asset is published to multiple destinations (e.g.
// multi-region deployments), the work graph deduplicates the build step so
// only one destination gets docker-tagged during build(). If build() was
// not called on this handler, ensure the image is tagged locally for this
// destination by re-tagging from any existing local image that carries the
// same imageTag.
if (!this.buildCompleted && !await dockerForPushing.exists(initOnce.imageUri)) {
const imageTag = this.asset.destination.imageTag;
const existing = await dockerForPushing.findImageByTag(imageTag);
if (existing) {
await dockerForPushing.tag(existing, initOnce.imageUri);
}
}

this.host.emitMessage(EventType.UPLOAD, `Push ${initOnce.imageUri}`);
await dockerForPushing.push({
tag: initOnce.imageUri,
Expand Down
106 changes: 106 additions & 0 deletions packages/@aws-cdk/cdk-assets-lib/test/docker-images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,31 @@ beforeEach(() => {
},
},
}),
'/multi-dest/cdk.out/assets.json': JSON.stringify({
version: Manifest.version(),
dockerImages: {
theAsset: {
source: {
directory: 'dockerdir',
},
destinations: {
dest1: {
region: 'us-north-50',
assumeRoleArn: 'arn:aws:role',
repositoryName: 'repo1',
imageTag: 'abcdef',
},
dest2: {
region: 'eu-south-99',
assumeRoleArn: 'arn:aws:role',
repositoryName: 'repo2',
imageTag: 'abcdef',
},
},
},
},
}),
'/multi-dest/cdk.out/dockerdir/Dockerfile': 'FROM scratch',
'/simple/cdk.out/dockerdir/Dockerfile': 'FROM scratch',
'/abs/cdk.out/assets.json': JSON.stringify({
version: Manifest.version(),
Expand Down Expand Up @@ -844,6 +869,87 @@ test('publishing only', async () => {
expect(true).toBeTruthy(); // Expect no exception, satisfy linter
});

test('publish re-tags image when local tag is missing for a destination', async () => {
const pub = new AssetPublishing(AssetManifest.fromPath(mockfs.path('/multi-dest/cdk.out')), {
aws,
throwOnError: true,
});

mockEcr.on(DescribeRepositoriesCommand).callsFake((input) => {
const repos: Record<string, string> = {
repo1: '12345.amazonaws.com/repo1',
repo2: '12345.amazonaws.com/repo2',
};
const url = repos[input.repositoryNames[0]];
if (!url) {
throw new Error(`Unexpected repo: ${JSON.stringify(input)}`);
}
return { repositories: [{ repositoryUri: url }] };
});

// Image doesn't exist in either ECR destination
mockEcr.on(DescribeImagesCommand).rejects(err);

const expectAllSpawns = mockSpawn(
// First destination: login + build + tag + push (buildCompleted=true, skips exists check)
{ commandLine: ['docker', 'login', '--username', 'user', '--password-stdin', 'proxy.com'] },
{ commandLine: ['docker', 'inspect', 'cdkasset-theasset'], exitCode: 1 },
{ commandLine: ['docker', 'build', '--tag', 'cdkasset-theasset', '.'], cwd: 'multi-dest/cdk.out/dockerdir' },
{ commandLine: ['docker', 'tag', 'cdkasset-theasset', '12345.amazonaws.com/repo1:abcdef'] },
{ commandLine: ['docker', 'push', '12345.amazonaws.com/repo1:abcdef'] },
// Second destination: login skipped (same domain), build finds cached image, tags, push
{ commandLine: ['docker', 'inspect', 'cdkasset-theasset'] },
{ commandLine: ['docker', 'tag', 'cdkasset-theasset', '12345.amazonaws.com/repo2:abcdef'] },
{ commandLine: ['docker', 'push', '12345.amazonaws.com/repo2:abcdef'] },
);

await pub.publish();

expectAllSpawns();
});

test('publish re-tags from existing image when build was not called for destination', async () => {
const pub = new AssetPublishing(AssetManifest.fromPath(mockfs.path('/multi-dest/cdk.out')), {
aws,
throwOnError: true,
buildAssets: false,
publishAssets: true,
});

mockEcr.on(DescribeRepositoriesCommand).callsFake((input) => {
const repos: Record<string, string> = {
repo1: '12345.amazonaws.com/repo1',
repo2: '12345.amazonaws.com/repo2',
};
const url = repos[input.repositoryNames[0]];
if (!url) {
throw new Error(`Unexpected repo: ${JSON.stringify(input)}`);
}
return { repositories: [{ repositoryUri: url }] };
});

// Image doesn't exist in either ECR destination
mockEcr.on(DescribeImagesCommand).rejects(err);

const expectAllSpawns = mockSpawn(
// First destination: login + exists check fails + find by tag + re-tag + push
{ commandLine: ['docker', 'login', '--username', 'user', '--password-stdin', 'proxy.com'] },
{ commandLine: ['docker', 'inspect', '12345.amazonaws.com/repo1:abcdef'], exitCode: 1 },
{ commandLine: ['docker', 'images', '--format', '{{.Repository}}:{{.Tag}}', '--filter', 'reference=*/*:abcdef'], stdout: 'cdkasset-theasset:abcdef' },
{ commandLine: ['docker', 'tag', 'cdkasset-theasset:abcdef', '12345.amazonaws.com/repo1:abcdef'] },
{ commandLine: ['docker', 'push', '12345.amazonaws.com/repo1:abcdef'] },
// Second destination: login skipped (same domain), same re-tag flow
{ commandLine: ['docker', 'inspect', '12345.amazonaws.com/repo2:abcdef'], exitCode: 1 },
{ commandLine: ['docker', 'images', '--format', '{{.Repository}}:{{.Tag}}', '--filter', 'reference=*/*:abcdef'], stdout: 'cdkasset-theasset:abcdef' },
{ commandLine: ['docker', 'tag', 'cdkasset-theasset:abcdef', '12345.amazonaws.com/repo2:abcdef'] },
{ commandLine: ['docker', 'push', '12345.amazonaws.com/repo2:abcdef'] },
);

await pub.publish();

expectAllSpawns();
});

test('overriding the docker command', async () => {
process.env.CDK_DOCKER = 'custom';

Expand Down
Loading