Skip to content

Commit 0d8142b

Browse files
authored
fix(pipelines): cross-stack step dependencies have wrong name (#23594)
We used to immediately recurse into the dependencies of a Step, and add a new Node for them to the current Graph if they didn't exist yet. This was originally written like this to support `inputs` for the `ShellSteps`, which would automatically add in any necessary Nodes into the same Graph as necessary without explicitly adding them. However, it would also have the effect that Steps which **would have** been added to the Graph would be parented incorrectly (they would be eagerly added to the wrong parent). Do this in two stages now: first try to resolve all step dependencies between things that are added to the graph, then create new Nodes for missing Step dependencies later. Fixes #21843. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 0ce19f0 commit 0d8142b

File tree

5 files changed

+204
-29
lines changed

5 files changed

+204
-29
lines changed

packages/@aws-cdk/pipelines/lib/helpers-internal/graph.ts

+95-10
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ export class GraphNode<A> {
7373
return x;
7474
}
7575

76+
public get rootGraph(): Graph<A> {
77+
const root = this.root;
78+
if (!(root instanceof Graph)) {
79+
throw new Error(`Expecting a graph as root, got: ${root}`);
80+
}
81+
return root;
82+
}
83+
7684
public get parentGraph() {
7785
return this._parentGraph;
7886
}
@@ -93,50 +101,103 @@ export class GraphNode<A> {
93101
}
94102

95103
/**
96-
* A dependency set that can be constructed partially and later finished
104+
* A dependency set that is constructed over time
97105
*
98106
* It doesn't matter in what order sources and targets for the dependency
99107
* relationship(s) get added. This class can serve as a synchronization
100108
* point if the order in which graph nodes get added to the graph is not
101109
* well-defined.
102110
*
103-
* Useful utility during graph building.
111+
* You can think of a DependencyBuilder as a vertex that doesn't actually exist in the tree:
112+
*
113+
* ┌────┐ ┌────┐
114+
* │ P1 │◀─┐ ┌──│ S1 │
115+
* └────┘ │ .─. │ └────┘
116+
* ├──( B )◀─┤
117+
* ┌────┐ │ `─' │ ┌────┐
118+
* │ P2 │◀─┘ └──│ S2 │
119+
* └────┘ └────┘
120+
*
121+
* Ultimately leads to: { S1 -> P1, S1 -> P2, S2 -> P1, S2 -> P2 }.
104122
*/
105123
export class DependencyBuilder<A> {
106-
private readonly targets: GraphNode<A>[] = [];
107-
private readonly sources: GraphNode<A>[] = [];
124+
private readonly _producers: GraphNode<A>[] = [];
125+
private readonly _consumers: GraphNode<A>[] = [];
108126

127+
/**
128+
* Add a producer: make all nodes added by 'dependBy' depend on these
129+
*/
109130
public dependOn(...targets: GraphNode<A>[]) {
110131
for (const target of targets) {
111-
for (const source of this.sources) {
132+
for (const source of this._consumers) {
112133
source.dependOn(target);
113134
}
114-
this.targets.push(target);
135+
this._producers.push(target);
115136
}
116137
return this;
117138
}
118139

140+
/**
141+
* Add a consumer: make these nodes depend on all nodes added by 'dependOn'.
142+
*/
119143
public dependBy(...sources: GraphNode<A>[]) {
120144
for (const source of sources) {
121-
for (const target of this.targets) {
145+
for (const target of this._producers) {
122146
source.dependOn(target);
123147
}
124-
this.sources.push(source);
148+
this._consumers.push(source);
125149
}
126150
return this;
127151
}
152+
153+
/**
154+
* Whether there are any consumers (nodes added by 'dependBy') but no producers (nodes added by 'dependOn')
155+
*/
156+
public get hasUnsatisfiedConsumers() {
157+
return this._consumers.length > 0 && this._producers.length === 0;
158+
}
159+
160+
public get consumers(): ReadonlyArray<GraphNode<A>> {
161+
return this._consumers;
162+
}
163+
164+
public consumersAsString() {
165+
return this.consumers.map(c => `${c}`).join(',');
166+
}
128167
}
129168

130-
export class DependencyBuilders<K, A> {
169+
/**
170+
* A set of dependency builders identified by a given key.
171+
*/
172+
export class DependencyBuilders<K, A=any> {
131173
private readonly builders = new Map<K, DependencyBuilder<A>>();
132174

133-
public get(key: K) {
175+
public for(key: K) {
134176
const b = this.builders.get(key);
135177
if (b) { return b; }
136178
const ret = new DependencyBuilder<A>();
137179
this.builders.set(key, ret);
138180
return ret;
139181
}
182+
183+
/**
184+
* @deprecated Use 'for'
185+
*/
186+
public get(key: K) {
187+
return this.for(key);
188+
}
189+
190+
public unsatisfiedBuilders() {
191+
const ret = new Array<[K, DependencyBuilder<A>]>();
192+
193+
for (const [k, builder] of this.builders.entries()) {
194+
if (builder.hasUnsatisfiedConsumers) {
195+
ret.push([k, builder]);
196+
}
197+
}
198+
199+
return ret;
200+
}
140201
}
141202

142203
export interface GraphProps<A> extends GraphNodeProps<A> {
@@ -304,12 +365,32 @@ export class GraphNodeCollection<A> {
304365
this.nodes = Array.from(nodes);
305366
}
306367

368+
/**
369+
* Add one or more dependencies to all nodes in the collection
370+
*/
307371
public dependOn(...dependencies: Array<GraphNode<A> | undefined>) {
308372
for (const node of this.nodes) {
309373
node.dependOn(...dependencies.filter(isDefined));
310374
}
311375
}
312376

377+
/**
378+
* Return the topographically first node in the collection
379+
*/
380+
public first() {
381+
const nodes = new Set(this.nodes);
382+
const sorted = this.nodes[0].rootGraph.sortedLeaves();
383+
for (const tranche of sorted) {
384+
for (const node of tranche) {
385+
if (nodes.has(node)) {
386+
return node;
387+
}
388+
}
389+
}
390+
391+
throw new Error(`Could not calculate first node between ${this}`);
392+
}
393+
313394
/**
314395
* Returns the graph node that's shared between these nodes
315396
*/
@@ -351,6 +432,10 @@ export class GraphNodeCollection<A> {
351432

352433
return paths[0][0];
353434
}
435+
436+
public toString() {
437+
return this.nodes.map(n => `${n}`).join(', ');
438+
}
354439
}
355440

356441
/**

packages/@aws-cdk/pipelines/lib/helpers-internal/pipeline-graph.ts

+54-13
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ export class PipelineGraph {
5454
private readonly assetNodesByType = new Map<AssetType, AGraphNode>();
5555
private readonly synthNode?: AGraphNode;
5656
private readonly selfMutateNode?: AGraphNode;
57-
private readonly stackOutputDependencies = new DependencyBuilders<StackDeployment, any>();
57+
private readonly stackOutputDependencies = new DependencyBuilders<StackDeployment>();
58+
/** Mapping steps to depbuilders, satisfied by the step itself */
59+
private readonly nodeDependencies = new DependencyBuilders<Step>();
5860
private readonly publishTemplate: boolean;
5961
private readonly prepareStep: boolean;
6062
private readonly singlePublisher: boolean;
@@ -102,16 +104,15 @@ export class PipelineGraph {
102104
waves[i].dependOn(waves[i - 1]);
103105
}
104106

105-
// Add additional dependencies between steps that depend on stack outputs and the stacks
106-
// that produce them.
107+
this.addMissingDependencyNodes();
107108
}
108109

109110
public isSynthNode(node: AGraphNode) {
110111
return this.synthNode === node;
111112
}
112113

113114
private addBuildStep(step: Step) {
114-
return this.addAndRecurse(step, this.topLevelGraph('Build'));
115+
return this.addStepNode(step, this.topLevelGraph('Build'));
115116
}
116117

117118
private addWave(wave: Wave): AGraph {
@@ -174,7 +175,7 @@ export class PipelineGraph {
174175

175176
const cloudAssembly = this.cloudAssemblyFileSet;
176177

177-
firstDeployNode.dependOn(this.addAndRecurse(cloudAssembly.producer, retGraph));
178+
firstDeployNode.dependOn(this.addStepNode(cloudAssembly.producer, retGraph));
178179

179180
// add the template asset
180181
if (this.publishTemplate) {
@@ -195,7 +196,7 @@ export class PipelineGraph {
195196

196197
// Add stack output synchronization point
197198
if (this.queries.stackOutputsReferenced(stack).length > 0) {
198-
this.stackOutputDependencies.get(stack).dependOn(deployNode);
199+
this.stackOutputDependencies.for(stack).dependOn(deployNode);
199200
}
200201
}
201202

@@ -220,7 +221,7 @@ export class PipelineGraph {
220221

221222
private addChangeSetNode(changeSet: Step[], prepareNode: AGraphNode, deployNode: AGraphNode, graph: AGraph) {
222223
for (const c of changeSet) {
223-
const changeSetNode = this.addAndRecurse(c, graph);
224+
const changeSetNode = this.addStepNode(c, graph);
224225
changeSetNode?.dependOn(prepareNode);
225226
deployNode.dependOn(changeSetNode);
226227
}
@@ -230,12 +231,12 @@ export class PipelineGraph {
230231
const currentNodes = new GraphNodeCollection(parent.nodes);
231232
const preNodes = new GraphNodeCollection(new Array<AGraphNode>());
232233
for (const p of pre) {
233-
const preNode = this.addAndRecurse(p, parent);
234+
const preNode = this.addStepNode(p, parent);
234235
currentNodes.dependOn(preNode);
235236
preNodes.nodes.push(preNode!);
236237
}
237238
for (const p of post) {
238-
const postNode = this.addAndRecurse(p, parent);
239+
const postNode = this.addStepNode(p, parent);
239240
postNode?.dependOn(...currentNodes.nodes);
240241
}
241242
return preNodes;
@@ -250,7 +251,12 @@ export class PipelineGraph {
250251
return ret as AGraph;
251252
}
252253

253-
private addAndRecurse(step: Step, parent: AGraph) {
254+
/**
255+
* Add a Node to a Graph for a given Step
256+
*
257+
* Adds all dependencies for that Node to the same Step as well.
258+
*/
259+
private addStepNode(step: Step, parent: AGraph) {
254260
if (step === PipelineGraph.NO_STEP) { return undefined; }
255261

256262
const previous = this.added.get(step);
@@ -267,21 +273,56 @@ export class PipelineGraph {
267273
parent.add(node);
268274
this.added.set(step, node);
269275

276+
// This used to recurse -- that's not safe, because it might create nodes in the
277+
// wrong graph (it would create a dependency node, that might need to be created in
278+
// a different graph, in the current one). Instead, use DependencyBuilders.
270279
for (const dep of step.dependencies) {
271-
const producerNode = this.addAndRecurse(dep, parent);
272-
node.dependOn(producerNode);
280+
this.nodeDependencies.for(dep).dependBy(node);
273281
}
282+
this.nodeDependencies.for(step).dependOn(node);
274283

275284
// Add stack dependencies (by use of the dependency builder this also works
276285
// if we encounter the Step before the Stack has been properly added yet)
277286
for (const output of step.consumedStackOutputs) {
278287
const stack = this.queries.producingStack(output);
279-
this.stackOutputDependencies.get(stack).dependBy(node);
288+
this.stackOutputDependencies.for(stack).dependBy(node);
280289
}
281290

282291
return node;
283292
}
284293

294+
/**
295+
* Add dependencies that aren't in the pipeline yet
296+
*
297+
* Build steps reference as many sources (or other builds) as they want, which will be added
298+
* automatically. Do that here. We couldn't do it earlier, because if there were dependencies
299+
* between steps we didn't want to reparent those unnecessarily.
300+
*/
301+
private addMissingDependencyNodes() {
302+
// May need to do this more than once to recursively add all missing producers
303+
let attempts = 20;
304+
while (attempts-- > 0) {
305+
const unsatisfied = this.nodeDependencies.unsatisfiedBuilders().filter(([s]) => s !== PipelineGraph.NO_STEP);
306+
if (unsatisfied.length === 0) { return; }
307+
308+
for (const [step, builder] of unsatisfied) {
309+
// Add a new node for this step to the parent of the "leftmost" consumer.
310+
const leftMostConsumer = new GraphNodeCollection(builder.consumers).first();
311+
const parent = leftMostConsumer.parentGraph;
312+
if (!parent) {
313+
throw new Error(`Consumer doesn't have a parent graph: ${leftMostConsumer}`);
314+
}
315+
this.addStepNode(step, parent);
316+
}
317+
}
318+
319+
const unsatisfied = this.nodeDependencies.unsatisfiedBuilders();
320+
throw new Error([
321+
'Recursion depth too large while adding dependency nodes:',
322+
unsatisfied.map(([step, builder]) => `${builder.consumersAsString()} awaiting ${step}.`),
323+
].join(' '));
324+
}
325+
285326
private publishAsset(stackAsset: StackAsset): AGraphNode {
286327
const assetsGraph = this.topLevelGraph('Assets');
287328

packages/@aws-cdk/pipelines/test/blueprint/helpers-internal/pipeline-graph.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -340,8 +340,8 @@ describe('with app with output', () => {
340340
});
341341

342342
// WHEN
343-
const graph = new PipelineGraph(blueprint).graph;
344343
expect(() => {
344+
const graph = new PipelineGraph(blueprint).graph;
345345
assertGraph(nodeAt(graph, 'Alpha')).sortedLeaves();
346346
}).toThrow(/Dependency cycle/);
347347
});

packages/@aws-cdk/pipelines/test/codepipeline/codepipeline.test.ts

+36-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Stack } from '@aws-cdk/core';
88
import { Construct } from 'constructs';
99
import * as cdkp from '../../lib';
1010
import { CodePipeline } from '../../lib';
11-
import { PIPELINE_ENV, TestApp, ModernTestGitHubNpmPipeline, FileAssetApp } from '../testhelpers';
11+
import { PIPELINE_ENV, TestApp, ModernTestGitHubNpmPipeline, FileAssetApp, TwoStackApp } from '../testhelpers';
1212

1313
let app: TestApp;
1414

@@ -298,6 +298,41 @@ describe('deployment of stack', () => {
298298
});
299299
});
300300

301+
test('action name is calculated properly if it has cross-stack dependencies', () => {
302+
// GIVEN
303+
const pipelineStack = new cdk.Stack(app, 'PipelineStack', { env: PIPELINE_ENV });
304+
const pipeline = new ModernTestGitHubNpmPipeline(pipelineStack, 'Cdk', {
305+
crossAccountKeys: true,
306+
});
307+
308+
// WHEN
309+
const s1step = new cdkp.ManualApprovalStep('S1');
310+
const s2step = new cdkp.ManualApprovalStep('S2');
311+
s1step.addStepDependency(s2step);
312+
313+
// The issue we were diagnosing only manifests if the stacks don't have
314+
// a dependency on each other
315+
const stage = new TwoStackApp(app, 'TheApp', { withDependency: false });
316+
pipeline.addStage(stage, {
317+
stackSteps: [
318+
{ stack: stage.stack1, post: [s1step] },
319+
{ stack: stage.stack2, post: [s2step] },
320+
],
321+
});
322+
323+
// THEN
324+
const template = Template.fromStack(pipelineStack);
325+
template.hasResourceProperties('AWS::CodePipeline::Pipeline', {
326+
Stages: Match.arrayWith([{
327+
Name: 'TheApp',
328+
Actions: Match.arrayWith([
329+
Match.objectLike({ Name: 'Stack2.S2', RunOrder: 3 }),
330+
Match.objectLike({ Name: 'Stack1.S1', RunOrder: 4 }),
331+
]),
332+
}]),
333+
});
334+
});
335+
301336
interface ReuseCodePipelineStackProps extends cdk.StackProps {
302337
reuseCrossRegionSupportStacks?: boolean;
303338
}

0 commit comments

Comments
 (0)