packages/@aws-cdk/integ-runner/lib/runner/runner-base.ts (243 lines of code) (raw):

/* eslint-disable @cdklabs/no-literal-partition */ import * as path from 'path'; import type { ICdk } from '@aws-cdk/cdk-cli-wrapper'; import { CdkCliWrapper } from '@aws-cdk/cdk-cli-wrapper'; import type { TestCase, DefaultCdkOptions } from '@aws-cdk/cloud-assembly-schema'; import { AVAILABILITY_ZONE_FALLBACK_CONTEXT_KEY, TARGET_PARTITIONS } from '@aws-cdk/cx-api'; import * as fs from 'fs-extra'; import { IntegTestSuite, LegacyIntegTestSuite } from './integ-test-suite'; import type { IntegTest } from './integration-tests'; import * as recommendedFlagsFile from '../recommended-feature-flags.json'; import { flatten } from '../utils'; import type { ManifestTrace } from './private/cloud-assembly'; import { AssemblyManifestReader } from './private/cloud-assembly'; import type { DestructiveChange } from '../workers/common'; const DESTRUCTIVE_CHANGES = '!!DESTRUCTIVE_CHANGES:'; /** * Options for creating an integration test runner */ export interface IntegRunnerOptions { /** * Information about the test to run */ readonly test: IntegTest; /** * The AWS profile to use when invoking the CDK CLI * * @default - no profile is passed, the default profile is used */ readonly profile?: string; /** * Additional environment variables that will be available * to the CDK CLI * * @default - no additional environment variables */ readonly env?: { [name: string]: string }; /** * tmp cdk.out directory * * @default - directory will be `cdk-integ.out.${testName}` */ readonly integOutDir?: string; /** * Instance of the CDK CLI to use * * @default - CdkCliWrapper */ readonly cdk?: ICdk; /** * Show output from running integration tests * * @default false */ readonly showOutput?: boolean; } /** * The different components of a test name */ /** * Represents an Integration test runner */ export abstract class IntegRunner { /** * The directory where the snapshot will be stored */ public readonly snapshotDir: string; /** * An instance of the CDK CLI */ public readonly cdk: ICdk; /** * Pretty name of the test */ public readonly testName: string; /** * The value used in the '--app' CLI parameter * * Path to the integ test source file, relative to `this.directory`. */ protected readonly cdkApp: string; /** * The path where the `cdk.context.json` file * will be created */ protected readonly cdkContextPath: string; /** * The test suite from the existing snapshot */ protected readonly expectedTestSuite?: IntegTestSuite | LegacyIntegTestSuite; /** * The test suite from the new "actual" snapshot */ protected readonly actualTestSuite: IntegTestSuite | LegacyIntegTestSuite; /** * The working directory that the integration tests will be * executed from */ protected readonly directory: string; /** * The test to run */ protected readonly test: IntegTest; /** * Default options to pass to the CDK CLI */ protected readonly defaultArgs: DefaultCdkOptions = { pathMetadata: false, assetMetadata: false, versionReporting: false, }; /** * The directory where the CDK will be synthed to * * Relative to cwd. */ protected readonly cdkOutDir: string; protected readonly profile?: string; protected _destructiveChanges?: DestructiveChange[]; private legacyContext?: Record<string, any>; protected isLegacyTest?: boolean; constructor(options: IntegRunnerOptions) { this.test = options.test; this.directory = this.test.directory; this.testName = this.test.testName; this.snapshotDir = this.test.snapshotDir; this.cdkContextPath = path.join(this.directory, 'cdk.context.json'); this.cdk = options.cdk ?? new CdkCliWrapper({ directory: this.directory, showOutput: options.showOutput, env: { ...options.env, }, }); this.cdkOutDir = options.integOutDir ?? this.test.temporaryOutputDir; const testRunCommand = this.test.appCommand; this.cdkApp = testRunCommand.replace('{filePath}', path.relative(this.directory, this.test.fileName)); this.profile = options.profile; if (this.hasSnapshot()) { this.expectedTestSuite = this.loadManifest(); } this.actualTestSuite = this.generateActualSnapshot(); } /** * Return the list of expected (i.e. existing) test cases for this integration test */ public expectedTests(): { [testName: string]: TestCase } | undefined { return this.expectedTestSuite?.testSuite; } /** * Return the list of actual (i.e. new) test cases for this integration test */ public actualTests(): { [testName: string]: TestCase } | undefined { return this.actualTestSuite.testSuite; } /** * Generate a new "actual" snapshot which will be compared to the * existing "expected" snapshot * This will synth and then load the integration test manifest */ public generateActualSnapshot(): IntegTestSuite | LegacyIntegTestSuite { this.cdk.synthFast({ execCmd: this.cdkApp.split(' '), env: { ...DEFAULT_SYNTH_OPTIONS.env, // we don't know the "actual" context yet (this method is what generates it) so just // use the "expected" context. This is only run in order to read the manifest CDK_CONTEXT_JSON: JSON.stringify(this.getContext(this.expectedTestSuite?.synthContext)), }, output: path.relative(this.directory, this.cdkOutDir), }); const manifest = this.loadManifest(this.cdkOutDir); // after we load the manifest remove the tmp snapshot // so that it doesn't mess up the real snapshot created later this.cleanup(); return manifest; } /** * Returns true if a snapshot already exists for this test */ public hasSnapshot(): boolean { return fs.existsSync(this.snapshotDir); } /** * Load the integ manifest which contains information * on how to execute the tests * First we try and load the manifest from the integ manifest (i.e. integ.json) * from the cloud assembly. If it doesn't exist, then we fallback to the * "legacy mode" and create a manifest from pragma */ protected loadManifest(dir?: string): IntegTestSuite | LegacyIntegTestSuite { try { const testSuite = IntegTestSuite.fromPath(dir ?? this.snapshotDir); return testSuite; } catch { const testCases = LegacyIntegTestSuite.fromLegacy({ cdk: this.cdk, testName: this.test.normalizedTestName, integSourceFilePath: this.test.fileName, listOptions: { ...this.defaultArgs, all: true, app: this.cdkApp, profile: this.profile, output: path.relative(this.directory, this.cdkOutDir), }, }); this.legacyContext = LegacyIntegTestSuite.getPragmaContext(this.test.fileName); this.isLegacyTest = true; return testCases; } } protected cleanup(): void { const cdkOutPath = this.cdkOutDir; if (fs.existsSync(cdkOutPath)) { fs.removeSync(cdkOutPath); } } /** * If there are any destructive changes to a stack then this will record * those in the manifest.json file */ private renderTraceData(): ManifestTrace { const traceData: ManifestTrace = new Map(); const destructiveChanges = this._destructiveChanges ?? []; destructiveChanges.forEach(change => { const trace = traceData.get(change.stackName); if (trace) { trace.set(change.logicalId, `${DESTRUCTIVE_CHANGES} ${change.impact}`); } else { traceData.set(change.stackName, new Map([ [change.logicalId, `${DESTRUCTIVE_CHANGES} ${change.impact}`], ])); } }); return traceData; } /** * In cases where we do not want to retain the assets, * for example, if the assets are very large. * * Since it is possible to disable the update workflow for individual test * cases, this needs to first get a list of stacks that have the update workflow * disabled and then delete assets that relate to that stack. It does that * by reading the asset manifest for the stack and deleting the asset source */ protected removeAssetsFromSnapshot(): void { const stacks = this.actualTestSuite.getStacksWithoutUpdateWorkflow() ?? []; const manifest = AssemblyManifestReader.fromPath(this.snapshotDir); const assets = flatten(stacks.map(stack => { return manifest.getAssetLocationsForStack(stack) ?? []; })); assets.forEach(asset => { const fileName = path.join(this.snapshotDir, asset); if (fs.existsSync(fileName)) { if (fs.lstatSync(fileName).isDirectory()) { fs.removeSync(fileName); } else { fs.unlinkSync(fileName); } } }); } /** * Remove the asset cache (.cache/) files from the snapshot. * These are a cache of the asset zips, but we are fine with * re-zipping on deploy */ protected removeAssetsCacheFromSnapshot(): void { const files = fs.readdirSync(this.snapshotDir); files.forEach(file => { const fileName = path.join(this.snapshotDir, file); if (fs.lstatSync(fileName).isDirectory() && file === '.cache') { fs.emptyDirSync(fileName); fs.rmdirSync(fileName); } }); } /** * Create the new snapshot. * * If lookups are enabled, then we need create the snapshot by synthing again * with the dummy context so that each time the test is run on different machines * (and with different context/env) the diff will not change. * * If lookups are disabled (which means the stack is env agnostic) then just copy * the assembly that was output by the deployment */ protected createSnapshot(): void { if (fs.existsSync(this.snapshotDir)) { fs.removeSync(this.snapshotDir); } // if lookups are enabled then we need to synth again // using dummy context and save that as the snapshot if (this.actualTestSuite.enableLookups) { this.cdk.synthFast({ execCmd: this.cdkApp.split(' '), env: { ...DEFAULT_SYNTH_OPTIONS.env, CDK_CONTEXT_JSON: JSON.stringify(this.getContext(DEFAULT_SYNTH_OPTIONS.context)), }, output: path.relative(this.directory, this.snapshotDir), }); } else { fs.moveSync(this.cdkOutDir, this.snapshotDir, { overwrite: true }); } this.cleanupSnapshot(); } /** * Perform some cleanup steps after the snapshot is created * Anytime the snapshot needs to be modified after creation * the logic should live here. */ private cleanupSnapshot(): void { if (fs.existsSync(this.snapshotDir)) { this.removeAssetsFromSnapshot(); this.removeAssetsCacheFromSnapshot(); const assembly = AssemblyManifestReader.fromPath(this.snapshotDir); assembly.cleanManifest(); assembly.recordTrace(this.renderTraceData()); } // if this is a legacy test then create an integ manifest // in the snapshot directory which can be used for the // update workflow. Save any legacyContext as well so that it can be read // the next time if (this.actualTestSuite.type === 'legacy-test-suite') { (this.actualTestSuite as LegacyIntegTestSuite).saveManifest(this.snapshotDir, this.legacyContext); } } protected getContext(additionalContext?: Record<string, any>): Record<string, any> { return { ...currentlyRecommendedAwsCdkLibFlags(), ...this.legacyContext, ...additionalContext, // We originally had PLANNED to set this to ['aws', 'aws-cn'], but due to a programming mistake // it was set to everything. In this PR, set it to everything to not mess up all the snapshots. [TARGET_PARTITIONS]: undefined, /* ---------------- THE FUTURE LIVES BELOW---------------------------- // Restricting to these target partitions makes most service principals synthesize to // `service.${URL_SUFFIX}`, which is technically *incorrect* (it's only `amazonaws.com` // or `amazonaws.com.cn`, never UrlSuffix for any of the restricted regions) but it's what // most existing integ tests contain, and we want to disturb as few as possible. // [TARGET_PARTITIONS]: ['aws', 'aws-cn'], /* ---------------- END OF THE FUTURE ------------------------------- */ }; } } // Default context we run all integ tests with, so they don't depend on the // account of the exercising user. export const DEFAULT_SYNTH_OPTIONS = { context: { [AVAILABILITY_ZONE_FALLBACK_CONTEXT_KEY]: ['test-region-1a', 'test-region-1b', 'test-region-1c'], 'availability-zones:account=12345678:region=test-region': ['test-region-1a', 'test-region-1b', 'test-region-1c'], 'ssm:account=12345678:parameterName=/aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-gp2:region=test-region': 'ami-1234', 'ssm:account=12345678:parameterName=/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2:region=test-region': 'ami-1234', 'ssm:account=12345678:parameterName=/aws/service/ecs/optimized-ami/amazon-linux/recommended:region=test-region': '{"image_id": "ami-1234"}', // eslint-disable-next-line max-len 'ami:account=12345678:filters.image-type.0=machine:filters.name.0=amzn-ami-vpc-nat-*:filters.state.0=available:owners.0=amazon:region=test-region': 'ami-1234', 'vpc-provider:account=12345678:filter.isDefault=true:region=test-region:returnAsymmetricSubnets=true': { vpcId: 'vpc-60900905', subnetGroups: [ { type: 'Public', name: 'Public', subnets: [ { subnetId: 'subnet-e19455ca', availabilityZone: 'us-east-1a', routeTableId: 'rtb-e19455ca', }, { subnetId: 'subnet-e0c24797', availabilityZone: 'us-east-1b', routeTableId: 'rtb-e0c24797', }, { subnetId: 'subnet-ccd77395', availabilityZone: 'us-east-1c', routeTableId: 'rtb-ccd77395', }, ], }, ], }, }, env: { CDK_INTEG_ACCOUNT: '12345678', CDK_INTEG_REGION: 'test-region', CDK_INTEG_HOSTED_ZONE_ID: 'Z23ABC4XYZL05B', CDK_INTEG_HOSTED_ZONE_NAME: 'example.com', CDK_INTEG_DOMAIN_NAME: '*.example.com', CDK_INTEG_CERT_ARN: 'arn:aws:acm:test-region:12345678:certificate/86468209-a272-595d-b831-0efb6421265z', CDK_INTEG_SUBNET_ID: 'subnet-0dff1a399d8f6f92c', }, }; /** * Return the currently recommended flags for `aws-cdk-lib`. * * These have been built into the CLI at build time. If this ever gets changed * back to a dynamic load, remember that this source file may be bundled into * a JavaScript bundle, and `__dirname` might not point where you think it does. */ export function currentlyRecommendedAwsCdkLibFlags() { return recommendedFlagsFile; }