Skip to content

Commit a9ff382

Browse files
committed
fix(cdk-assets): re-tag docker image for multi-destination publishes
When the same container image asset is published to multiple destinations (e.g. multi-region deployments), the WorkGraphBuilder deduplicates the asset-build node so that build() only runs for one destination. This means docker.tag() is only called for the first destination's ECR URI. When publish() subsequently runs for other destinations, docker push fails with "An image does not exist locally with the tag" because the local tag was never created for those repositories. This fix adds a check in publish() that verifies the destination imageUri exists locally before pushing. If it doesn't, it finds an existing local image with the same imageTag (content hash) and re-tags it for the current destination. This is a lightweight docker tag operation that avoids any rebuild. Fixes multi-region container image deployments failing on second+ region.
1 parent a90d578 commit a9ff382

3 files changed

Lines changed: 143 additions & 0 deletions

File tree

packages/@aws-cdk/cdk-assets-lib/lib/private/docker.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,25 @@ export class Docker {
166166
await this.execute(['tag', sourceTag, targetTag]);
167167
}
168168

169+
/**
170+
* Find a local image tagged with the given imageTag (the tag portion after the colon).
171+
* Returns the full `repository:tag` string, or undefined if not found.
172+
*/
173+
public async findImageByTag(imageTag: string): Promise<string | undefined> {
174+
try {
175+
const configArgs = this.configDir ? ['--config', this.configDir] : [];
176+
const shellEventPublisher = shellEventPublisherFromEventEmitter(this.eventEmitter);
177+
const output = await shell(
178+
[getDockerCmd(), ...configArgs, 'images', '--format', '{{.Repository}}:{{.Tag}}', '--filter', `reference=*/*:${imageTag}`],
179+
{ shellEventPublisher, subprocessOutputDestination: 'ignore' },
180+
);
181+
const firstLine = output.trim().split('\n')[0]?.trim();
182+
return firstLine || undefined;
183+
} catch {
184+
return undefined;
185+
}
186+
}
187+
169188
public async push(options: PushOptions) {
170189
await this.execute(['push', options.tag], {
171190
subprocessOutputDestination: this.subprocessOutputDestination,

packages/@aws-cdk/cdk-assets-lib/lib/private/handlers/container-images.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ interface ContainerImageAssetHandlerInit {
1818

1919
export class ContainerImageAssetHandler implements IAssetHandler {
2020
private init?: ContainerImageAssetHandlerInit;
21+
private buildCompleted = false;
2122

2223
constructor(
2324
private readonly workDir: string,
@@ -60,6 +61,7 @@ export class ContainerImageAssetHandler implements IAssetHandler {
6061
}
6162

6263
await dockerForBuilding.tag(localTagName, initOnce.imageUri);
64+
this.buildCompleted = true;
6365
}
6466

6567
public async isPublished(): Promise<boolean> {
@@ -93,6 +95,20 @@ export class ContainerImageAssetHandler implements IAssetHandler {
9395
return;
9496
}
9597

98+
// When the same image asset is published to multiple destinations (e.g.
99+
// multi-region deployments), the work graph deduplicates the build step so
100+
// only one destination gets docker-tagged during build(). If build() was
101+
// not called on this handler, ensure the image is tagged locally for this
102+
// destination by re-tagging from any existing local image that carries the
103+
// same imageTag.
104+
if (!this.buildCompleted && !await dockerForPushing.exists(initOnce.imageUri)) {
105+
const imageTag = this.asset.destination.imageTag;
106+
const existing = await dockerForPushing.findImageByTag(imageTag);
107+
if (existing) {
108+
await dockerForPushing.tag(existing, initOnce.imageUri);
109+
}
110+
}
111+
96112
this.host.emitMessage(EventType.UPLOAD, `Push ${initOnce.imageUri}`);
97113
await dockerForPushing.push({
98114
tag: initOnce.imageUri,

packages/@aws-cdk/cdk-assets-lib/test/docker-images.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,31 @@ beforeEach(() => {
132132
},
133133
},
134134
}),
135+
'/multi-dest/cdk.out/assets.json': JSON.stringify({
136+
version: Manifest.version(),
137+
dockerImages: {
138+
theAsset: {
139+
source: {
140+
directory: 'dockerdir',
141+
},
142+
destinations: {
143+
dest1: {
144+
region: 'us-north-50',
145+
assumeRoleArn: 'arn:aws:role',
146+
repositoryName: 'repo1',
147+
imageTag: 'abcdef',
148+
},
149+
dest2: {
150+
region: 'eu-south-99',
151+
assumeRoleArn: 'arn:aws:role',
152+
repositoryName: 'repo2',
153+
imageTag: 'abcdef',
154+
},
155+
},
156+
},
157+
},
158+
}),
159+
'/multi-dest/cdk.out/dockerdir/Dockerfile': 'FROM scratch',
135160
'/simple/cdk.out/dockerdir/Dockerfile': 'FROM scratch',
136161
'/abs/cdk.out/assets.json': JSON.stringify({
137162
version: Manifest.version(),
@@ -844,6 +869,89 @@ test('publishing only', async () => {
844869
expect(true).toBeTruthy(); // Expect no exception, satisfy linter
845870
});
846871

872+
test('publish re-tags image when local tag is missing for a destination', async () => {
873+
const pub = new AssetPublishing(AssetManifest.fromPath(mockfs.path('/multi-dest/cdk.out')), {
874+
aws,
875+
throwOnError: true,
876+
});
877+
878+
mockEcr.on(DescribeRepositoriesCommand).callsFake((input) => {
879+
const repos: Record<string, string> = {
880+
repo1: '12345.amazonaws.com/repo1',
881+
repo2: '12345.amazonaws.com/repo2',
882+
};
883+
const url = repos[input.repositoryNames[0]];
884+
if (!url) {
885+
throw new Error(`Unexpected repo: ${JSON.stringify(input)}`);
886+
}
887+
return { repositories: [{ repositoryUri: url }] };
888+
});
889+
890+
// Image doesn't exist in either ECR destination
891+
mockEcr.on(DescribeImagesCommand).rejects(err);
892+
893+
const expectAllSpawns = mockSpawn(
894+
// First destination: build + tag + push (buildCompleted=true, skips exists check)
895+
{ commandLine: ['docker', 'login', '--username', 'user', '--password-stdin', 'proxy.com'] },
896+
{ commandLine: ['docker', 'inspect', 'cdkasset-theasset'], exitCode: 1 },
897+
{ commandLine: ['docker', 'build', '--tag', 'cdkasset-theasset', '.'], cwd: 'multi-dest/cdk.out/dockerdir' },
898+
{ commandLine: ['docker', 'tag', 'cdkasset-theasset', '12345.amazonaws.com/repo1:abcdef'] },
899+
{ commandLine: ['docker', 'push', '12345.amazonaws.com/repo1:abcdef'] },
900+
// Second destination: build finds cached image, tags, then push
901+
{ commandLine: ['docker', 'login', '--username', 'user', '--password-stdin', 'proxy.com'] },
902+
{ commandLine: ['docker', 'inspect', 'cdkasset-theasset'] },
903+
{ commandLine: ['docker', 'tag', 'cdkasset-theasset', '12345.amazonaws.com/repo2:abcdef'] },
904+
{ commandLine: ['docker', 'push', '12345.amazonaws.com/repo2:abcdef'] },
905+
);
906+
907+
await pub.publish();
908+
909+
expectAllSpawns();
910+
});
911+
912+
test('publish re-tags from existing image when build was not called for destination', async () => {
913+
const pub = new AssetPublishing(AssetManifest.fromPath(mockfs.path('/multi-dest/cdk.out')), {
914+
aws,
915+
throwOnError: true,
916+
buildAssets: false,
917+
publishAssets: true,
918+
});
919+
920+
mockEcr.on(DescribeRepositoriesCommand).callsFake((input) => {
921+
const repos: Record<string, string> = {
922+
repo1: '12345.amazonaws.com/repo1',
923+
repo2: '12345.amazonaws.com/repo2',
924+
};
925+
const url = repos[input.repositoryNames[0]];
926+
if (!url) {
927+
throw new Error(`Unexpected repo: ${JSON.stringify(input)}`);
928+
}
929+
return { repositories: [{ repositoryUri: url }] };
930+
});
931+
932+
// Image doesn't exist in either ECR destination
933+
mockEcr.on(DescribeImagesCommand).rejects(err);
934+
935+
const expectAllSpawns = mockSpawn(
936+
// First destination: publish() finds imageUri doesn't exist locally, finds by tag, re-tags
937+
{ commandLine: ['docker', 'login', '--username', 'user', '--password-stdin', 'proxy.com'] },
938+
{ commandLine: ['docker', 'inspect', '12345.amazonaws.com/repo1:abcdef'], exitCode: 1 },
939+
{ commandLine: ['docker', 'images', '--format', '{{.Repository}}:{{.Tag}}', '--filter', 'reference=*/*:abcdef'], stdout: 'cdkasset-theasset:abcdef' },
940+
{ commandLine: ['docker', 'tag', 'cdkasset-theasset:abcdef', '12345.amazonaws.com/repo1:abcdef'] },
941+
{ commandLine: ['docker', 'push', '12345.amazonaws.com/repo1:abcdef'] },
942+
// Second destination: same flow
943+
{ commandLine: ['docker', 'login', '--username', 'user', '--password-stdin', 'proxy.com'] },
944+
{ commandLine: ['docker', 'inspect', '12345.amazonaws.com/repo2:abcdef'], exitCode: 1 },
945+
{ commandLine: ['docker', 'images', '--format', '{{.Repository}}:{{.Tag}}', '--filter', 'reference=*/*:abcdef'], stdout: 'cdkasset-theasset:abcdef' },
946+
{ commandLine: ['docker', 'tag', 'cdkasset-theasset:abcdef', '12345.amazonaws.com/repo2:abcdef'] },
947+
{ commandLine: ['docker', 'push', '12345.amazonaws.com/repo2:abcdef'] },
948+
);
949+
950+
await pub.publish();
951+
952+
expectAllSpawns();
953+
});
954+
847955
test('overriding the docker command', async () => {
848956
process.env.CDK_DOCKER = 'custom';
849957

0 commit comments

Comments
 (0)