Skip to content

Commit c134c3f

Browse files
authored
chore(pipelines): write a GraphViz file with the pipeline structure (#24030)
This is a re-roll of #23908 which had to be reverted in #24006 because there are some cases where change set approval steps that are shared between two stacks would cause cyclic dependencies between those stacks that caused them to be mutually unsortable. Solve this by adding a mode to the toposort routine that will proceed even if there are cyclic dependencies (which is used purely for rendering). ---- Add a `pipeline.dot` file to the cloud assembly containing the graph structure of the pipeline. This change is a `chore`, not a `feat`, as I want this to be a debugging aid, but I don't want to service feature requests on it. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 989454f commit c134c3f

File tree

4 files changed

+139
-12
lines changed

4 files changed

+139
-12
lines changed

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import * as fs from 'fs';
12
import * as path from 'path';
23
import * as cb from '@aws-cdk/aws-codebuild';
34
import * as cp from '@aws-cdk/aws-codepipeline';
45
import * as cpa from '@aws-cdk/aws-codepipeline-actions';
56
import * as ec2 from '@aws-cdk/aws-ec2';
67
import * as iam from '@aws-cdk/aws-iam';
7-
import { Aws, CfnCapabilities, Duration, PhysicalName, Stack } from '@aws-cdk/core';
8+
import { Aws, CfnCapabilities, Duration, PhysicalName, Stack, Names } from '@aws-cdk/core';
89
import * as cxapi from '@aws-cdk/cx-api';
910
import { Construct } from 'constructs';
1011
import { AssetType, FileSet, IFileSetProducer, ManualApprovalStep, ShellStep, StackAsset, StackDeployment, Step } from '../blueprint';
@@ -423,6 +424,10 @@ export class CodePipeline extends PipelineBase {
423424
this._cloudAssemblyFileSet = graphFromBp.cloudAssemblyFileSet;
424425

425426
this.pipelineStagesAndActionsFromGraph(graphFromBp);
427+
428+
// Write a dotfile for the pipeline layout
429+
const dotFile = `${Names.uniqueId(this)}.dot`;
430+
fs.writeFileSync(path.join(this.myCxAsmRoot, dotFile), graphFromBp.graph.renderDot().replace(/input\.dot/, dotFile), { encoding: 'utf-8' });
426431
}
427432

428433
private get myCxAsmRoot(): string {

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

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ export class Graph<A> extends GraphNode<A> {
251251
/**
252252
* Return topologically sorted tranches of nodes at this graph level
253253
*/
254-
public sortedChildren(): GraphNode<A>[][] {
254+
public sortedChildren(fail=true): GraphNode<A>[][] {
255255
// Project dependencies to current children
256256
const nodes = this.nodes;
257257
const projectedDependencies = projectDependencies(this.deepDependencies(), (node) => {
@@ -261,7 +261,7 @@ export class Graph<A> extends GraphNode<A> {
261261
return nodes.has(node) ? [node] : [];
262262
});
263263

264-
return topoSort(nodes, projectedDependencies);
264+
return topoSort(nodes, projectedDependencies, fail);
265265
}
266266

267267
/**
@@ -291,13 +291,21 @@ export class Graph<A> extends GraphNode<A> {
291291
return topoSort(new Set(projectedDependencies.keys()), projectedDependencies);
292292
}
293293

294-
public consoleLog(indent: number = 0) {
295-
process.stdout.write(' '.repeat(indent) + this + depString(this) + '\n');
296-
for (const node of this.nodes) {
297-
if (node instanceof Graph) {
298-
node.consoleLog(indent + 2);
299-
} else {
300-
process.stdout.write(' '.repeat(indent + 2) + node + depString(node) + '\n');
294+
public render() {
295+
const lines = new Array<string>();
296+
recurse(this, '', true);
297+
return lines.join('\n');
298+
299+
function recurse(x: GraphNode<A>, indent: string, last: boolean) {
300+
const bullet = last ? '└─' : '├─';
301+
const follow = last ? ' ' : '│ ';
302+
lines.push(`${indent} ${bullet} ${x}${depString(x)}`);
303+
if (x instanceof Graph) {
304+
let i = 0;
305+
const sortedNodes = Array.prototype.concat.call([], ...x.sortedChildren(false));
306+
for (const child of sortedNodes) {
307+
recurse(child, `${indent} ${follow} `, i++ == x.nodes.size - 1);
308+
}
301309
}
302310
}
303311

@@ -309,6 +317,79 @@ export class Graph<A> extends GraphNode<A> {
309317
}
310318
}
311319

320+
public renderDot() {
321+
const lines = new Array<string>();
322+
323+
lines.push('digraph G {');
324+
lines.push(' # Arrows represent an "unlocks" relationship (opposite of dependency). So chosen');
325+
lines.push(' # because the layout looks more natural that way.');
326+
lines.push(' # To represent subgraph dependencies, subgraphs are represented by BEGIN/END nodes.');
327+
lines.push(' # To render: `dot -Tsvg input.dot > graph.svg`, open in a browser.');
328+
lines.push(' node [shape="box"];');
329+
for (const child of this.nodes) {
330+
recurse(child);
331+
}
332+
lines.push('}');
333+
334+
return lines.join('\n');
335+
336+
function recurse(node: GraphNode<A>) {
337+
let dependencySource;
338+
339+
if (node instanceof Graph) {
340+
lines.push(`${graphBegin(node)} [shape="cds", style="filled", fillcolor="#b7deff"];`);
341+
lines.push(`${graphEnd(node)} [shape="cds", style="filled", fillcolor="#b7deff"];`);
342+
dependencySource = graphBegin(node);
343+
} else {
344+
dependencySource = nodeLabel(node);
345+
lines.push(`${nodeLabel(node)};`);
346+
}
347+
348+
for (const dep of node.dependencies) {
349+
const dst = dep instanceof Graph ? graphEnd(dep) : nodeLabel(dep);
350+
lines.push(`${dst} -> ${dependencySource};`);
351+
}
352+
353+
if (node instanceof Graph && node.nodes.size > 0) {
354+
for (const child of node.nodes) {
355+
recurse(child);
356+
}
357+
358+
// Add dependency arrows between the "subgraph begin" and the first rank of
359+
// the children, and the last rank of the children and "subgraph end" nodes.
360+
const sortedChildren = node.sortedChildren(false);
361+
for (const first of sortedChildren[0]) {
362+
const src = first instanceof Graph ? graphBegin(first) : nodeLabel(first);
363+
lines.push(`${graphBegin(node)} -> ${src};`);
364+
}
365+
for (const last of sortedChildren[sortedChildren.length - 1]) {
366+
const dst = last instanceof Graph ? graphEnd(last) : nodeLabel(last);
367+
lines.push(`${dst} -> ${graphEnd(node)};`);
368+
}
369+
}
370+
}
371+
372+
function id(node: GraphNode<A>) {
373+
return node.rootPath().slice(1).map(n => n.id).join('.');
374+
}
375+
376+
function nodeLabel(node: GraphNode<A>) {
377+
return `"${id(node)}"`;
378+
}
379+
380+
function graphBegin(node: Graph<A>) {
381+
return `"BEGIN ${id(node)}"`;
382+
}
383+
384+
function graphEnd(node: Graph<A>) {
385+
return `"END ${id(node)}"`;
386+
}
387+
}
388+
389+
public consoleLog(_indent: number = 0) {
390+
process.stdout.write(this.render() + '\n');
391+
}
392+
312393
/**
313394
* Return the union of all dependencies of the descendants of this graph
314395
*/

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export function printDependencyMap<A>(dependencies: Map<GraphNode<A>, Set<GraphN
99
console.log(lines.join('\n'));
1010
}
1111

12-
export function topoSort<A>(nodes: Set<GraphNode<A>>, dependencies: Map<GraphNode<A>, Set<GraphNode<A>>>): GraphNode<A>[][] {
12+
export function topoSort<A>(nodes: Set<GraphNode<A>>, dependencies: Map<GraphNode<A>, Set<GraphNode<A>>>, fail=true): GraphNode<A>[][] {
1313
const remaining = new Set<GraphNode<A>>(nodes);
1414

1515
const ret: GraphNode<A>[][] = [];
@@ -26,7 +26,14 @@ export function topoSort<A>(nodes: Set<GraphNode<A>>, dependencies: Map<GraphNod
2626
// If we didn't make any progress, we got stuck
2727
if (selectable.length === 0) {
2828
const cycle = findCycle(dependencies);
29-
throw new Error(`Dependency cycle in graph: ${cycle.map(n => n.id).join(' => ')}`);
29+
30+
if (fail) {
31+
throw new Error(`Dependency cycle in graph: ${cycle.map(n => n.id).join(' => ')}`);
32+
}
33+
34+
// If we're trying not to fail, pick one at random from the cycle and treat it
35+
// as selectable, then continue.
36+
selectable.push(cycle[0]);
3037
}
3138

3239
ret.push(selectable);

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,40 @@ test('action name is calculated properly if it has cross-stack dependencies', ()
392392
});
393393
});
394394

395+
test('synths with change set approvers', () => {
396+
// GIVEN
397+
const pipelineStack = new cdk.Stack(app, 'PipelineStack', { env: PIPELINE_ENV });
398+
const pipeline = new ModernTestGitHubNpmPipeline(pipelineStack, 'Cdk');
399+
400+
// WHEN
401+
const csApproval = new cdkp.ManualApprovalStep('ChangeSetApproval');
402+
403+
// The issue we were diagnosing only manifests if the stacks don't have
404+
// a dependency on each other
405+
const stage = new TwoStackApp(app, 'TheApp', { withDependency: false });
406+
pipeline.addStage(stage, {
407+
stackSteps: [
408+
{ stack: stage.stack1, changeSet: [csApproval] },
409+
{ stack: stage.stack2, changeSet: [csApproval] },
410+
],
411+
});
412+
413+
// THEN
414+
const template = Template.fromStack(pipelineStack);
415+
template.hasResourceProperties('AWS::CodePipeline::Pipeline', {
416+
Stages: Match.arrayWith([{
417+
Name: 'TheApp',
418+
Actions: Match.arrayWith([
419+
Match.objectLike({ Name: 'Stack1.Prepare', RunOrder: 1 }),
420+
Match.objectLike({ Name: 'Stack2.Prepare', RunOrder: 1 }),
421+
Match.objectLike({ Name: 'Stack1.ChangeSetApproval', RunOrder: 2 }),
422+
Match.objectLike({ Name: 'Stack1.Deploy', RunOrder: 3 }),
423+
Match.objectLike({ Name: 'Stack2.Deploy', RunOrder: 3 }),
424+
]),
425+
}]),
426+
});
427+
});
428+
395429
interface ReuseCodePipelineStackProps extends cdk.StackProps {
396430
reuseCrossRegionSupportStacks?: boolean;
397431
}

0 commit comments

Comments
 (0)