Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
105 changes: 95 additions & 10 deletions packages/@aws-cdk/pipelines/lib/helpers-internal/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ export class GraphNode<A> {
return x;
}

public get rootGraph(): Graph<A> {
const root = this.root;
if (!(root instanceof Graph)) {
throw new Error(`Expecting a graph as root, got: ${root}`);
}
return root;
}

public get parentGraph() {
return this._parentGraph;
}
Expand All @@ -93,50 +101,103 @@ export class GraphNode<A> {
}

/**
* A dependency set that can be constructed partially and later finished
* A dependency set that is constructed over time
*
* It doesn't matter in what order sources and targets for the dependency
* relationship(s) get added. This class can serve as a synchronization
* point if the order in which graph nodes get added to the graph is not
* well-defined.
*
* Useful utility during graph building.
* You can think of a DependencyBuilder as a vertex that doesn't actually exist in the tree:
*
* ┌────┐ ┌────┐
* │ P1 │◀─┐ ┌──│ S1 │
* └────┘ │ .─. │ └────┘
* ├──( B )◀─┤
* ┌────┐ │ `─' │ ┌────┐
* │ P2 │◀─┘ └──│ S2 │
* └────┘ └────┘
*
* Ultimately leads to: { S1 -> P1, S1 -> P2, S2 -> P1, S2 -> P2 }.
*/
export class DependencyBuilder<A> {
private readonly targets: GraphNode<A>[] = [];
private readonly sources: GraphNode<A>[] = [];
private readonly _producers: GraphNode<A>[] = [];
private readonly _consumers: GraphNode<A>[] = [];

/**
* Add a producer: make all nodes added by 'dependBy' depend on these
*/
public dependOn(...targets: GraphNode<A>[]) {
for (const target of targets) {
for (const source of this.sources) {
for (const source of this._consumers) {
source.dependOn(target);
}
this.targets.push(target);
this._producers.push(target);
}
return this;
}

/**
* Add a consumer: make these nodes depend on all nodes added by 'dependOn'.
*/
public dependBy(...sources: GraphNode<A>[]) {
for (const source of sources) {
for (const target of this.targets) {
for (const target of this._producers) {
source.dependOn(target);
}
this.sources.push(source);
this._consumers.push(source);
}
return this;
}

/**
* Whether there are any consumers (nodes added by 'dependBy') but no producers (nodes added by 'dependOn')
*/
public get hasUnsatisfiedConsumers() {
return this._consumers.length > 0 && this._producers.length === 0;
}

public get consumers(): ReadonlyArray<GraphNode<A>> {
return this._consumers;
}

public consumersAsString() {
return this.consumers.map(c => `${c}`).join(',');
}
}

export class DependencyBuilders<K, A> {
/**
* A set of dependency builders identified by a given key.
*/
export class DependencyBuilders<K, A=any> {
private readonly builders = new Map<K, DependencyBuilder<A>>();

public get(key: K) {
public for(key: K) {
const b = this.builders.get(key);
if (b) { return b; }
const ret = new DependencyBuilder<A>();
this.builders.set(key, ret);
return ret;
}

/**
* @deprecated Use 'for'
*/
public get(key: K) {
return this.for(key);
}

public unsatisfiedBuilders() {
const ret = new Array<[K, DependencyBuilder<A>]>();

for (const [k, builder] of this.builders.entries()) {
if (builder.hasUnsatisfiedConsumers) {
ret.push([k, builder]);
}
}

return ret;
}
}

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

/**
* Add one or more dependencies to all nodes in the collection
*/
public dependOn(...dependencies: Array<GraphNode<A> | undefined>) {
for (const node of this.nodes) {
node.dependOn(...dependencies.filter(isDefined));
}
}

/**
* Return the topographically first node in the collection
*/
public first() {
const nodes = new Set(this.nodes);
const sorted = this.nodes[0].rootGraph.sortedLeaves();
for (const tranche of sorted) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[tangent][non-blocking] An unexpected word: tranche: portion.

for (const node of tranche) {
if (nodes.has(node)) {
return node;
}
}
}

throw new Error(`Could not calculate first node between ${this}`);
}

/**
* Returns the graph node that's shared between these nodes
*/
Expand Down Expand Up @@ -351,6 +432,10 @@ export class GraphNodeCollection<A> {

return paths[0][0];
}

public toString() {
return this.nodes.map(n => `${n}`).join(', ');
}
}

/**
Expand Down
67 changes: 54 additions & 13 deletions packages/@aws-cdk/pipelines/lib/helpers-internal/pipeline-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ export class PipelineGraph {
private readonly assetNodesByType = new Map<AssetType, AGraphNode>();
private readonly synthNode?: AGraphNode;
private readonly selfMutateNode?: AGraphNode;
private readonly stackOutputDependencies = new DependencyBuilders<StackDeployment, any>();
private readonly stackOutputDependencies = new DependencyBuilders<StackDeployment>();
/** Mapping steps to depbuilders, satisfied by the step itself */
private readonly nodeDependencies = new DependencyBuilders<Step>();
private readonly publishTemplate: boolean;
private readonly prepareStep: boolean;
private readonly singlePublisher: boolean;
Expand Down Expand Up @@ -102,16 +104,15 @@ export class PipelineGraph {
waves[i].dependOn(waves[i - 1]);
}

// Add additional dependencies between steps that depend on stack outputs and the stacks
// that produce them.
this.addMissingDependencyNodes();
}

public isSynthNode(node: AGraphNode) {
return this.synthNode === node;
}

private addBuildStep(step: Step) {
return this.addAndRecurse(step, this.topLevelGraph('Build'));
return this.addStepNode(step, this.topLevelGraph('Build'));
}

private addWave(wave: Wave): AGraph {
Expand Down Expand Up @@ -174,7 +175,7 @@ export class PipelineGraph {

const cloudAssembly = this.cloudAssemblyFileSet;

firstDeployNode.dependOn(this.addAndRecurse(cloudAssembly.producer, retGraph));
firstDeployNode.dependOn(this.addStepNode(cloudAssembly.producer, retGraph));

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

// Add stack output synchronization point
if (this.queries.stackOutputsReferenced(stack).length > 0) {
this.stackOutputDependencies.get(stack).dependOn(deployNode);
this.stackOutputDependencies.for(stack).dependOn(deployNode);
}
}

Expand All @@ -220,7 +221,7 @@ export class PipelineGraph {

private addChangeSetNode(changeSet: Step[], prepareNode: AGraphNode, deployNode: AGraphNode, graph: AGraph) {
for (const c of changeSet) {
const changeSetNode = this.addAndRecurse(c, graph);
const changeSetNode = this.addStepNode(c, graph);
changeSetNode?.dependOn(prepareNode);
deployNode.dependOn(changeSetNode);
}
Expand All @@ -230,12 +231,12 @@ export class PipelineGraph {
const currentNodes = new GraphNodeCollection(parent.nodes);
const preNodes = new GraphNodeCollection(new Array<AGraphNode>());
for (const p of pre) {
const preNode = this.addAndRecurse(p, parent);
const preNode = this.addStepNode(p, parent);
currentNodes.dependOn(preNode);
preNodes.nodes.push(preNode!);
}
for (const p of post) {
const postNode = this.addAndRecurse(p, parent);
const postNode = this.addStepNode(p, parent);
postNode?.dependOn(...currentNodes.nodes);
}
return preNodes;
Expand All @@ -250,7 +251,12 @@ export class PipelineGraph {
return ret as AGraph;
}

private addAndRecurse(step: Step, parent: AGraph) {
/**
* Add a Node to a Graph for a given Step
*
* Adds all dependencies for that Node to the same Step as well.
*/
private addStepNode(step: Step, parent: AGraph) {
if (step === PipelineGraph.NO_STEP) { return undefined; }

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

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

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

return node;
}

/**
* Add dependencies that aren't in the pipeline yet
*
* Build steps reference as many sources (or other builds) as they want, which will be added
* automatically. Do that here. We couldn't do it earlier, because if there were dependencies
* between steps we didn't want to reparent those unnecessarily.
*/
private addMissingDependencyNodes() {
// May need to do this more than once to recursively add all missing producers
let attempts = 20;
while (attempts-- > 0) {
const unsatisfied = this.nodeDependencies.unsatisfiedBuilders().filter(([s]) => s !== PipelineGraph.NO_STEP);
if (unsatisfied.length === 0) { return; }

for (const [step, builder] of unsatisfied) {
// Add a new node for this step to the parent of the "leftmost" consumer.
const leftMostConsumer = new GraphNodeCollection(builder.consumers).first();
const parent = leftMostConsumer.parentGraph;
if (!parent) {
throw new Error(`Consumer doesn't have a parent graph: ${leftMostConsumer}`);
}
this.addStepNode(step, parent);
}
}

const unsatisfied = this.nodeDependencies.unsatisfiedBuilders();
throw new Error([
'Recursion depth too large while adding dependency nodes:',
unsatisfied.map(([step, builder]) => `${builder.consumersAsString()} awaiting ${step}.`),
].join(' '));
}

private publishAsset(stackAsset: StackAsset): AGraphNode {
const assetsGraph = this.topLevelGraph('Assets');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,8 @@ describe('with app with output', () => {
});

// WHEN
const graph = new PipelineGraph(blueprint).graph;
expect(() => {
const graph = new PipelineGraph(blueprint).graph;
assertGraph(nodeAt(graph, 'Alpha')).sortedLeaves();
}).toThrow(/Dependency cycle/);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Stack } from '@aws-cdk/core';
import { Construct } from 'constructs';
import * as cdkp from '../../lib';
import { CodePipeline } from '../../lib';
import { PIPELINE_ENV, TestApp, ModernTestGitHubNpmPipeline, FileAssetApp } from '../testhelpers';
import { PIPELINE_ENV, TestApp, ModernTestGitHubNpmPipeline, FileAssetApp, TwoStackApp } from '../testhelpers';

let app: TestApp;

Expand Down Expand Up @@ -298,6 +298,41 @@ describe('deployment of stack', () => {
});
});

test('action name is calculated properly if it has cross-stack dependencies', () => {
// GIVEN
const pipelineStack = new cdk.Stack(app, 'PipelineStack', { env: PIPELINE_ENV });
const pipeline = new ModernTestGitHubNpmPipeline(pipelineStack, 'Cdk', {
crossAccountKeys: true,
});

// WHEN
const s1step = new cdkp.ManualApprovalStep('S1');
const s2step = new cdkp.ManualApprovalStep('S2');
s1step.addStepDependency(s2step);

// The issue we were diagnosing only manifests if the stacks don't have
// a dependency on each other
const stage = new TwoStackApp(app, 'TheApp', { withDependency: false });
pipeline.addStage(stage, {
stackSteps: [
{ stack: stage.stack1, post: [s1step] },
{ stack: stage.stack2, post: [s2step] },
],
});

// THEN
const template = Template.fromStack(pipelineStack);
template.hasResourceProperties('AWS::CodePipeline::Pipeline', {
Stages: Match.arrayWith([{
Name: 'TheApp',
Actions: Match.arrayWith([
Match.objectLike({ Name: 'Stack2.S2', RunOrder: 3 }),
Match.objectLike({ Name: 'Stack1.S1', RunOrder: 4 }),
]),
}]),
});
});

interface ReuseCodePipelineStackProps extends cdk.StackProps {
reuseCrossRegionSupportStacks?: boolean;
}
Expand Down
Loading