diff --git a/packages/@aws-cdk/assert/lib/canonicalize-assets.ts b/packages/@aws-cdk/assert/lib/canonicalize-assets.ts new file mode 100644 index 0000000000000..9cee3d4742b3c --- /dev/null +++ b/packages/@aws-cdk/assert/lib/canonicalize-assets.ts @@ -0,0 +1,71 @@ +/** + * Reduce template to a normal form where asset references have been normalized + * + * This makes it possible to compare templates if all that's different between + * them is the hashes of the asset values. + * + * Currently only handles parameterized assets, but can (and should) + * be adapted to handle convention-mode assets as well when we start using + * more of those. + */ +export function canonicalizeTemplate(template: any): any { + // For the weird case where we have an array of templates... + if (Array.isArray(template)) { + return template.map(canonicalizeTemplate); + } + + // Find assets via parameters + const stringSubstitutions = new Array<[RegExp, string]>(); + const paramRe = /^AssetParameters([a-zA-Z0-9]{64})(S3Bucket|S3VersionKey|ArtifactHash)([a-zA-Z0-9]{8})$/; + + const assetsSeen = new Set(); + for (const paramName of Object.keys(template?.Parameters || {})) { + const m = paramRe.exec(paramName); + if (!m) { continue; } + if (assetsSeen.has(m[1])) { continue; } + + assetsSeen.add(m[1]); + const ix = assetsSeen.size; + + // Full parameter reference + stringSubstitutions.push([ + new RegExp(`AssetParameters${m[1]}(S3Bucket|S3VersionKey|ArtifactHash)([a-zA-Z0-9]{8})`), + `Asset${ix}$1`, + ]); + // Substring asset hash reference + stringSubstitutions.push([ + new RegExp(`${m[1]}`), + `Asset${ix}Hash`, + ]); + } + + // Substitute them out + return substitute(template); + + function substitute(what: any): any { + if (Array.isArray(what)) { + return what.map(substitute); + } + + if (typeof what === 'object' && what !== null) { + const ret: any = {}; + for (const [k, v] of Object.entries(what)) { + ret[stringSub(k)] = substitute(v); + } + return ret; + } + + if (typeof what === 'string') { + return stringSub(what); + } + + return what; + } + + function stringSub(x: string) { + for (const [re, replacement] of stringSubstitutions) { + x = x.replace(re, replacement); + } + return x; + } +} diff --git a/packages/@aws-cdk/assert/lib/expect.ts b/packages/@aws-cdk/assert/lib/expect.ts index 392d374cb52a1..21dd7e011c826 100644 --- a/packages/@aws-cdk/assert/lib/expect.ts +++ b/packages/@aws-cdk/assert/lib/expect.ts @@ -3,8 +3,10 @@ import * as api from '@aws-cdk/cx-api'; import { StackInspector } from './inspector'; import { SynthUtils } from './synth-utils'; -export function expect(stack: api.CloudFormationStackArtifact | cdk.Stack, skipValidation = false): StackInspector { +export function expect(stack: api.CloudFormationStackArtifact | cdk.Stack | Record, skipValidation = false): StackInspector { // if this is already a synthesized stack, then just inspect it. - const artifact = stack instanceof api.CloudFormationStackArtifact ? stack : SynthUtils._synthesizeWithNested(stack, { skipValidation }); + const artifact = stack instanceof api.CloudFormationStackArtifact ? stack + : cdk.Stack.isStack(stack) ? SynthUtils._synthesizeWithNested(stack, { skipValidation }) + : stack; // This is a template already return new StackInspector(artifact); } diff --git a/packages/@aws-cdk/assert/lib/index.ts b/packages/@aws-cdk/assert/lib/index.ts index ff3516dc2f6fd..751b04a0c4f07 100644 --- a/packages/@aws-cdk/assert/lib/index.ts +++ b/packages/@aws-cdk/assert/lib/index.ts @@ -1,4 +1,5 @@ export * from './assertion'; +export * from './canonicalize-assets'; export * from './expect'; export * from './inspector'; export * from './synth-utils'; diff --git a/packages/@aws-cdk/assert/test/canonicalize-assets.test.ts b/packages/@aws-cdk/assert/test/canonicalize-assets.test.ts new file mode 100644 index 0000000000000..38ebd4b1e5b47 --- /dev/null +++ b/packages/@aws-cdk/assert/test/canonicalize-assets.test.ts @@ -0,0 +1,135 @@ +import { canonicalizeTemplate } from '../lib'; + +test('Canonicalize asset parameters and references to them', () => { + const template = { + Resources: { + AResource: { + Type: 'Some::Resource', + Properties: { + SomeValue: { Ref: 'AssetParametersea46702e1c05b2735e48e826d630f7bf6acdf7e55d6fa8d9fa8df858d5542161S3Bucket0C424907' }, + }, + }, + }, + Parameters: { + AssetParametersea46702e1c05b2735e48e826d630f7bf6acdf7e55d6fa8d9fa8df858d5542161S3Bucket0C424907: { + Type: 'String', + Description: 'S3 bucket for asset \'ea46702e1c05b2735e48e826d630f7bf6acdf7e55d6fa8d9fa8df858d5542161\'', + }, + AssetParametersea46702e1c05b2735e48e826d630f7bf6acdf7e55d6fa8d9fa8df858d5542161S3VersionKey6841F1F8: { + Type: 'String', + Description: 'S3 key for asset version \'ea46702e1c05b2735e48e826d630f7bf6acdf7e55d6fa8d9fa8df858d5542161\'', + }, + AssetParametersea46702e1c05b2735e48e826d630f7bf6acdf7e55d6fa8d9fa8df858d5542161ArtifactHash67B22EF2: { + Type: 'String', + Description: 'Artifact hash for asset \'ea46702e1c05b2735e48e826d630f7bf6acdf7e55d6fa8d9fa8df858d5542161\'', + }, + }, + }; + + const expected = { + Resources: { + AResource: { + Type: 'Some::Resource', + Properties: { + SomeValue: { Ref: 'Asset1S3Bucket' }, + }, + }, + }, + Parameters: { + Asset1S3Bucket: { + Type: 'String', + Description: 'S3 bucket for asset \'Asset1Hash\'', + }, + Asset1S3VersionKey: { + Type: 'String', + Description: 'S3 key for asset version \'Asset1Hash\'', + }, + Asset1ArtifactHash: { + Type: 'String', + Description: 'Artifact hash for asset \'Asset1Hash\'', + }, + }, + }; + + expect(canonicalizeTemplate(template)).toEqual(expected); +}); + +test('Distinguished 2 different assets', () => { + const template = { + Resources: { + AResource: { + Type: 'Some::Resource', + Properties: { + SomeValue: { Ref: 'AssetParametersea46702e1c05b2735e48e826d630f7bf6acdf7e55d6fa8d9fa8df858d5542161S3Bucket0C424907' }, + OtherValue: { Ref: 'AssetParameters1111111111111111111111111111111111122222222222222222222222222222ArtifactHash67B22EF2' }, + }, + }, + }, + Parameters: { + AssetParametersea46702e1c05b2735e48e826d630f7bf6acdf7e55d6fa8d9fa8df858d5542161S3Bucket0C424907: { + Type: 'String', + Description: 'S3 bucket for asset \'ea46702e1c05b2735e48e826d630f7bf6acdf7e55d6fa8d9fa8df858d5542161\'', + }, + AssetParametersea46702e1c05b2735e48e826d630f7bf6acdf7e55d6fa8d9fa8df858d5542161S3VersionKey6841F1F8: { + Type: 'String', + Description: 'S3 key for asset version \'ea46702e1c05b2735e48e826d630f7bf6acdf7e55d6fa8d9fa8df858d5542161\'', + }, + AssetParametersea46702e1c05b2735e48e826d630f7bf6acdf7e55d6fa8d9fa8df858d5542161ArtifactHash67B22EF2: { + Type: 'String', + Description: 'Artifact hash for asset \'ea46702e1c05b2735e48e826d630f7bf6acdf7e55d6fa8d9fa8df858d5542161\'', + }, + AssetParameters1111111111111111111111111111111111122222222222222222222222222222S3Bucket0C424907: { + Type: 'String', + Description: 'S3 bucket for asset \'1111111111111111111111111111111111122222222222222222222222222222\'', + }, + AssetParameters1111111111111111111111111111111111122222222222222222222222222222S3VersionKey6841F1F8: { + Type: 'String', + Description: 'S3 key for asset version \'1111111111111111111111111111111111122222222222222222222222222222\'', + }, + AssetParameters1111111111111111111111111111111111122222222222222222222222222222ArtifactHash67B22EF2: { + Type: 'String', + Description: 'Artifact hash for asset \'1111111111111111111111111111111111122222222222222222222222222222\'', + }, + }, + }; + + const expected = { + Resources: { + AResource: { + Type: 'Some::Resource', + Properties: { + SomeValue: { Ref: 'Asset1S3Bucket' }, + OtherValue: { Ref: 'Asset2ArtifactHash' }, + }, + }, + }, + Parameters: { + Asset1S3Bucket: { + Type: 'String', + Description: 'S3 bucket for asset \'Asset1Hash\'', + }, + Asset1S3VersionKey: { + Type: 'String', + Description: 'S3 key for asset version \'Asset1Hash\'', + }, + Asset1ArtifactHash: { + Type: 'String', + Description: 'Artifact hash for asset \'Asset1Hash\'', + }, + Asset2S3Bucket: { + Type: 'String', + Description: 'S3 bucket for asset \'Asset2Hash\'', + }, + Asset2S3VersionKey: { + Type: 'String', + Description: 'S3 key for asset version \'Asset2Hash\'', + }, + Asset2ArtifactHash: { + Type: 'String', + Description: 'Artifact hash for asset \'Asset2Hash\'', + }, + }, + }; + + expect(canonicalizeTemplate(template)).toEqual(expected); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-lambda/test/test.layers.ts b/packages/@aws-cdk/aws-lambda/test/test.layers.ts index 3ec35f172f382..d5d76901152f8 100644 --- a/packages/@aws-cdk/aws-lambda/test/test.layers.ts +++ b/packages/@aws-cdk/aws-lambda/test/test.layers.ts @@ -1,4 +1,4 @@ -import { expect, haveResource, ResourcePart } from '@aws-cdk/assert'; +import { canonicalizeTemplate, expect, haveResource, ResourcePart, SynthUtils } from '@aws-cdk/assert'; import * as s3 from '@aws-cdk/aws-s3'; import * as cdk from '@aws-cdk/core'; import * as cxapi from '@aws-cdk/cx-api'; @@ -85,9 +85,9 @@ export = testCase({ }); // THEN - expect(stack).to(haveResource('AWS::Lambda::LayerVersion', { + expect(canonicalizeTemplate(SynthUtils.toCloudFormation(stack))).to(haveResource('AWS::Lambda::LayerVersion', { Metadata: { - 'aws:asset:path': 'asset.8811a2632ac5564a08fd269e159298f7e497f259578b0dc5e927a1f48ab24d34', + 'aws:asset:path': 'asset.Asset1Hash', 'aws:asset:property': 'Content', }, }, ResourcePart.CompleteDefinition)); diff --git a/packages/@aws-cdk/core/lib/private/runtime-info.ts b/packages/@aws-cdk/core/lib/private/runtime-info.ts index dce0ac508d71f..182776f205c48 100644 --- a/packages/@aws-cdk/core/lib/private/runtime-info.ts +++ b/packages/@aws-cdk/core/lib/private/runtime-info.ts @@ -65,7 +65,7 @@ export function collectRuntimeInformation(): cxschema.RuntimeInfo { function findNpmPackage(fileName: string): { name: string, version: string, private?: boolean } | undefined { const mod = require.cache[fileName]; - if (!mod.paths) { + if (!mod?.paths) { // sometimes this can be undefined. for example when querying for .json modules // inside a jest runtime environment. // see https://github.com/aws/aws-cdk/issues/7657 @@ -74,7 +74,7 @@ function findNpmPackage(fileName: string): { name: string, version: string, priv } // For any path in ``mod.paths`` that is a node_modules folder, use its parent directory instead. - const paths = mod.paths.map((path: string) => basename(path) === 'node_modules' ? dirname(path) : path); + const paths = mod?.paths.map((path: string) => basename(path) === 'node_modules' ? dirname(path) : path); try { const packagePath = require.resolve( diff --git a/packages/@aws-cdk/core/test/custom-resource-provider/test.custom-resource-provider.ts b/packages/@aws-cdk/core/test/custom-resource-provider/test.custom-resource-provider.ts index 47bdbce65e852..76ed2a2b1b1ed 100644 --- a/packages/@aws-cdk/core/test/custom-resource-provider/test.custom-resource-provider.ts +++ b/packages/@aws-cdk/core/test/custom-resource-provider/test.custom-resource-provider.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import { Test } from 'nodeunit'; import * as path from 'path'; -import { CustomResourceProvider, CustomResourceProviderRuntime, Duration, Size, Stack } from '../../lib'; +import { AssetStaging, CustomResourceProvider, CustomResourceProviderRuntime, Duration, Size, Stack } from '../../lib'; import { toCloudFormation } from '../util'; const TEST_HANDLER = `${__dirname}/mock-provider`; @@ -20,6 +20,16 @@ export = { // THEN test.ok(fs.existsSync(path.join(TEST_HANDLER, '__entrypoint__.js')), 'expecting entrypoint to be copied to the handler directory'); const cfn = toCloudFormation(stack); + + // The asset hash constantly changes, so in order to not have to chase it, just look + // it up from the output. + const staging = stack.node.tryFindChild('Custom:MyResourceTypeCustomResourceProvider')?.node.tryFindChild('Staging') as AssetStaging; + const assetHash = staging.sourceHash; + const paramNames = Object.keys(cfn.Parameters); + const bucketParam = paramNames[0]; + const keyParam = paramNames[1]; + const hashParam = paramNames[2]; + test.deepEqual(cfn, { Resources: { CustomMyResourceTypeCustomResourceProviderRoleBD5E655F: { @@ -48,9 +58,7 @@ export = { Type: 'AWS::Lambda::Function', Properties: { Code: { - S3Bucket: { - Ref: 'AssetParameters925e7fbbec7bdbf0136ef5a07b8a0fbe0b1f1bb4ea50ae2154163df78aa9f226S3Bucket8B4D0E9E', - }, + S3Bucket: { Ref: bucketParam }, S3Key: { 'Fn::Join': [ '', @@ -61,9 +69,7 @@ export = { { 'Fn::Split': [ '||', - { - Ref: 'AssetParameters925e7fbbec7bdbf0136ef5a07b8a0fbe0b1f1bb4ea50ae2154163df78aa9f226S3VersionKeyDECB34FE', - }, + { Ref: keyParam }, ], }, ], @@ -74,9 +80,7 @@ export = { { 'Fn::Split': [ '||', - { - Ref: 'AssetParameters925e7fbbec7bdbf0136ef5a07b8a0fbe0b1f1bb4ea50ae2154163df78aa9f226S3VersionKeyDECB34FE', - }, + { Ref: keyParam }, ], }, ], @@ -102,17 +106,17 @@ export = { }, }, Parameters: { - AssetParameters925e7fbbec7bdbf0136ef5a07b8a0fbe0b1f1bb4ea50ae2154163df78aa9f226S3Bucket8B4D0E9E: { + [bucketParam]: { Type: 'String', - Description: 'S3 bucket for asset "925e7fbbec7bdbf0136ef5a07b8a0fbe0b1f1bb4ea50ae2154163df78aa9f226"', + Description: `S3 bucket for asset "${assetHash}"`, }, - AssetParameters925e7fbbec7bdbf0136ef5a07b8a0fbe0b1f1bb4ea50ae2154163df78aa9f226S3VersionKeyDECB34FE: { + [keyParam]: { Type: 'String', - Description: 'S3 key for asset version "925e7fbbec7bdbf0136ef5a07b8a0fbe0b1f1bb4ea50ae2154163df78aa9f226"', + Description: `S3 key for asset version "${assetHash}"`, }, - AssetParameters925e7fbbec7bdbf0136ef5a07b8a0fbe0b1f1bb4ea50ae2154163df78aa9f226ArtifactHashEEC400F2: { + [hashParam]: { Type: 'String', - Description: 'Artifact hash for asset "925e7fbbec7bdbf0136ef5a07b8a0fbe0b1f1bb4ea50ae2154163df78aa9f226"', + Description: `Artifact hash for asset "${assetHash}"`, }, }, }); diff --git a/tools/cdk-integ-tools/bin/cdk-integ-assert.ts b/tools/cdk-integ-tools/bin/cdk-integ-assert.ts index 300c5ddc75bf4..a58d942983ed8 100644 --- a/tools/cdk-integ-tools/bin/cdk-integ-assert.ts +++ b/tools/cdk-integ-tools/bin/cdk-integ-assert.ts @@ -1,10 +1,13 @@ #!/usr/bin/env node // Verify that all integration tests still match their expected output +import { canonicalizeTemplate } from '@aws-cdk/assert'; import { diffTemplate, formatDifferences } from '@aws-cdk/cloudformation-diff'; import { DEFAULT_SYNTH_OPTIONS, IntegrationTests } from '../lib/integ-helpers'; // tslint:disable:no-console +const IGNORE_ASSETS_PRAGMA = 'pragma:ignore-assets'; + async function main() { const tests = await new IntegrationTests('test').fromCliArgs(); // always assert all tests const failures: string[] = []; @@ -16,9 +19,13 @@ async function main() { throw new Error(`No such file: ${test.expectedFileName}. Run 'npm run integ'.`); } - const expected = await test.readExpected(); + let expected = await test.readExpected(); + let actual = await test.cdkSynthFast(DEFAULT_SYNTH_OPTIONS); - const actual = await test.cdkSynthFast(DEFAULT_SYNTH_OPTIONS); + if ((await test.pragmas()).includes(IGNORE_ASSETS_PRAGMA)) { + expected = canonicalizeTemplate(expected); + actual = canonicalizeTemplate(actual); + } const diff = diffTemplate(expected, actual); diff --git a/tools/cdk-integ-tools/lib/integ-helpers.ts b/tools/cdk-integ-tools/lib/integ-helpers.ts index d264f545acde8..ae4ecff95e96b 100644 --- a/tools/cdk-integ-tools/lib/integ-helpers.ts +++ b/tools/cdk-integ-tools/lib/integ-helpers.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import { AVAILABILITY_ZONE_FALLBACK_CONTEXT_KEY } from '../../../packages/@aws-cdk/cx-api/lib'; const CDK_INTEG_STACK_PRAGMA = '/// !cdk-integ'; +const PRAGMA_PREFIX = 'pragma:'; export class IntegrationTests { constructor(private readonly directory: string) { @@ -225,6 +226,18 @@ export class IntegrationTest { await fs.writeFile(this.expectedFilePath, JSON.stringify(actual, undefined, 2), { encoding: 'utf-8' }); } + /** + * Return the non-stack pragmas + * + * These are all pragmas that start with "pragma:". + * + * For backwards compatibility reasons, all pragmas that DON'T start with this + * string are considered to be stack names. + */ + public async pragmas(): Promise { + return (await this.readIntegPragma()).filter(p => p.startsWith(PRAGMA_PREFIX)); + } + private async writeCdkContext(config: any) { await fs.writeFile(this.cdkContextPath, JSON.stringify(config, undefined, 2), { encoding: 'utf-8' }); } @@ -241,10 +254,29 @@ export class IntegrationTest { } /** + * Reads stack names from the "!cdk-integ" pragma. + * + * Every word that's NOT prefixed by "pragma:" is considered a stack name. + * + * @example + * + * /// !cdk-integ + */ + private async readStackPragma(): Promise { + return (await this.readIntegPragma()).filter(p => !p.startsWith(PRAGMA_PREFIX)); + } + + /** + * Read arbitrary cdk-integ pragma directives + * * Reads the test source file and looks for the "!cdk-integ" pragma. If it exists, returns it's * contents. This allows integ tests to supply custom command line arguments to "cdk deploy" and "cdk synth". + * + * @example + * + * /// !cdk-integ [...] */ - private async readStackPragma(): Promise { + private async readIntegPragma(): Promise { const source = await fs.readFile(this.sourceFilePath, { encoding: 'utf-8' }); const pragmaLine = source.split('\n').find(x => x.startsWith(CDK_INTEG_STACK_PRAGMA + ' ')); if (!pragmaLine) { @@ -253,7 +285,7 @@ export class IntegrationTest { const args = pragmaLine.substring(CDK_INTEG_STACK_PRAGMA.length).trim().split(' '); if (args.length === 0) { - throw new Error(`Invalid syntax for cdk-integ pragma. Usage: "${CDK_INTEG_STACK_PRAGMA} STACK ..."`); + throw new Error(`Invalid syntax for cdk-integ pragma. Usage: "${CDK_INTEG_STACK_PRAGMA} [STACK] [pragma:PRAGMA] [...]"`); } return args; } diff --git a/tools/cdk-integ-tools/package.json b/tools/cdk-integ-tools/package.json index 9dfb2ad3eda96..23b74c1489650 100644 --- a/tools/cdk-integ-tools/package.json +++ b/tools/cdk-integ-tools/package.json @@ -37,6 +37,7 @@ "dependencies": { "@aws-cdk/cloudformation-diff": "0.0.0", "@aws-cdk/cx-api": "0.0.0", + "@aws-cdk/assert": "0.0.0", "aws-cdk": "0.0.0", "fs-extra": "^9.0.1", "yargs": "^15.3.1" @@ -48,5 +49,8 @@ "homepage": "https://github.com/aws/aws-cdk", "engines": { "node": ">= 10.13.0 <13 || >=13.7.0" + }, + "peerDependencies": { + "@aws-cdk/assert": "0.0.0" } }