diff --git a/packages/@aws-cdk/integ-runner/lib/cli.ts b/packages/@aws-cdk/integ-runner/lib/cli.ts index 46b10b25b..a02b7ec7b 100644 --- a/packages/@aws-cdk/integ-runner/lib/cli.ts +++ b/packages/@aws-cdk/integ-runner/lib/cli.ts @@ -223,6 +223,7 @@ async function run(options: ReturnType) { caBundlePath: options.caBundlePath, roleArn: options.roleArn, allowDeleteFailures: options.allowDeleteFailures, + resolvedSnapshots: options.unstable.includes('resolved-snapshots'), }); testsSucceeded = success; diff --git a/packages/@aws-cdk/integ-runner/lib/engines/cdk-interface.ts b/packages/@aws-cdk/integ-runner/lib/engines/cdk-interface.ts index 6036b0dd7..d1f50e41b 100644 --- a/packages/@aws-cdk/integ-runner/lib/engines/cdk-interface.ts +++ b/packages/@aws-cdk/integ-runner/lib/engines/cdk-interface.ts @@ -1,5 +1,5 @@ import type { DefaultCdkOptions, DestroyOptions as BaseDestroyOptions, DeployOptions as BaseDeployOptions } from '@aws-cdk/cloud-assembly-schema/lib/integ-tests'; -import type { DeploymentMethod, ResourceDeleteFailure } from '@aws-cdk/toolkit-lib'; +import type { DeploymentMethod, EvaluatedStack, ResourceDeleteFailure } from '@aws-cdk/toolkit-lib'; /** * Events emitted during watch mode @@ -143,4 +143,15 @@ export interface ICdk { * cdk watch */ watch(options: DeployOptions, events?: WatchEvents): Promise; + + /** + * Evaluate the templates of deployed stacks against the deployed stacks + */ + evaluate(options: EvaluateOptions): Promise; +} + +/** + * Options for evaluating deployed stacks + */ +export interface EvaluateOptions extends DefaultCdkOptions { } diff --git a/packages/@aws-cdk/integ-runner/lib/engines/toolkit-lib.ts b/packages/@aws-cdk/integ-runner/lib/engines/toolkit-lib.ts index 81b42f970..e994ff4b7 100644 --- a/packages/@aws-cdk/integ-runner/lib/engines/toolkit-lib.ts +++ b/packages/@aws-cdk/integ-runner/lib/engines/toolkit-lib.ts @@ -1,11 +1,11 @@ import * as path from 'node:path'; import { UNKNOWN_REGION } from '@aws-cdk/cloud-assembly-api'; import type { DefaultCdkOptions } from '@aws-cdk/cloud-assembly-schema/lib/integ-tests'; -import type { ICloudAssemblySource, IIoHost, IoMessage, IoRequest, IReadableCloudAssembly, NonInteractiveIoHostProps, StackSelector } from '@aws-cdk/toolkit-lib'; +import type { EvaluatedStack, ICloudAssemblySource, IIoHost, IoMessage, IoRequest, IReadableCloudAssembly, NonInteractiveIoHostProps, StackSelector } from '@aws-cdk/toolkit-lib'; import { BaseCredentials, ExpandStackSelection, MemoryContext, NonInteractiveIoHost, StackSelectionStrategy, Toolkit } from '@aws-cdk/toolkit-lib'; import chalk from 'chalk'; import * as fs from 'fs-extra'; -import type { CdkDeployResult, CxOptions, DeployOptions, DestroyOptions, ICdk, ListOptions, SynthOptions, WatchEvents, WatchOptions } from './cdk-interface'; +import type { CdkDeployResult, CxOptions, DeployOptions, DestroyOptions, EvaluateOptions, ICdk, ListOptions, SynthOptions, WatchEvents, WatchOptions } from './cdk-interface'; import { ProxyAgentProvider } from './proxy-agent'; export interface ToolkitLibEngineOptions { @@ -130,6 +130,7 @@ export class ToolkitLibRunnerEngine implements ICdk { const toolkit = new Toolkit({ ioHost: this.showOutput ? this.ioHost : new NoopIoHost(), + unstableFeatures: ['evaluate'], sdkConfig: { baseCredentials: BaseCredentials.awsCliCompatible({ profile, @@ -244,6 +245,18 @@ export class ToolkitLibRunnerEngine implements ICdk { } } + /** + * Evaluates the templates of the deployed stacks + */ + public async evaluate(options: EvaluateOptions): Promise { + const toolkit = this.getOrCreateToolkit(options); + const cx = await this.cx(options); + const result = await toolkit.evaluate(cx, { + stacks: this.stackSelector(options), + }); + return result.stacks; + } + /** * Destroys the CDK app */ diff --git a/packages/@aws-cdk/integ-runner/lib/runner/cdk-test-app.ts b/packages/@aws-cdk/integ-runner/lib/runner/cdk-test-app.ts index 4c2801398..743fc979f 100644 --- a/packages/@aws-cdk/integ-runner/lib/runner/cdk-test-app.ts +++ b/packages/@aws-cdk/integ-runner/lib/runner/cdk-test-app.ts @@ -4,7 +4,7 @@ import { AVAILABILITY_ZONE_FALLBACK_CONTEXT_KEY, UNKNOWN_REGION } from '@aws-cdk import { type TestCase, type DefaultCdkOptions, RequireApproval } from '@aws-cdk/cloud-assembly-schema'; import * as fs from 'fs-extra'; import { IntegTestSuite, LegacyIntegTestSuite } from './integ-test-suite'; -import type { DeployOptions, DestroyOptions, ICdk, WatchEvents } from '../engines/cdk-interface'; +import type { DeployOptions, DestroyOptions, EvaluateOptions, ICdk, WatchEvents } from '../engines/cdk-interface'; import * as logger from '../logger'; import * as recommendedFlagsFile from '../recommended-feature-flags.json'; import { flatten } from '../utils'; @@ -393,6 +393,24 @@ export class CdkTestApp { }); } + public async evaluate(evaluateArgs: Omit) { + if (!this.hasValidRegion) { + throw new Error('CdkTestApp.evaluate: not initialized with a valid region'); + } + + return this.cdk.evaluate({ + ...DEFAULT_ARGS, + ...evaluateArgs, + app: this.appCommand, + output: this.outputDirectory, + profile: this.profile, + context: { + ...this.getContext('deployment'), + ...evaluateArgs?.context, + }, + }); + } + public async destroy(destroyArgs: Omit) { if (!this.hasValidRegion) { throw new Error('CdkTestApp.destroy: not initialized with a valid region'); diff --git a/packages/@aws-cdk/integ-runner/lib/runner/integ-test-runner.ts b/packages/@aws-cdk/integ-runner/lib/runner/integ-test-runner.ts index abb21f5e8..16101a0db 100644 --- a/packages/@aws-cdk/integ-runner/lib/runner/integ-test-runner.ts +++ b/packages/@aws-cdk/integ-runner/lib/runner/integ-test-runner.ts @@ -11,6 +11,8 @@ import { chunks, exec, execWithSubShell, promiseWithResolvers, renderCommand } f import type { CdkTestAppOptions, LegacyEnableLookups } from './cdk-test-app'; import { CdkTestApp, DEFAULT_ARGS } from './cdk-test-app'; import type { IntegTest } from './integration-tests'; +import type { ResolvedSnapshot } from './resolved-snapshot'; +import { resolvedSnapshot, writeResolvedSnapshots } from './resolved-snapshot'; import type { DestructiveChange, AssertionResults, AssertionResult } from '../workers/common'; import { DiagnosticReason, formatAssertionResults, formatError } from '../workers/common'; @@ -108,6 +110,14 @@ export interface RunOptions extends CommonOptions { * @default false */ readonly allowDeleteFailures?: boolean; + + /** + * Write each deployed test stack's resolved template to `.resolved.json` in the snapshot + * (unstable feature `resolved-snapshots`) + * + * @default false + */ + readonly resolvedSnapshots?: boolean; } type RunnerOptions = Omit & { @@ -167,6 +177,7 @@ export class IntegTestRunner { } const clean = runOptions.clean ?? true; + const resolvedSnapshots: ResolvedSnapshot[] = []; try { for (const [testCaseName, actualTestCase] of Object.entries(actualTestSuite.testSuite)) { const updateWorkflowEnabled = (runOptions.updateWorkflow ?? false) @@ -191,6 +202,14 @@ export class IntegTestRunner { allowDeleteFailures, runOptions.updateFromTags, ); + if (runOptions.resolvedSnapshots) { + // while the stacks are still deployed + const evaluated = await actual.evaluate({ + stacks: actualTestCase.stacks, + context: actualTestCase.cdkCommandOptions?.deploy?.args?.context, + }); + resolvedSnapshots.push(...evaluated.map(resolvedSnapshot)); + } } finally { if (!runOptions.dryRun && clean && (actualTestCase.cdkCommandOptions?.destroy?.enabled ?? true)) { await this.destroyTestAndAssertions(actual, actualTestCase, { @@ -207,7 +226,9 @@ export class IntegTestRunner { // only create the snapshot if there are no failed assertion results // (i.e. no failures) if (!Object.values(assertionResults ?? {}).some(result => result.status === 'fail')) { - await this.createSnapshot(actualTestSuite.enableLookups); + const snapshot = await this.createSnapshot(actualTestSuite.enableLookups); + // after the snapshot is synthesized, which replaces the snapshot directory + await writeResolvedSnapshots(snapshot.outputDirectory, resolvedSnapshots); } } finally { actual.cleanup(); @@ -216,9 +237,10 @@ export class IntegTestRunner { return assertionResults; } - private async createSnapshot(enableLookups: LegacyEnableLookups) { + private async createSnapshot(enableLookups: LegacyEnableLookups): Promise { const expected = await CdkTestApp.forGoldenSnapshot(this.appOptions); await expected.synthForGoldenSnapshot(this.destructiveChanges ?? [], enableLookups); + return expected; } /** diff --git a/packages/@aws-cdk/integ-runner/lib/runner/resolved-snapshot.ts b/packages/@aws-cdk/integ-runner/lib/runner/resolved-snapshot.ts new file mode 100644 index 000000000..240e0911d --- /dev/null +++ b/packages/@aws-cdk/integ-runner/lib/runner/resolved-snapshot.ts @@ -0,0 +1,141 @@ +import * as path from 'path'; +import type { EvaluatedStack, UnresolvedExpression } from '@aws-cdk/toolkit-lib'; +import * as fs from 'fs-extra'; + +/** + * Resource types whose settings are account-wide: the deployed value is whichever stack set it last + */ +const ACCOUNT_WIDE_TYPES = new Set(['AWS::ApiGateway::Account']); + +/** + * Shortest generated value that is also replaced inside longer strings. + * Shorter ones (e.g. API Gateway resource ids) are only replaced when they are the whole value. + */ +const MIN_EMBEDDED_LENGTH = 8; + +/** + * Parts of physical ids are only replaced if they contain a digit, like generated names and ids do: + * other parts are words such as a WAF scope (`REGIONAL`) that appear elsewhere with their own meaning + */ +const looksGenerated = (value: string) => /\d/.test(value); + +/** + * A deployed stack's template, with the values CloudFormation and the services chose for this deployment + * replaced by placeholders, so it is the same for every deployment of the same app + */ +export interface ResolvedSnapshot { + readonly version: '1'; + readonly stackName: string; + /** The template's resources, evaluated against the deployed stack */ + readonly resources: { [logicalId: string]: any }; + readonly outputs?: { [logicalId: string]: any }; + /** Expressions that could not be evaluated */ + readonly unresolved: UnresolvedExpression[]; + /** Resources with account-wide settings, whose deployed values depend on what else is deployed in the account */ + readonly accountWide?: string[]; + readonly nestedStacks?: { [logicalId: string]: ResolvedSnapshot }; +} + +/** + * Make a stack's evaluated template independent of the deployment it was evaluated against. + * + * Replaces, in every string value: + * - each generated physical id (one that doesn't appear in the resource's own properties) with `${LogicalId}`; + * the name at the end of a URL or ARN physical id with + * `${LogicalId.Name}`, and each part of a `|`-separated physical id with `${LogicalId[n]}` + * - the stack id, account, region and partition with `${AWS::StackId}`, `${AWS::AccountId}`, `${AWS::Region}` + * and `${AWS::Partition}` (taken from the stack id) + */ +export function resolvedSnapshot(stack: EvaluatedStack): ResolvedSnapshot { + const replacements = new Map(); + const add = (value: string | undefined, placeholder: string) => { + if (value && !replacements.has(value)) { + replacements.set(value, placeholder); + } + }; + // most specific first: when two values are equal, the first one added wins + add(stack.stackId, '${AWS::StackId}'); + // Names the template chooses (e.g. `StageName: prod`, `BucketName: my-bucket`) appear in the resource's own + // properties and are the same in every deployment; CloudFormation-generated names and ids never do + const generated = Object.entries(stack.physicalResourceIds) + .filter(([logicalId, physicalId]) => !stringsIn(stack.template.Resources?.[logicalId]?.Properties).has(physicalId)); + for (const [logicalId, physicalId] of generated) { + add(physicalId, `\${${logicalId}}`); + } + for (const [logicalId, physicalId] of generated) { + const parts = physicalId.split('|'); + if (parts.length > 1) { + parts.forEach((part, i) => looksGenerated(part) && add(part, `\${${logicalId}[${i}]}`)); + } + const name = physicalId.replace(/\/$/, '').split(/[/:]/).pop(); + if (name && name !== physicalId && looksGenerated(name)) { + add(name, `\${${logicalId}.Name}`); + } + } + const [, partition, , region, account] = stack.stackId.split(':'); + add(account, '${AWS::AccountId}'); + add(region, '${AWS::Region}'); + + const embedded = [...replacements.keys()].filter((v) => v.length >= MIN_EMBEDDED_LENGTH).sort((a, b) => b.length - a.length); + const embeddedPattern = embedded.length > 0 ? new RegExp(embedded.map(escapeRegExp).join('|'), 'g') : undefined; + const replace = (value: string) => { + const whole = replacements.get(value); + if (whole !== undefined) { + return whole; + } + const replaced = embeddedPattern ? value.replace(embeddedPattern, (match) => replacements.get(match)!) : value; + return partition ? replaced.split(`arn:${partition}:`).join('arn:${AWS::Partition}:') : replaced; + }; + + const resources = mapStrings(stack.template.Resources ?? {}, replace); + const accountWide = Object.entries(stack.template.Resources ?? {}) + .filter(([, r]) => ACCOUNT_WIDE_TYPES.has(r?.Type)) + .map(([logicalId]) => logicalId); + + return { + version: '1', + stackName: stack.stackName, + resources, + ...(stack.template.Outputs ? { outputs: mapStrings(stack.template.Outputs, replace) } : {}), + unresolved: stack.unresolved, + ...(accountWide.length > 0 ? { accountWide } : {}), + ...(stack.nestedStacks ? { + nestedStacks: Object.fromEntries(Object.entries(stack.nestedStacks).map(([id, nested]) => [id, resolvedSnapshot(nested)])), + } : {}), + }; +} + +/** + * Write each stack's resolved snapshot to `.resolved.json` in the snapshot directory + */ +export async function writeResolvedSnapshots(snapshotDirectory: string, snapshots: ResolvedSnapshot[]): Promise { + for (const snapshot of snapshots) { + await fs.writeJson(path.join(snapshotDirectory, `${snapshot.stackName}.resolved.json`), snapshot, { spaces: 1 }); + } +} + +function mapStrings(value: any, fn: (s: string) => string): any { + if (typeof value === 'string') { + return fn(value); + } + if (Array.isArray(value)) { + return value.map((x) => mapStrings(x, fn)); + } + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([k, x]) => [k, mapStrings(x, fn)])); + } + return value; +} + +function stringsIn(value: any, into = new Set()): Set { + if (typeof value === 'string') { + into.add(value); + } else if (value && typeof value === 'object') { + Object.values(value).forEach((x) => stringsIn(x, into)); + } + return into; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/packages/@aws-cdk/integ-runner/lib/unstable-features.ts b/packages/@aws-cdk/integ-runner/lib/unstable-features.ts index 795b6ab38..6ccccb56e 100644 --- a/packages/@aws-cdk/integ-runner/lib/unstable-features.ts +++ b/packages/@aws-cdk/integ-runner/lib/unstable-features.ts @@ -28,6 +28,11 @@ export interface UnstableFeature { * Registry of all unstable features */ export const UNSTABLE_FEATURES: Record = { + 'resolved-snapshots': { + status: FeatureStatus.SUPPORTED, + deprecationMessage: '', + removalMessage: '', + }, 'deprecated-cli-engine': { status: FeatureStatus.REMOVED, deprecationMessage: 'You have opted-in to use the deprecated CLI engine which is scheduled to be removed in January 2026. If you have encountered blockers while using the new default engine, please let us know by opening an issue: https://github.com/aws/aws-cdk-cli/issues/new/choose\n\nTo use the new default engine, remove the `--unstable=deprecated-cli-engine` option.', diff --git a/packages/@aws-cdk/integ-runner/lib/workers/common.ts b/packages/@aws-cdk/integ-runner/lib/workers/common.ts index 6a13b552b..7494e0bf1 100644 --- a/packages/@aws-cdk/integ-runner/lib/workers/common.ts +++ b/packages/@aws-cdk/integ-runner/lib/workers/common.ts @@ -222,6 +222,13 @@ export interface IntegTestOptions { */ readonly allowDeleteFailures?: boolean; + /** + * Write each deployed test stack's resolved template to the snapshot (unstable feature `resolved-snapshots`) + * + * @default false + */ + readonly resolvedSnapshots?: boolean; + /** * In unit test mode, leave the synth output directories, don't clean them. * diff --git a/packages/@aws-cdk/integ-runner/lib/workers/extract/extract_worker.ts b/packages/@aws-cdk/integ-runner/lib/workers/extract/extract_worker.ts index 754922cbc..f0e7d2497 100644 --- a/packages/@aws-cdk/integ-runner/lib/workers/extract/extract_worker.ts +++ b/packages/@aws-cdk/integ-runner/lib/workers/extract/extract_worker.ts @@ -71,6 +71,7 @@ export async function integTestWorker(request: IntegTestBatchRequest): Promise result.status === 'fail')) { failures.push(testInfo); diff --git a/packages/@aws-cdk/integ-runner/lib/workers/integ-test-worker.ts b/packages/@aws-cdk/integ-runner/lib/workers/integ-test-worker.ts index ea6ba1474..fefb076d5 100644 --- a/packages/@aws-cdk/integ-runner/lib/workers/integ-test-worker.ts +++ b/packages/@aws-cdk/integ-runner/lib/workers/integ-test-worker.ts @@ -168,6 +168,7 @@ export async function runIntegrationTestsInParallel( caBundlePath: options.caBundlePath, roleArn: options.roleArn, allowDeleteFailures: options.allowDeleteFailures, + resolvedSnapshots: options.resolvedSnapshots, }], { on: (diagnostic: Diagnostic) => { printResults(diagnostic); diff --git a/packages/@aws-cdk/integ-runner/test/helpers.ts b/packages/@aws-cdk/integ-runner/test/helpers.ts index 452db0a29..c7da3b472 100644 --- a/packages/@aws-cdk/integ-runner/test/helpers.ts +++ b/packages/@aws-cdk/integ-runner/test/helpers.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import type { DestroyOptions } from '@aws-cdk/cloud-assembly-schema/lib/integ-tests'; -import type { CdkDeployResult, DeployOptions, ICdk, ListOptions, SynthOptions } from '../lib/engines/cdk-interface'; +import type { EvaluatedStack } from '@aws-cdk/toolkit-lib'; +import type { CdkDeployResult, DeployOptions, EvaluateOptions, ICdk, ListOptions, SynthOptions } from '../lib/engines/cdk-interface'; import { IntegSnapshotRunner, IntegTest } from '../lib/runner'; import type { DestructiveChange, Diagnostic } from '../lib/workers'; @@ -34,6 +35,7 @@ export interface MockCdkMocks { synth?: jest.MockedFn<(options: SynthOptions) => Promise>; destroy?: jest.MockedFn<(options: DestroyOptions) => Promise>; list?: jest.MockedFn<(options: ListOptions) => Promise>; + evaluate?: jest.MockedFn<(options: EvaluateOptions) => Promise>; } export class MockCdkProvider { @@ -47,6 +49,7 @@ export class MockCdkProvider { synth: jest.fn().mockImplementation(), destroy: jest.fn().mockImplementation(), list: jest.fn().mockResolvedValue([]), + evaluate: jest.fn().mockResolvedValue([]), }; this.mockAll(); } @@ -75,12 +78,17 @@ export class MockCdkProvider { this.mocks.list = mock ?? jest.fn().mockResolvedValue([]); this.cdk.list = this.mocks.list; } + public mockEvaluate(mock?: MockCdkMocks['evaluate']) { + this.mocks.evaluate = mock ?? jest.fn().mockResolvedValue([]); + this.cdk.evaluate = this.mocks.evaluate; + } public mockAll(mocks: MockCdkMocks = {}): Required { this.mockDeploy(mocks.deploy); this.mockWatch(mocks.watch); this.mockSynth(mocks.synth); this.mockDestroy(mocks.destroy); this.mockList(mocks.list); + this.mockEvaluate(mocks.evaluate); return this.mocks as Required; } diff --git a/packages/@aws-cdk/integ-runner/test/runner/integ-test-runner.test.ts b/packages/@aws-cdk/integ-runner/test/runner/integ-test-runner.test.ts index 30d3d25b5..6c6f7bdaa 100644 --- a/packages/@aws-cdk/integ-runner/test/runner/integ-test-runner.test.ts +++ b/packages/@aws-cdk/integ-runner/test/runner/integ-test-runner.test.ts @@ -79,6 +79,67 @@ describe('IntegTest runIntegTests', () => { })); }); + test('with resolved snapshots, evaluates the deployed test stacks and writes them to the snapshot', async () => { + // GIVEN + const writeJson = jest.spyOn(fs, 'writeJson').mockImplementation(async () => { + }); + cdkMock.mockEvaluate(jest.fn().mockResolvedValue([{ + stackName: 'test-stack', + hierarchicalId: 'test-stack', + stackId: 'arn:aws:cloudformation:eu-west-1:123456789012:stack/test-stack/abcd1234-0000-0000-0000-000000000000', + physicalResourceIds: { Queue: 'test-stack-Queue-1A2B3C4D5E6F' }, + template: { Resources: { Alarm: { Type: 'AWS::CloudWatch::Alarm', Properties: { Dimensions: [{ Value: 'test-stack-Queue-1A2B3C4D5E6F' }] } } } }, + unresolved: [], + }])); + + // WHEN + const integTest = new IntegTestRunner({ + cdk: cdkMock.cdk, + region: 'eu-west-1', + test: new IntegTest({ + fileName: 'test/test-data/xxxxx.test-with-snapshot.js', + discoveryRoot: 'test/test-data', + }), + TESTING_usingMocks: true, + TESTING_useComparisonOutputDirectory: true, + }); + await integTest.runIntegTestCase({ resolvedSnapshots: true }); + + // THEN: evaluated after the deployment, before the stacks are destroyed + expect(cdkMock.mocks.evaluate).toHaveBeenCalledWith(expect.objectContaining({ + stacks: ['test-stack', 'new-test-stack'], + output: 'test/test-data/cdk-integ.out.xxxxx.test-with-snapshot.js.snapshot', + })); + expect(cdkMock.mocks.evaluate!.mock.invocationCallOrder[0]).toBeGreaterThan(cdkMock.mocks.deploy!.mock.invocationCallOrder[0]); + expect(cdkMock.mocks.evaluate!.mock.invocationCallOrder[0]).toBeLessThan(cdkMock.mocks.destroy!.mock.invocationCallOrder[0]); + expect(writeJson).toHaveBeenCalledWith( + 'test/test-data/xxxxx.test-with-snapshot.js.snapshot/test-stack.resolved.json', + expect.objectContaining({ + stackName: 'test-stack', + resources: { Alarm: { Type: 'AWS::CloudWatch::Alarm', Properties: { Dimensions: [{ Value: '${Queue}' }] } } }, + }), + expect.anything(), + ); + }); + + test('without resolved snapshots, does not evaluate the deployed stacks', async () => { + // WHEN + const integTest = new IntegTestRunner({ + cdk: cdkMock.cdk, + region: 'eu-west-1', + test: new IntegTest({ + fileName: 'test/test-data/xxxxx.test-with-snapshot.js', + discoveryRoot: 'test/test-data', + }), + TESTING_usingMocks: true, + TESTING_useComparisonOutputDirectory: true, + }); + await integTest.runIntegTestCase({}); + + // THEN + expect(cdkMock.mocks.evaluate).not.toHaveBeenCalled(); + }); + test('no snapshot', async () => { // WHEN const integTest = new IntegTestRunner({ diff --git a/packages/@aws-cdk/integ-runner/test/runner/resolved-snapshot.test.ts b/packages/@aws-cdk/integ-runner/test/runner/resolved-snapshot.test.ts new file mode 100644 index 000000000..dc7e62c49 --- /dev/null +++ b/packages/@aws-cdk/integ-runner/test/runner/resolved-snapshot.test.ts @@ -0,0 +1,124 @@ +import type { EvaluatedStack } from '@aws-cdk/toolkit-lib'; +import { resolvedSnapshot } from '../../lib/runner/resolved-snapshot'; + +/** + * The same app deployed twice: every generated name and id differs, in different accounts and regions + */ +function deployment(suffix: string, account: string, region: string): EvaluatedStack { + const queueName = `my-stack-JobsQueue1A2B3C4D-${suffix}Q`; + const queueUrl = `https://sqs.${region}.amazonaws.com/${account}/${queueName}`; + const roleName = `my-stack-HandlerServiceRole-${suffix}R`; + const functionName = `my-stack-Handler886CB40B-${suffix}F`; + return { + stackName: 'my-stack', + hierarchicalId: 'my-stack', + stackId: `arn:aws:cloudformation:${region}:${account}:stack/my-stack/${suffix}-0000-11f1-a84b-0affd9b7fb2d`, + physicalResourceIds: { + JobsQueue1A2B3C4D: queueUrl, + HandlerServiceRole: roleName, + Handler886CB40B: functionName, + ApiResource: `${suffix.slice(0, 6)}`, + WebAcl: `my-acl|0f3a${suffix}-5b6c-7d8e|REGIONAL`, + ApiStage: 'prod', + ApiAccount: `my-sta-ApiAc-${suffix}`, + }, + template: { + Resources: { + Handler886CB40B: { + Type: 'AWS::Lambda::Function', + Properties: { + Role: `arn:aws:iam::${account}:role/${roleName}`, + Environment: { Variables: { QUEUE_URL: queueUrl, STAGE: 'prod' } }, + }, + }, + AlarmQueueDepth: { + Type: 'AWS::CloudWatch::Alarm', + Properties: { Dimensions: [{ Name: 'QueueName', Value: queueName }], Namespace: 'AWS/SQS' }, + }, + ApiMethod: { + Type: 'AWS::ApiGateway::Method', + Properties: { ResourceId: suffix.slice(0, 6), HttpMethod: 'GET' }, + }, + WebAclAssociation: { + Type: 'AWS::WAFv2::WebACLAssociation', + Properties: { WebACLArn: `arn:aws:wafv2:${region}:${account}:regional/webacl/my-acl/0f3a${suffix}-5b6c-7d8e`, Scope: 'REGIONAL' }, + }, + ApiStage: { + Type: 'AWS::ApiGateway::Stage', + Properties: { StageName: 'prod' }, + }, + ApiAccount: { + Type: 'AWS::ApiGateway::Account', + Properties: { CloudWatchRoleArn: `arn:aws:iam::${account}:role/${roleName}` }, + }, + }, + Outputs: { + QueueUrl: { Value: queueUrl }, + }, + }, + unresolved: [], + }; +} + +describe('resolvedSnapshot', () => { + test('is the same for two deployments of the same app, in different accounts and regions', () => { + const a = resolvedSnapshot(deployment('7l66agrnngob', '111111111111', 'us-east-1')); + const b = resolvedSnapshot(deployment('xt2nevv23fvk', '222222222222', 'eu-west-2')); + + expect(a).toEqual(b); + }); + + test('replaces physical ids, the names inside them, and the account, region and partition', () => { + const snapshot = resolvedSnapshot(deployment('7l66agrnngob', '111111111111', 'us-east-1')); + + expect(snapshot.resources.Handler886CB40B.Properties).toEqual({ + Role: 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${HandlerServiceRole}', + Environment: { Variables: { QUEUE_URL: '${JobsQueue1A2B3C4D}', STAGE: 'prod' } }, + }); + expect(snapshot.resources.AlarmQueueDepth.Properties.Dimensions).toEqual([{ Name: 'QueueName', Value: '${JobsQueue1A2B3C4D.Name}' }]); + expect(snapshot.outputs).toEqual({ QueueUrl: { Value: '${JobsQueue1A2B3C4D}' } }); + }); + + test('keeps names the template chooses', () => { + const snapshot = resolvedSnapshot(deployment('7l66agrnngob', '111111111111', 'us-east-1')); + + expect(snapshot.resources.ApiStage.Properties).toEqual({ StageName: 'prod' }); + }); + + test('replaces short ids only when they are the whole value', () => { + const snapshot = resolvedSnapshot(deployment('7l66agrnngob', '111111111111', 'us-east-1')); + + expect(snapshot.resources.ApiMethod.Properties).toEqual({ ResourceId: '${ApiResource}', HttpMethod: 'GET' }); + }); + + test('replaces the generated parts of composite physical ids, but not words', () => { + const snapshot = resolvedSnapshot(deployment('7l66agrnngob', '111111111111', 'us-east-1')); + + expect(snapshot.resources.WebAclAssociation.Properties).toEqual({ + WebACLArn: 'arn:${AWS::Partition}:wafv2:${AWS::Region}:${AWS::AccountId}:regional/webacl/my-acl/${WebAcl[1]}', + Scope: 'REGIONAL', + }); + }); + + test('lists resources with account-wide settings', () => { + const snapshot = resolvedSnapshot(deployment('7l66agrnngob', '111111111111', 'us-east-1')); + + expect(snapshot.accountWide).toEqual(['ApiAccount']); + }); + + test('keeps unresolved expressions, and includes nested stacks', () => { + const parent = deployment('7l66agrnngob', '111111111111', 'us-east-1'); + const nested: EvaluatedStack = { + ...deployment('9p8o7i6u5y4t', '111111111111', 'us-east-1'), + stackName: 'my-stack-Nested-ABC123', + unresolved: [{ path: 'Resources.X.Properties.Y', intrinsic: 'Fn::GetAZs', reason: 'not supported' }], + }; + + const snapshot = resolvedSnapshot({ ...parent, nestedStacks: { NestedStack: nested } }); + + expect(snapshot.nestedStacks?.NestedStack.stackName).toEqual('my-stack-Nested-ABC123'); + expect(snapshot.nestedStacks?.NestedStack.unresolved).toEqual(nested.unresolved); + expect(snapshot.nestedStacks?.NestedStack.resources.Handler886CB40B.Properties.Role) + .toEqual('arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${HandlerServiceRole}'); + }); +});