diff --git a/packages/@aws-cdk/assertions/README.md b/packages/@aws-cdk/assertions/README.md index d651ab72c1bc3..706d467eb3e59 100644 --- a/packages/@aws-cdk/assertions/README.md +++ b/packages/@aws-cdk/assertions/README.md @@ -107,16 +107,32 @@ By default, the `hasResource()` and `hasResourceProperties()` APIs perform deep partial object matching. This behavior can be configured using matchers. See subsequent section on [special matchers](#special-matchers). -## Other Sections +## Output and Mapping sections -Similar to the `hasResource()` and `findResources()`, we have equivalent methods -to check and find other sections of the CloudFormation resources. +The module allows you to assert that the CloudFormation template contains an Output +that matches specific properties. The following code asserts that a template contains +an Output with a `logicalId` of `Foo` and the specified properties - -* Outputs - `hasOutput()` and `findOutputs()` -* Mapping - `hasMapping()` and `findMappings()` +```ts +assert.hasOutput('Foo', { + Value: 'Bar', + Export: { Name: 'ExportBaz' }, +}); +``` + +If you want to match against all Outputs in the template, use `*` as the `logicalId`. + +```ts +assert.hasOutput('*', { + Value: 'Bar', + Export: { Name: 'ExportBaz' }, +}); +``` + +`findOutputs()` will return a list of outputs that match the `logicalId` and `props`, +and you can use the `'*'` special case as well. -All of the defaults and behaviour documented for `hasResource()` and -`findResources()` apply to these methods. +The APIs `hasMapping()` and `findMappings()` provide similar functionalities. ## Special Matchers diff --git a/packages/@aws-cdk/assertions/lib/private/mappings.ts b/packages/@aws-cdk/assertions/lib/private/mappings.ts index 0def435cc0e1d..266e322bb1139 100644 --- a/packages/@aws-cdk/assertions/lib/private/mappings.ts +++ b/packages/@aws-cdk/assertions/lib/private/mappings.ts @@ -1,9 +1,9 @@ import { StackInspector } from '../vendored/assert'; -import { formatFailure, matchSection } from './section'; +import { filterLogicalId, formatFailure, matchSection } from './section'; -export function findMappings(inspector: StackInspector, props: any = {}): { [key: string]: any }[] { +export function findMappings(inspector: StackInspector, logicalId: string, props: any = {}): { [key: string]: any }[] { const section: { [key: string] : {} } = inspector.value.Mappings; - const result = matchSection(section, props); + const result = matchSection(filterLogicalId(section, logicalId), props); if (!result.match) { return []; @@ -12,9 +12,9 @@ export function findMappings(inspector: StackInspector, props: any = {}): { [key return result.matches; } -export function hasMapping(inspector: StackInspector, props: any): string | void { +export function hasMapping(inspector: StackInspector, logicalId: string, props: any): string | void { const section: { [key: string]: {} } = inspector.value.Mappings; - const result = matchSection(section, props); + const result = matchSection(filterLogicalId(section, logicalId), props); if (result.match) { return; diff --git a/packages/@aws-cdk/assertions/lib/private/outputs.ts b/packages/@aws-cdk/assertions/lib/private/outputs.ts index 46e5a6cb1d52b..870e00555b254 100644 --- a/packages/@aws-cdk/assertions/lib/private/outputs.ts +++ b/packages/@aws-cdk/assertions/lib/private/outputs.ts @@ -1,9 +1,9 @@ import { StackInspector } from '../vendored/assert'; -import { formatFailure, matchSection } from './section'; +import { filterLogicalId, formatFailure, matchSection } from './section'; -export function findOutputs(inspector: StackInspector, props: any = {}): { [key: string]: any }[] { +export function findOutputs(inspector: StackInspector, logicalId: string, props: any = {}): { [key: string]: any }[] { const section: { [key: string] : {} } = inspector.value.Outputs; - const result = matchSection(section, props); + const result = matchSection(filterLogicalId(section, logicalId), props); if (!result.match) { return []; @@ -12,20 +12,19 @@ export function findOutputs(inspector: StackInspector, props: any = {}): { [key: return result.matches; } -export function hasOutput(inspector: StackInspector, props: any): string | void { +export function hasOutput(inspector: StackInspector, logicalId: string, props: any): string | void { const section: { [key: string]: {} } = inspector.value.Outputs; - const result = matchSection(section, props); - + const result = matchSection(filterLogicalId(section, logicalId), props); if (result.match) { return; } if (result.closestResult === undefined) { - return 'No outputs found in the template'; + return `No outputs named ${logicalId} found in the template.`; } return [ - `Template has ${result.analyzedCount} outputs, but none match as expected.`, + `Template has ${result.analyzedCount} outputs named ${logicalId}, but none match as expected.`, formatFailure(result.closestResult), ].join('\n'); -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/assertions/lib/private/section.ts b/packages/@aws-cdk/assertions/lib/private/section.ts index d8f0123de20d6..59ad55241e581 100644 --- a/packages/@aws-cdk/assertions/lib/private/section.ts +++ b/packages/@aws-cdk/assertions/lib/private/section.ts @@ -55,4 +55,13 @@ export function formatFailure(closestResult: MatchResult): string { function leftPad(x: string, indent: number = 2): string { const pad = ' '.repeat(indent); return pad + x.split('\n').join(`\n${pad}`); +} + +export function filterLogicalId(section: { [key: string]: {} }, logicalId: string): { [key: string]: {} } { + // default signal for all logicalIds is '*' + if (logicalId === '*') return section; + + return Object.entries(section ?? {}) + .filter(([k, _]) => k === logicalId) + .reduce((agg, [k, v]) => { return { ...agg, [k]: v }; }, {}); } \ No newline at end of file diff --git a/packages/@aws-cdk/assertions/lib/template.ts b/packages/@aws-cdk/assertions/lib/template.ts index 848c46bcc295a..d642e74962080 100644 --- a/packages/@aws-cdk/assertions/lib/template.ts +++ b/packages/@aws-cdk/assertions/lib/template.ts @@ -109,10 +109,11 @@ export class Template { * Assert that an Output with the given properties exists in the CloudFormation template. * By default, performs partial matching on the resource, via the `Match.objectLike()`. * To configure different behavour, use other matchers in the `Match` class. + * @param logicalId the name of the output. Provide `'*'` to match all outputs in the template. * @param props the output as should be expected in the template. */ - public hasOutput(props: any): void { - const matchError = hasOutput(this.inspector, props); + public hasOutput(logicalId: string, props: any): void { + const matchError = hasOutput(this.inspector, logicalId, props); if (matchError) { throw new Error(matchError); } @@ -120,22 +121,24 @@ export class Template { /** * Get the set of matching Outputs that match the given properties in the CloudFormation template. + * @param logicalId the name of the output. Provide `'*'` to match all outputs in the template. * @param props by default, matches all Outputs in the template. * When a literal object is provided, performs a partial match via `Match.objectLike()`. * Use the `Match` APIs to configure a different behaviour. */ - public findOutputs(props: any = {}): { [key: string]: any }[] { - return findOutputs(this.inspector, props); + public findOutputs(logicalId: string, props: any = {}): { [key: string]: any }[] { + return findOutputs(this.inspector, logicalId, props); } /** * Assert that a Mapping with the given properties exists in the CloudFormation template. * By default, performs partial matching on the resource, via the `Match.objectLike()`. * To configure different behavour, use other matchers in the `Match` class. + * @param logicalId the name of the mapping. Provide `'*'` to match all mappings in the template. * @param props the output as should be expected in the template. */ - public hasMapping(props: any): void { - const matchError = hasMapping(this.inspector, props); + public hasMapping(logicalId: string, props: any): void { + const matchError = hasMapping(this.inspector, logicalId, props); if (matchError) { throw new Error(matchError); } @@ -143,12 +146,13 @@ export class Template { /** * Get the set of matching Mappings that match the given properties in the CloudFormation template. + * @param logicalId the name of the mapping. Provide `'*'` to match all mappings in the template. * @param props by default, matches all Mappings in the template. * When a literal object is provided, performs a partial match via `Match.objectLike()`. * Use the `Match` APIs to configure a different behaviour. */ - public findMappings(props: any = {}): { [key: string]: any }[] { - return findMappings(this.inspector, props); + public findMappings(logicalId: string, props: any = {}): { [key: string]: any }[] { + return findMappings(this.inspector, logicalId, props); } /** diff --git a/packages/@aws-cdk/assertions/test/template.test.ts b/packages/@aws-cdk/assertions/test/template.test.ts index 50fb60a1a27f7..ca5c5c5ea1e58 100644 --- a/packages/@aws-cdk/assertions/test/template.test.ts +++ b/packages/@aws-cdk/assertions/test/template.test.ts @@ -342,7 +342,7 @@ describe('Template', () => { }); const inspect = Template.fromStack(stack); - expect(() => inspect.hasOutput({ Value: 'Bar' })).not.toThrow(); + expect(() => inspect.hasOutput('Foo', { Value: 'Bar' })).not.toThrow(); }); test('not matching', (done) => { @@ -357,18 +357,62 @@ describe('Template', () => { const inspect = Template.fromStack(stack); expectToThrow( - () => inspect.hasOutput({ + () => inspect.hasOutput('Foo', { Value: 'Bar', Export: { Name: 'ExportBaz' }, }), [ - /2 outputs/, + /1 outputs named Foo/, /Expected ExportBaz but received ExportBar/, ], done, ); done(); }); + + test('value not matching with outputName', (done) => { + const stack = new Stack(); + new CfnOutput(stack, 'Foo', { + value: 'Bar', + }); + new CfnOutput(stack, 'Fred', { + value: 'Baz', + }); + + const inspect = Template.fromStack(stack); + expectToThrow( + () => inspect.hasOutput('Fred', { + Value: 'Bar', + }), + [ + /1 outputs named Fred/, + /Expected Bar but received Baz/, + ], + done, + ); + done(); + }); + }); + + test('outputName not matching', (done) => { + const stack = new Stack(); + new CfnOutput(stack, 'Foo', { + value: 'Bar', + exportName: 'ExportBar', + }); + + const inspect = Template.fromStack(stack); + expectToThrow( + () => inspect.hasOutput('Fred', { + Value: 'Bar', + Export: { Name: 'ExportBar' }, + }), + [ + /No outputs named Fred found in the template./, + ], + done, + ); + done(); }); describe('findOutputs', () => { @@ -388,7 +432,7 @@ describe('Template', () => { }); const inspect = Template.fromStack(stack); - const result = inspect.findOutputs({ Value: 'Fred' }); + const result = inspect.findOutputs('*', { Value: 'Fred' }); expect(result).toEqual([ { Value: 'Fred', Description: 'FooFred' }, { Value: 'Fred', Description: 'BarFred' }, @@ -402,7 +446,37 @@ describe('Template', () => { }); const inspect = Template.fromStack(stack); - const result = inspect.findOutputs({ Value: 'Waldo' }); + const result = inspect.findOutputs('*', { Value: 'Waldo' }); + expect(result.length).toEqual(0); + }); + + test('matching specific output', () => { + const stack = new Stack(); + new CfnOutput(stack, 'Foo', { + value: 'Fred', + }); + new CfnOutput(stack, 'Baz', { + value: 'Waldo', + }); + + const inspect = Template.fromStack(stack); + const result = inspect.findOutputs('Foo', { Value: 'Fred' }); + expect(result).toEqual([ + { Value: 'Fred' }, + ]); + }); + + test('not matching specific output', () => { + const stack = new Stack(); + new CfnOutput(stack, 'Foo', { + value: 'Fred', + }); + new CfnOutput(stack, 'Baz', { + value: 'Waldo', + }); + + const inspect = Template.fromStack(stack); + const result = inspect.findOutputs('Foo', { Value: 'Waldo' }); expect(result.length).toEqual(0); }); }); @@ -423,7 +497,7 @@ describe('Template', () => { }); const inspect = Template.fromStack(stack); - expect(() => inspect.hasMapping({ Foo: { Bar: 'Lightning' } })).not.toThrow(); + expect(() => inspect.hasMapping('*', { Foo: { Bar: 'Lightning' } })).not.toThrow(); }); test('not matching', (done) => { @@ -442,7 +516,7 @@ describe('Template', () => { const inspect = Template.fromStack(stack); expectToThrow( - () => inspect.hasMapping({ + () => inspect.hasMapping('*', { Foo: { Bar: 'Qux' }, }), [ @@ -453,6 +527,52 @@ describe('Template', () => { ); done(); }); + + test('matching specific outputName', () => { + const stack = new Stack(); + new CfnMapping(stack, 'Foo', { + mapping: { + Foo: { Bar: 'Lightning', Fred: 'Waldo' }, + Baz: { Bar: 'Qux' }, + }, + }); + new CfnMapping(stack, 'Fred', { + mapping: { + Foo: { Bar: 'Lightning' }, + }, + }); + + const inspect = Template.fromStack(stack); + expect(() => inspect.hasMapping('Foo', { Baz: { Bar: 'Qux' } })).not.toThrow(); + }); + + test('not matching specific outputName', (done) => { + const stack = new Stack(); + new CfnMapping(stack, 'Foo', { + mapping: { + Foo: { Bar: 'Fred', Baz: 'Waldo' }, + Qux: { Bar: 'Fred' }, + }, + }); + new CfnMapping(stack, 'Fred', { + mapping: { + Foo: { Baz: 'Baz' }, + }, + }); + + const inspect = Template.fromStack(stack); + expectToThrow( + () => inspect.hasMapping('Fred', { + Foo: { Baz: 'Fred' }, + }), + [ + /1 mappings/, + /Expected Fred but received Baz/, + ], + done, + ); + done(); + }); }); describe('findMappings', () => { @@ -471,7 +591,7 @@ describe('Template', () => { }); const inspect = Template.fromStack(stack); - const result = inspect.findMappings({ Foo: { Bar: 'Lightning' } }); + const result = inspect.findMappings('*', { Foo: { Bar: 'Lightning' } }); expect(result).toEqual([ { Foo: { Bar: 'Lightning', Fred: 'Waldo' }, @@ -490,7 +610,50 @@ describe('Template', () => { }); const inspect = Template.fromStack(stack); - const result = inspect.findMappings({ Foo: { Bar: 'Waldo' } }); + const result = inspect.findMappings('*', { Foo: { Bar: 'Waldo' } }); + expect(result.length).toEqual(0); + }); + + test('matching with specific outputName', () => { + const stack = new Stack(); + new CfnMapping(stack, 'Foo', { + mapping: { + Foo: { Bar: 'Lightning', Fred: 'Waldo' }, + Baz: { Bar: 'Qux' }, + }, + }); + new CfnMapping(stack, 'Fred', { + mapping: { + Foo: { Bar: 'Lightning' }, + }, + }); + + const inspect = Template.fromStack(stack); + const result = inspect.findMappings('Foo', { Foo: { Bar: 'Lightning' } }); + expect(result).toEqual([ + { + Foo: { Bar: 'Lightning', Fred: 'Waldo' }, + Baz: { Bar: 'Qux' }, + }, + ]); + }); + + test('not matching', () => { + const stack = new Stack(); + new CfnMapping(stack, 'Foo', { + mapping: { + Foo: { Bar: 'Lightning', Fred: 'Waldo' }, + Baz: { Bar: 'Qux' }, + }, + }); + new CfnMapping(stack, 'Fred', { + mapping: { + Foo: { Bar: 'Lightning' }, + }, + }); + + const inspect = Template.fromStack(stack); + const result = inspect.findMappings('Fred', { Baz: { Bar: 'Qux' } }); expect(result.length).toEqual(0); }); }); diff --git a/packages/@aws-cdk/aws-kinesisfirehose/test/delivery-stream.test.ts b/packages/@aws-cdk/aws-kinesisfirehose/test/delivery-stream.test.ts index 7c36a29e379b5..f716e56a8f326 100644 --- a/packages/@aws-cdk/aws-kinesisfirehose/test/delivery-stream.test.ts +++ b/packages/@aws-cdk/aws-kinesisfirehose/test/delivery-stream.test.ts @@ -491,7 +491,7 @@ describe('delivery stream', () => { destinations: [mockS3Destination], }); - Template.fromStack(stack).hasMapping({ + Template.fromStack(stack).hasMapping('*', { 'af-south-1': { FirehoseCidrBlock: '13.244.121.224/27', }, diff --git a/packages/@aws-cdk/aws-neptune/test/instance.test.ts b/packages/@aws-cdk/aws-neptune/test/instance.test.ts index 38a6981ab2a78..ed83e1506496a 100644 --- a/packages/@aws-cdk/aws-neptune/test/instance.test.ts +++ b/packages/@aws-cdk/aws-neptune/test/instance.test.ts @@ -43,7 +43,7 @@ describe('DatabaseInstance', () => { }); // THEN - Template.fromStack(stack).hasOutput({ + Template.fromStack(stack).hasOutput(exportName, { Export: { Name: exportName }, Value: { 'Fn::Join': [ @@ -78,7 +78,7 @@ describe('DatabaseInstance', () => { }); // THEN - Template.fromStack(stack).hasOutput({ + Template.fromStack(stack).hasOutput('EndpointOutput', { Export: { Name: endpointExportName }, Value: `${instanceEndpointAddress}:${port}`, }); diff --git a/packages/aws-cdk/lib/cdk-toolkit.ts b/packages/aws-cdk/lib/cdk-toolkit.ts index 636da75d12975..6bc109dd37822 100644 --- a/packages/aws-cdk/lib/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cdk-toolkit.ts @@ -404,7 +404,8 @@ export class CdkToolkit { defaultBehavior: DefaultSelection.OnlySingle, }); - await this.validateStacks(stacks); + this.validateStacksSelected(stacks, selector.patterns); + this.validateStacks(stacks); return stacks; } @@ -422,7 +423,8 @@ export class CdkToolkit { ? allStacks.filter(art => art.validateOnSynth ?? false) : new StackCollection(assembly, []); - await this.validateStacks(selectedForDiff.concat(autoValidateStacks)); + this.validateStacksSelected(selectedForDiff.concat(autoValidateStacks), stackNames); + this.validateStacks(selectedForDiff.concat(autoValidateStacks)); return selectedForDiff; } @@ -442,7 +444,7 @@ export class CdkToolkit { /** * Validate the stacks for errors and warnings according to the CLI's current settings */ - private async validateStacks(stacks: StackCollection) { + private validateStacks(stacks: StackCollection) { stacks.processMetadataMessages({ ignoreErrors: this.props.ignoreErrors, strict: this.props.strict, @@ -450,6 +452,15 @@ export class CdkToolkit { }); } + /** + * Validate that if a user specified a stack name there exists at least 1 stack selected + */ + private validateStacksSelected(stacks: StackCollection, stackNames: string[]) { + if (stackNames.length != 0 && stacks.stackCount == 0) { + throw new Error(`No stacks match the name(s) ${stackNames}`); + } + } + /** * Select a single stack by its name */ diff --git a/packages/aws-cdk/test/cdk-toolkit.test.ts b/packages/aws-cdk/test/cdk-toolkit.test.ts index 9c42c21261eaa..19276b15b7b7b 100644 --- a/packages/aws-cdk/test/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cdk-toolkit.test.ts @@ -40,6 +40,14 @@ function defaultToolkitSetup() { } describe('deploy', () => { + test('fails when no valid stack names are given', async () => { + // GIVEN + const toolkit = defaultToolkitSetup(); + + // WHEN + await expect(() => toolkit.deploy({ selector: { patterns: ['Test-Stack-D'] } })).rejects.toThrow('No stacks match the name(s) Test-Stack-D'); + }); + describe('with hotswap deployment', () => { test("passes through the 'hotswap' option to CloudFormationDeployments.deployStack()", async () => { // GIVEN diff --git a/packages/aws-cdk/test/diff.test.ts b/packages/aws-cdk/test/diff.test.ts index c1bcd11c78caa..829c24d637ca9 100644 --- a/packages/aws-cdk/test/diff.test.ts +++ b/packages/aws-cdk/test/diff.test.ts @@ -96,6 +96,16 @@ test('exits with 1 with diffs and fail set to true', async () => { expect(exitCode).toBe(1); }); +test('throws an error if no valid stack names given', async () => { + const buffer = new StringWritable(); + + // WHEN + await expect(() => toolkit.diff({ + stackNames: ['X', 'Y', 'Z'], + stream: buffer, + })).rejects.toThrow('No stacks match the name(s) X,Y,Z'); +}); + test('exits with 1 with diff in first stack, but not in second stack and fail set to true', async () => { // GIVEN const buffer = new StringWritable();