Skip to content

Commit 68ed8ca

Browse files
committed
fix(cli): assets shared between stages lead to an error (#25907)
The problem would manifest as this error message: ``` ❌ Deployment failed: Error: Duplicate use of node id: 07a6878c7a2ec9b49ef3c0ece94cef1c2dd20fba34ca9650dfa6e7e00f2b9961:current_account-current_region-build ``` The problem was that we were using the full asset "destination identifier" for both the build and publish steps, but then were trying to use the `source` object to deduplicate build steps. A more robust solution is to only use the asset identifier (excluding the destination identifier) for the build step, which includes all data necessary to deduplicate the asset. No need to look at the source at all anymore. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 0fd7f2b commit 68ed8ca

File tree

3 files changed

+83
-23
lines changed

3 files changed

+83
-23
lines changed

packages/aws-cdk/lib/util/work-graph-builder.ts

+9-10
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ export class WorkGraphBuilder {
1919
'stack': 5,
2020
};
2121
private readonly graph = new WorkGraph();
22-
private readonly assetBuildNodes = new Map<string, AssetBuildNode>;
2322

2423
constructor(private readonly prebuildAssets: boolean, private readonly idPrefix = '') { }
2524

@@ -39,12 +38,16 @@ export class WorkGraphBuilder {
3938
*/
4039
// eslint-disable-next-line max-len
4140
private addAsset(parentStack: cxapi.CloudFormationStackArtifact, assetArtifact: cxapi.AssetManifestArtifact, assetManifest: AssetManifest, asset: IManifestEntry) {
42-
const buildId = `${this.idPrefix}${asset.id}-build`;
41+
// Just the artifact identifier
42+
const assetId = asset.id.assetId;
43+
// Unique per destination where the artifact needs to go
44+
const assetDestinationId = `${asset.id}`;
4345

44-
// Add the build node, but only one per "source"
45-
// The genericSource includes a relative path we could make absolute to do more effective deduplication of build steps. Not doing that right now.
46-
const assetBuildNodeKey = JSON.stringify(asset.genericSource);
47-
if (!this.assetBuildNodes.has(assetBuildNodeKey)) {
46+
const buildId = `${this.idPrefix}${assetId}-build`;
47+
const publishNodeId = `${this.idPrefix}${assetDestinationId}-publish`;
48+
49+
// Build node only gets added once because they are all the same
50+
if (!this.graph.tryGetNode(buildId)) {
4851
const node: AssetBuildNode = {
4952
type: 'asset-build',
5053
id: buildId,
@@ -60,13 +63,9 @@ export class WorkGraphBuilder {
6063
deploymentState: DeploymentState.PENDING,
6164
priority: WorkGraphBuilder.PRIORITIES['asset-build'],
6265
};
63-
this.assetBuildNodes.set(assetBuildNodeKey, node);
6466
this.graph.addNodes(node);
6567
}
6668

67-
// Always add the publish
68-
const publishNodeId = `${this.idPrefix}${asset.id}-publish`;
69-
7069
const publishNode = this.graph.tryGetNode(publishNodeId);
7170
if (!publishNode) {
7271
this.graph.addNodes({

packages/aws-cdk/test/work-graph-builder.test.ts

+63-11
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as path from 'path';
33
import * as cxschema from '@aws-cdk/cloud-assembly-schema';
44
import * as cxapi from '@aws-cdk/cx-api';
55
import { CloudAssemblyBuilder } from '@aws-cdk/cx-api';
6+
import { WorkGraph } from '../lib/util/work-graph';
67
import { WorkGraphBuilder } from '../lib/util/work-graph-builder';
78
import { AssetBuildNode, AssetPublishNode, StackNode, WorkNode } from '../lib/util/work-graph-types';
89

@@ -36,14 +37,14 @@ describe('with some stacks and assets', () => {
3637

3738
expect(graph.node('F1:D1-publish')).toEqual(expect.objectContaining({
3839
type: 'asset-publish',
39-
dependencies: new Set(['F1:D1-build']),
40+
dependencies: new Set(['F1-build']),
4041
} as Partial<AssetPublishNode>));
4142
});
4243

4344
test('with prebuild off, asset building inherits dependencies from their parent stack', () => {
4445
const graph = new WorkGraphBuilder(false).build(assembly.artifacts);
4546

46-
expect(graph.node('F1:D1-build')).toEqual(expect.objectContaining({
47+
expect(graph.node('F1-build')).toEqual(expect.objectContaining({
4748
type: 'asset-build',
4849
dependencies: new Set(['stack0', 'stack1']),
4950
} as Partial<AssetBuildNode>));
@@ -52,7 +53,7 @@ describe('with some stacks and assets', () => {
5253
test('with prebuild on, assets only have their own dependencies', () => {
5354
const graph = new WorkGraphBuilder(true).build(assembly.artifacts);
5455

55-
expect(graph.node('F1:D1-build')).toEqual(expect.objectContaining({
56+
expect(graph.node('F1-build')).toEqual(expect.objectContaining({
5657
type: 'asset-build',
5758
dependencies: new Set(['stack0']),
5859
} as Partial<AssetBuildNode>));
@@ -138,17 +139,11 @@ describe('tests that use assets', () => {
138139

139140
const assembly = rootBuilder.buildAssembly();
140141

141-
const traversal: string[] = [];
142142
const graph = new WorkGraphBuilder(true).build(assembly.artifacts);
143-
await graph.doParallel(1, {
144-
deployStack: async (node) => { traversal.push(node.id); },
145-
buildAsset: async (node) => { traversal.push(node.id); },
146-
publishAsset: async (node) => { traversal.push(node.id); },
147-
});
143+
const traversal = await traverseAndRecord(graph);
148144

149-
expect(traversal).toHaveLength(4); // 1 asset build, 1 asset publish, 2 stacks
150145
expect(traversal).toEqual([
151-
'work-graph-builder.test.js:D1-build',
146+
'work-graph-builder.test.js-build',
152147
'work-graph-builder.test.js:D1-publish',
153148
'StackA',
154149
'StackB',
@@ -171,6 +166,53 @@ describe('tests that use assets', () => {
171166
// THEN
172167
expect(graph.findCycle()).toBeUndefined();
173168
});
169+
170+
test('the same asset to different destinations is only built once', async () => {
171+
addStack(rootBuilder, 'StackA', {
172+
environment: 'aws://11111/us-east-1',
173+
dependencies: ['StackA.assets'],
174+
});
175+
addAssets(rootBuilder, 'StackA.assets', {
176+
files: {
177+
abcdef: {
178+
source: { path: __dirname },
179+
destinations: {
180+
D1: { bucketName: 'bucket1', objectKey: 'key' },
181+
D2: { bucketName: 'bucket2', objectKey: 'key' },
182+
},
183+
},
184+
},
185+
});
186+
187+
addStack(rootBuilder, 'StackB', {
188+
environment: 'aws://11111/us-east-1',
189+
dependencies: ['StackB.assets', 'StackA'],
190+
});
191+
addAssets(rootBuilder, 'StackB.assets', {
192+
files: {
193+
abcdef: {
194+
source: { path: __dirname },
195+
destinations: {
196+
D3: { bucketName: 'bucket3', objectKey: 'key' },
197+
},
198+
},
199+
},
200+
});
201+
202+
const assembly = rootBuilder.buildAssembly();
203+
204+
const graph = new WorkGraphBuilder(true).build(assembly.artifacts);
205+
const traversal = await traverseAndRecord(graph);
206+
207+
expect(traversal).toEqual([
208+
'abcdef-build',
209+
'abcdef:D1-publish',
210+
'abcdef:D2-publish',
211+
'StackA',
212+
'abcdef:D3-publish',
213+
'StackB',
214+
]);
215+
});
174216
});
175217

176218
/**
@@ -251,3 +293,13 @@ function assertableNode<A extends WorkNode>(x: A) {
251293
dependencies: Array.from(x.dependencies),
252294
};
253295
}
296+
297+
async function traverseAndRecord(graph: WorkGraph) {
298+
const ret: string[] = [];
299+
await graph.doParallel(1, {
300+
deployStack: async (node) => { ret.push(node.id); },
301+
buildAsset: async (node) => { ret.push(node.id); },
302+
publishAsset: async (node) => { ret.push(node.id); },
303+
});
304+
return ret;
305+
}

packages/cdk-assets/lib/asset-manifest.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,10 @@ export class AssetManifest {
106106
}
107107

108108
/**
109-
* List of assets, splat out to destinations
109+
* List of assets per destination
110+
*
111+
* Returns one asset for every publishable destination. Multiple asset
112+
* destinations may share the same asset source.
110113
*/
111114
public get entries(): IManifestEntry[] {
112115
return [
@@ -145,7 +148,7 @@ const ASSET_TYPES: AssetType[] = ['files', 'dockerImages'];
145148
*/
146149
export interface IManifestEntry {
147150
/**
148-
* The identifier of the asset
151+
* The identifier of the asset and its destination
149152
*/
150153
readonly id: DestinationIdentifier;
151154

@@ -209,10 +212,16 @@ export class DockerImageManifestEntry implements IManifestEntry {
209212

210213
/**
211214
* Identify an asset destination in an asset manifest
215+
*
216+
* When stringified, this will be a combination of the source
217+
* and destination IDs.
212218
*/
213219
export class DestinationIdentifier {
214220
/**
215221
* Identifies the asset, by source.
222+
*
223+
* The assetId will be the same between assets that represent
224+
* the same physical file or image.
216225
*/
217226
public readonly assetId: string;
218227

0 commit comments

Comments
 (0)