Skip to content

Commit

Permalink
fix(pipelines): undeployable due to dependency cycle (aws#18686)
Browse files Browse the repository at this point in the history
A dependency cycle was inadvertently introduced to CDK Pipelines in aws#18492.

Fix that dependency cycle, and also one in Cognito IdentityPools.

Add facilities to the `assertions` library to automatically detect this in the future,
to stop errors like this from slipping in.

We could make it a separate assertion method
(`Template.fromStack().assertNoCycles()`), but the only thing that will
do is give you an opportunity to forget to put the test in.

Instead, we just check it by default for every generated template.

Fixes aws#18673.


----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
rix0rrr authored and TikiTDO committed Feb 21, 2022
1 parent f035297 commit ae0ccc0
Show file tree
Hide file tree
Showing 17 changed files with 254 additions and 113 deletions.
4 changes: 2 additions & 2 deletions packages/@aws-cdk/assertions/lib/private/conditions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { filterLogicalId, formatFailure, matchSection } from './section';
import { Template } from './template';

export function findConditions(template: Template, logicalId: string, props: any = {}): { [key: string]: { [key: string]: any } } {
const section: { [key: string] : {} } = template.Conditions;
const section: { [key: string] : {} } = template.Conditions ?? {};
const result = matchSection(filterLogicalId(section, logicalId), props);

if (!result.match) {
Expand All @@ -13,7 +13,7 @@ export function findConditions(template: Template, logicalId: string, props: any
}

export function hasCondition(template: Template, logicalId: string, props: any): string | void {
const section: { [key: string] : {} } = template.Conditions;
const section: { [key: string] : {} } = template.Conditions ?? {};
const result = matchSection(filterLogicalId(section, logicalId), props);
if (result.match) {
return;
Expand Down
175 changes: 175 additions & 0 deletions packages/@aws-cdk/assertions/lib/private/cyclic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { Resource, Template } from './template';

/**
* Check a template for cyclic dependencies
*
* This will make sure that we don't happily validate templates
* in unit tests that wouldn't deploy to CloudFormation anyway.
*/
export function checkTemplateForCyclicDependencies(template: Template): void {
const logicalIds = new Set(Object.keys(template.Resources ?? {}));

const dependencies = new Map<string, Set<string>>();
for (const [logicalId, resource] of Object.entries(template.Resources ?? {})) {
dependencies.set(logicalId, intersect(findResourceDependencies(resource), logicalIds));
}

// We will now progressively remove entries from the map of 'dependencies' that have
// 0 elements in them. If we can't do that anymore and the map isn't empty, we
// have a cyclic dependency.
while (dependencies.size > 0) {
const free = Array.from(dependencies.entries()).filter(([_, deps]) => deps.size === 0);
if (free.length === 0) {
// Oops!
const cycle = findCycle(dependencies);

const cycleResources: any = {};
for (const logicalId of cycle) {
cycleResources[logicalId] = template.Resources?.[logicalId];
}

throw new Error(`Template is undeployable, these resources have a dependency cycle: ${cycle.join(' -> ')}:\n\n${JSON.stringify(cycleResources, undefined, 2)}`);
}

for (const [logicalId, _] of free) {
for (const deps of dependencies.values()) {
deps.delete(logicalId);
}
dependencies.delete(logicalId);
}
}
}

function findResourceDependencies(res: Resource): Set<string> {
return new Set([
...toArray(res.DependsOn ?? []),
...findExpressionDependencies(res.Properties),
]);
}

function toArray<A>(x: A | A[]): A[] {
return Array.isArray(x) ? x : [x];
}

function findExpressionDependencies(obj: any): Set<string> {
const ret = new Set<string>();
recurse(obj);
return ret;

function recurse(x: any): void {
if (!x) { return; }
if (Array.isArray(x)) {
x.forEach(recurse);
}
if (typeof x === 'object') {
const keys = Object.keys(x);
if (keys.length === 1 && keys[0] === 'Ref') {
ret.add(x[keys[0]]);
} else if (keys.length === 1 && keys[0] === 'Fn::GetAtt') {
ret.add(x[keys[0]][0]);
} else if (keys.length === 1 && keys[0] === 'Fn::Sub') {
const argument = x[keys[0]];
const pattern = Array.isArray(argument) ? argument[0] : argument;
for (const logId of logicalIdsInSubString(pattern)) {
ret.add(logId);
}
const contextDict = Array.isArray(argument) ? argument[1] : undefined;
if (contextDict) {
Object.values(contextDict).forEach(recurse);
}
} else {
Object.values(x).forEach(recurse);
}
}
}
}

/**
* Return the logical IDs found in a {Fn::Sub} format string
*/
function logicalIdsInSubString(x: string): string[] {
return analyzeSubPattern(x).flatMap((fragment) => {
switch (fragment.type) {
case 'getatt':
case 'ref':
return [fragment.logicalId];
case 'literal':
return [];
}
});
}


function analyzeSubPattern(pattern: string): SubFragment[] {
const ret: SubFragment[] = [];
let start = 0;

let ph0 = pattern.indexOf('${', start);
while (ph0 > -1) {
if (pattern[ph0 + 2] === '!') {
// "${!" means "don't actually substitute"
start = ph0 + 3;
ph0 = pattern.indexOf('${', start);
continue;
}

const ph1 = pattern.indexOf('}', ph0 + 2);
if (ph1 === -1) {
break;
}
const placeholder = pattern.substring(ph0 + 2, ph1);

if (ph0 > start) {
ret.push({ type: 'literal', content: pattern.substring(start, ph0) });
}
if (placeholder.includes('.')) {
const [logicalId, attr] = placeholder.split('.');
ret.push({ type: 'getatt', logicalId: logicalId!, attr: attr! });
} else {
ret.push({ type: 'ref', logicalId: placeholder });
}

start = ph1 + 1;
ph0 = pattern.indexOf('${', start);
}

if (start < pattern.length - 1) {
ret.push({ type: 'literal', content: pattern.substr(start) });
}

return ret;
}

type SubFragment =
| { readonly type: 'literal'; readonly content: string }
| { readonly type: 'ref'; readonly logicalId: string }
| { readonly type: 'getatt'; readonly logicalId: string; readonly attr: string };


function intersect<A>(xs: Set<A>, ys: Set<A>): Set<A> {
return new Set<A>(Array.from(xs).filter(x => ys.has(x)));
}

/**
* Find cycles in a graph
*
* Not the fastest, but effective and should be rare
*/
function findCycle(deps: ReadonlyMap<string, ReadonlySet<string>>): string[] {
for (const node of deps.keys()) {
const cycle = recurse(node, [node]);
if (cycle) { return cycle; }
}
throw new Error('No cycle found. Assertion failure!');

function recurse(node: string, path: string[]): string[] | undefined {
for (const dep of deps.get(node) ?? []) {
if (dep === path[0]) { return [...path, dep]; }

const cycle = recurse(dep, [...path, dep]);
if (cycle) { return cycle; }
}

return undefined;
}
}
4 changes: 2 additions & 2 deletions packages/@aws-cdk/assertions/lib/private/mappings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { filterLogicalId, formatFailure, matchSection } from './section';
import { Template } from './template';

export function findMappings(template: Template, logicalId: string, props: any = {}): { [key: string]: { [key: string]: any } } {
const section: { [key: string] : {} } = template.Mappings;
const section: { [key: string] : {} } = template.Mappings ?? {};
const result = matchSection(filterLogicalId(section, logicalId), props);

if (!result.match) {
Expand All @@ -13,7 +13,7 @@ export function findMappings(template: Template, logicalId: string, props: any =
}

export function hasMapping(template: Template, logicalId: string, props: any): string | void {
const section: { [key: string]: {} } = template.Mappings;
const section: { [key: string]: {} } = template.Mappings ?? {};
const result = matchSection(filterLogicalId(section, logicalId), props);

if (result.match) {
Expand Down
4 changes: 2 additions & 2 deletions packages/@aws-cdk/assertions/lib/private/outputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { filterLogicalId, formatFailure, matchSection } from './section';
import { Template } from './template';

export function findOutputs(template: Template, logicalId: string, props: any = {}): { [key: string]: { [key: string]: any } } {
const section = template.Outputs;
const section = template.Outputs ?? {};
const result = matchSection(filterLogicalId(section, logicalId), props);

if (!result.match) {
Expand All @@ -13,7 +13,7 @@ export function findOutputs(template: Template, logicalId: string, props: any =
}

export function hasOutput(template: Template, logicalId: string, props: any): string | void {
const section: { [key: string]: {} } = template.Outputs;
const section: { [key: string]: {} } = template.Outputs ?? {};
const result = matchSection(filterLogicalId(section, logicalId), props);
if (result.match) {
return;
Expand Down
4 changes: 2 additions & 2 deletions packages/@aws-cdk/assertions/lib/private/parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { filterLogicalId, formatFailure, matchSection } from './section';
import { Template } from './template';

export function findParameters(template: Template, logicalId: string, props: any = {}): { [key: string]: { [key: string]: any } } {
const section: { [key: string] : {} } = template.Parameters;
const section: { [key: string] : {} } = template.Parameters ?? {};
const result = matchSection(filterLogicalId(section, logicalId), props);

if (!result.match) {
Expand All @@ -13,7 +13,7 @@ export function findParameters(template: Template, logicalId: string, props: any
}

export function hasParameter(template: Template, logicalId: string, props: any): string | void {
const section: { [key: string] : {} } = template.Parameters;
const section: { [key: string] : {} } = template.Parameters ?? {};
const result = matchSection(filterLogicalId(section, logicalId), props);
if (result.match) {
return;
Expand Down
8 changes: 4 additions & 4 deletions packages/@aws-cdk/assertions/lib/private/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { formatFailure, matchSection } from './section';
import { Resource, Template } from './template';

export function findResources(template: Template, type: string, props: any = {}): { [key: string]: { [key: string]: any } } {
const section = template.Resources;
const section = template.Resources ?? {};
const result = matchSection(filterType(section, type), props);

if (!result.match) {
Expand All @@ -15,7 +15,7 @@ export function findResources(template: Template, type: string, props: any = {})
}

export function hasResource(template: Template, type: string, props: any): string | void {
const section = template.Resources;
const section = template.Resources ?? {};
const result = matchSection(filterType(section, type), props);
if (result.match) {
return;
Expand Down Expand Up @@ -46,14 +46,14 @@ export function hasResourceProperties(template: Template, type: string, props: a
}

export function countResources(template: Template, type: string): number {
const section = template.Resources;
const section = template.Resources ?? {};
const types = filterType(section, type);

return Object.entries(types).length;
}

function addEmptyProperties(template: Template): Template {
let section = template.Resources;
let section = template.Resources ?? {};

Object.keys(section).map((key) => {
if (!section[key].hasOwnProperty('Properties')) {
Expand Down
14 changes: 9 additions & 5 deletions packages/@aws-cdk/assertions/lib/private/template.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
// Partial types for CloudFormation Template

export type Template = {
Resources: { [logicalId: string]: Resource },
Outputs: { [logicalId: string]: Output },
Mappings: { [logicalId: string]: Mapping },
Parameters: { [logicalId: string]: Parameter },
Conditions: { [logicalId: string]: Condition },
// In actuality this is not optional, but we sometimes don't generate it so we
// need to account for that.
Resources?: { [logicalId: string]: Resource },
Outputs?: { [logicalId: string]: Output },
Mappings?: { [logicalId: string]: Mapping },
Parameters?: { [logicalId: string]: Parameter },
Conditions?: { [logicalId: string]: Condition },
}

export type Resource = {
Type: string;
DependsOn?: string | string[];
Properties?: { [key: string]: any };
[key: string]: any;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/@aws-cdk/assertions/lib/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as fs from 'fs-extra';
import { Match } from './match';
import { Matcher } from './matcher';
import { findConditions, hasCondition } from './private/conditions';
import { checkTemplateForCyclicDependencies } from './private/cyclic';
import { findMappings, hasMapping } from './private/mappings';
import { findOutputs, hasOutput } from './private/outputs';
import { findParameters, hasParameter } from './private/parameters';
Expand Down Expand Up @@ -47,6 +48,7 @@ export class Template {

private constructor(template: { [key: string]: any }) {
this.template = template as TemplateType;
checkTemplateForCyclicDependencies(this.template);
}

/**
Expand Down
31 changes: 25 additions & 6 deletions packages/@aws-cdk/assertions/test/template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import { Capture, Match, Template } from '../lib';
describe('Template', () => {
test('fromString', () => {
const template = Template.fromString(`{
"Resources": {
"Foo": {
"Resources": {
"Foo": {
"Type": "Baz::Qux",
"Properties": { "Fred": "Waldo" }
}
}
}
}`);

Expand Down Expand Up @@ -79,11 +79,11 @@ describe('Template', () => {
describe('fromString', () => {
test('default', () => {
const assertions = Template.fromString(`{
"Resources": {
"Foo": {
"Resources": {
"Foo": {
"Type": "Baz::Qux",
"Properties": { "Fred": "Waldo" }
}
}
}
}`);
assertions.resourceCountIs('Baz::Qux', 1);
Expand Down Expand Up @@ -1084,6 +1084,25 @@ describe('Template', () => {
expect(Object.keys(result).length).toEqual(0);
});
});

test('throws when given a template with cyclic dependencies', () => {
expect(() => {
Template.fromJSON({
Resources: {
Res1: {
Type: 'Foo',
Properties: {
Thing: { Ref: 'Res2' },
},
},
Res2: {
Type: 'Foo',
DependsOn: ['Res1'],
},
},
});
}).toThrow(/dependency cycle/);
});
});

function expectToThrow(fn: () => void, msgs: (RegExp | string)[], done: jest.DoneCallback): void {
Expand Down
Loading

0 comments on commit ae0ccc0

Please sign in to comment.