Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(pipelines): write a GraphViz file with the pipeline structure #24030

Merged
merged 2 commits into from
Feb 6, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 6 additions & 1 deletion packages/@aws-cdk/pipelines/lib/codepipeline/codepipeline.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import * as fs from 'fs';
import * as path from 'path';
import * as cb from '@aws-cdk/aws-codebuild';
import * as cp from '@aws-cdk/aws-codepipeline';
import * as cpa from '@aws-cdk/aws-codepipeline-actions';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as iam from '@aws-cdk/aws-iam';
import { Aws, CfnCapabilities, Duration, PhysicalName, Stack } from '@aws-cdk/core';
import { Aws, CfnCapabilities, Duration, PhysicalName, Stack, Names } from '@aws-cdk/core';
import * as cxapi from '@aws-cdk/cx-api';
import { Construct } from 'constructs';
import { AssetType, FileSet, IFileSetProducer, ManualApprovalStep, ShellStep, StackAsset, StackDeployment, Step } from '../blueprint';
Expand Down Expand Up @@ -423,6 +424,10 @@ export class CodePipeline extends PipelineBase {
this._cloudAssemblyFileSet = graphFromBp.cloudAssemblyFileSet;

this.pipelineStagesAndActionsFromGraph(graphFromBp);

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

private get myCxAsmRoot(): string {
Expand Down
99 changes: 90 additions & 9 deletions packages/@aws-cdk/pipelines/lib/helpers-internal/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ export class Graph<A> extends GraphNode<A> {
/**
* Return topologically sorted tranches of nodes at this graph level
*/
public sortedChildren(): GraphNode<A>[][] {
public sortedChildren(fail=true): GraphNode<A>[][] {
// Project dependencies to current children
const nodes = this.nodes;
const projectedDependencies = projectDependencies(this.deepDependencies(), (node) => {
Expand All @@ -261,7 +261,7 @@ export class Graph<A> extends GraphNode<A> {
return nodes.has(node) ? [node] : [];
});

return topoSort(nodes, projectedDependencies);
return topoSort(nodes, projectedDependencies, fail);
}

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

public consoleLog(indent: number = 0) {
process.stdout.write(' '.repeat(indent) + this + depString(this) + '\n');
for (const node of this.nodes) {
if (node instanceof Graph) {
node.consoleLog(indent + 2);
} else {
process.stdout.write(' '.repeat(indent + 2) + node + depString(node) + '\n');
public render() {
const lines = new Array<string>();
recurse(this, '', true);
return lines.join('\n');

function recurse(x: GraphNode<A>, indent: string, last: boolean) {
const bullet = last ? '└─' : '├─';
const follow = last ? ' ' : '│ ';
lines.push(`${indent} ${bullet} ${x}${depString(x)}`);
if (x instanceof Graph) {
let i = 0;
const sortedNodes = Array.prototype.concat.call([], ...x.sortedChildren(false));
for (const child of sortedNodes) {
recurse(child, `${indent} ${follow} `, i++ == x.nodes.size - 1);
}
}
}

Expand All @@ -309,6 +317,79 @@ export class Graph<A> extends GraphNode<A> {
}
}

public renderDot() {
const lines = new Array<string>();

lines.push('digraph G {');
lines.push(' # Arrows represent an "unlocks" relationship (opposite of dependency). So chosen');
lines.push(' # because the layout looks more natural that way.');
lines.push(' # To represent subgraph dependencies, subgraphs are represented by BEGIN/END nodes.');
lines.push(' # To render: `dot -Tsvg input.dot > graph.svg`, open in a browser.');
lines.push(' node [shape="box"];');
for (const child of this.nodes) {
recurse(child);
}
lines.push('}');

return lines.join('\n');

function recurse(node: GraphNode<A>) {
let dependencySource;

if (node instanceof Graph) {
lines.push(`${graphBegin(node)} [shape="cds", style="filled", fillcolor="#b7deff"];`);
lines.push(`${graphEnd(node)} [shape="cds", style="filled", fillcolor="#b7deff"];`);
dependencySource = graphBegin(node);
} else {
dependencySource = nodeLabel(node);
lines.push(`${nodeLabel(node)};`);
}

for (const dep of node.dependencies) {
const dst = dep instanceof Graph ? graphEnd(dep) : nodeLabel(dep);
lines.push(`${dst} -> ${dependencySource};`);
}

if (node instanceof Graph && node.nodes.size > 0) {
for (const child of node.nodes) {
recurse(child);
}

// Add dependency arrows between the "subgraph begin" and the first rank of
// the children, and the last rank of the children and "subgraph end" nodes.
const sortedChildren = node.sortedChildren(false);
for (const first of sortedChildren[0]) {
const src = first instanceof Graph ? graphBegin(first) : nodeLabel(first);
lines.push(`${graphBegin(node)} -> ${src};`);
}
for (const last of sortedChildren[sortedChildren.length - 1]) {
const dst = last instanceof Graph ? graphEnd(last) : nodeLabel(last);
lines.push(`${dst} -> ${graphEnd(node)};`);
}
}
}

function id(node: GraphNode<A>) {
return node.rootPath().slice(1).map(n => n.id).join('.');
}

function nodeLabel(node: GraphNode<A>) {
return `"${id(node)}"`;
}

function graphBegin(node: Graph<A>) {
return `"BEGIN ${id(node)}"`;
}

function graphEnd(node: Graph<A>) {
return `"END ${id(node)}"`;
}
}

public consoleLog(_indent: number = 0) {
process.stdout.write(this.render() + '\n');
}

/**
* Return the union of all dependencies of the descendants of this graph
*/
Expand Down
11 changes: 9 additions & 2 deletions packages/@aws-cdk/pipelines/lib/helpers-internal/toposort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export function printDependencyMap<A>(dependencies: Map<GraphNode<A>, Set<GraphN
console.log(lines.join('\n'));
}

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

const ret: GraphNode<A>[][] = [];
Expand All @@ -26,7 +26,14 @@ export function topoSort<A>(nodes: Set<GraphNode<A>>, dependencies: Map<GraphNod
// If we didn't make any progress, we got stuck
if (selectable.length === 0) {
const cycle = findCycle(dependencies);
throw new Error(`Dependency cycle in graph: ${cycle.map(n => n.id).join(' => ')}`);

if (fail) {
throw new Error(`Dependency cycle in graph: ${cycle.map(n => n.id).join(' => ')}`);
}

// If we're trying not to fail, pick one at random from the cycle and treat it
// as selectable, then continue.
selectable.push(cycle[0]);
}

ret.push(selectable);
Expand Down
34 changes: 34 additions & 0 deletions packages/@aws-cdk/pipelines/test/codepipeline/codepipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,40 @@ test('action name is calculated properly if it has cross-stack dependencies', ()
});
});

test('synths with change set approvers', () => {
// GIVEN
const pipelineStack = new cdk.Stack(app, 'PipelineStack', { env: PIPELINE_ENV });
const pipeline = new ModernTestGitHubNpmPipeline(pipelineStack, 'Cdk');

// WHEN
const csApproval = new cdkp.ManualApprovalStep('ChangeSetApproval');

// 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, changeSet: [csApproval] },
{ stack: stage.stack2, changeSet: [csApproval] },
],
});

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

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