packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts (557 lines of code) (raw):
/* eslint-disable no-console */
import * as assert from 'assert';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Stack } from '@aws-sdk/client-cloudformation';
import { DescribeStacksCommand } from '@aws-sdk/client-cloudformation';
import { GetAuthorizationTokenCommand } from '@aws-sdk/client-ecr-public';
import type { AwsClients } from './aws';
import { outputFromStack, sleep } from './aws';
import type { TestContext } from './integ-test';
import { findYarnPackages } from './package-sources/repo-source';
import type { IPackageSource } from './package-sources/source';
import { packageSourceInSubprocess } from './package-sources/subprocess';
import { RESOURCES_DIR } from './resources';
import type { ShellOptions } from './shell';
import { shell, ShellHelper, rimraf } from './shell';
import type { AwsContext } from './with-aws';
import { atmosphereEnabled, withAws } from './with-aws';
import { withTimeout } from './with-timeout';
export const DEFAULT_TEST_TIMEOUT_S = 20 * 60;
export const EXTENDED_TEST_TIMEOUT_S = 30 * 60;
/**
* Higher order function to execute a block with a CDK app fixture
*
* Requires an AWS client to be passed in.
*
* For backwards compatibility with existing tests (so we don't have to change
* too much) the inner block is expected to take a `TestFixture` object.
*/
export function withSpecificCdkApp(
appName: string,
block: (context: TestFixture) => Promise<void>,
): (context: TestContext & AwsContext & DisableBootstrapContext) => Promise<void> {
return async (context: TestContext & AwsContext & DisableBootstrapContext) => {
const randy = context.randomString;
const stackNamePrefix = `cdktest-${randy}`;
const integTestDir = path.join(os.tmpdir(), `cdk-integ-${randy}`);
context.output.write(` Stack prefix: ${stackNamePrefix}\n`);
context.output.write(` Test directory: ${integTestDir}\n`);
context.output.write(` Region: ${context.aws.region}\n`);
await cloneDirectory(path.join(RESOURCES_DIR, 'cdk-apps', appName), integTestDir, context.output);
const fixture = new TestFixture(
integTestDir,
stackNamePrefix,
context.output,
context.aws,
context.randomString);
await fixture.ecrPublicLogin();
let success = true;
try {
const installationVersion = fixture.packages.requestedFrameworkVersion();
if (fixture.packages.majorVersion() === '1') {
await installNpmPackages(fixture, {
'@aws-cdk/core': installationVersion,
'@aws-cdk/aws-sns': installationVersion,
'@aws-cdk/aws-sqs': installationVersion,
'@aws-cdk/aws-iam': installationVersion,
'@aws-cdk/aws-lambda': installationVersion,
'@aws-cdk/aws-ssm': installationVersion,
'@aws-cdk/aws-ecr-assets': installationVersion,
'@aws-cdk/aws-cloudformation': installationVersion,
'@aws-cdk/aws-ec2': installationVersion,
'@aws-cdk/aws-s3': installationVersion,
'constructs': '^3',
});
} else {
await installNpmPackages(fixture, {
'aws-cdk-lib': installationVersion,
'constructs': '^10',
});
}
if (!context.disableBootstrap) {
await ensureBootstrapped(fixture);
}
await block(fixture);
} catch (e) {
success = false;
throw e;
} finally {
if (process.env.INTEG_NO_CLEAN) {
context.log(`Left test directory in '${integTestDir}' ($INTEG_NO_CLEAN)\n`);
} else {
await fixture.dispose(success);
}
}
};
}
/**
* Like `withSpecificCdkApp`, but uses the default integration testing app with a million stacks in it
*/
export function withCdkApp(
block: (context: TestFixture) => Promise<void>,
): (context: TestContext & AwsContext & DisableBootstrapContext) => Promise<void> {
// 'app' is the name of the default integration app in the `cdk-apps` directory
return withSpecificCdkApp('app', block);
}
export function withCdkMigrateApp(
language: string,
block: (context: TestFixture) => Promise<void>,
): (context: TestContext & AwsContext & DisableBootstrapContext) => Promise<void> {
return async (context: TestContext & AwsContext & DisableBootstrapContext) => {
const stackName = `cdk-migrate-${language}-integ-${context.randomString}`;
const integTestDir = path.join(os.tmpdir(), `cdk-migrate-${language}-integ-${context.randomString}`);
context.output.write(` Stack name: ${stackName}\n`);
context.output.write(` Test directory: ${integTestDir}\n`);
fs.mkdirSync(integTestDir);
const fixture = new TestFixture(
integTestDir,
stackName,
context.output,
context.aws,
context.randomString,
);
await fixture.ecrPublicLogin();
await ensureBootstrapped(fixture);
await fixture.cdkMigrate(language, stackName);
const testFixture = new TestFixture(
path.join(integTestDir, stackName),
stackName,
context.output,
context.aws,
context.randomString,
);
let success = true;
try {
await block(testFixture);
} catch (e) {
success = false;
throw e;
} finally {
if (process.env.INTEG_NO_CLEAN) {
context.log(`Left test directory in '${integTestDir}' ($INTEG_NO_CLEAN)`);
} else {
await fixture.dispose(success);
}
}
};
}
/**
* Default test fixture for most (all?) integ tests
*
* It's a composition of withAws/withCdkApp, expecting the test block to take a `TestFixture`
* object.
*
* We could have put `withAws(withCdkApp(fixture => { /... actual test here.../ }))` in every
* test declaration but centralizing it is going to make it convenient to modify in the future.
*/
export function withDefaultFixture(block: (context: TestFixture) => Promise<void>) {
return withAws(withTimeout(DEFAULT_TEST_TIMEOUT_S, withCdkApp(block)));
}
export function withSpecificFixture(appName: string, block: (context: TestFixture) => Promise<void>) {
return withAws(withTimeout(DEFAULT_TEST_TIMEOUT_S, withSpecificCdkApp(appName, block)));
}
export function withExtendedTimeoutFixture(block: (context: TestFixture) => Promise<void>) {
return withAws(withTimeout(EXTENDED_TEST_TIMEOUT_S, withCdkApp(block)));
}
export function withCDKMigrateFixture(language: string, block: (content: TestFixture) => Promise<void>) {
return withAws(withTimeout(DEFAULT_TEST_TIMEOUT_S, withCdkMigrateApp(language, block)));
}
export interface DisableBootstrapContext {
/**
* Whether to disable creating the default bootstrap
* stack prior to running the test
*
* This should be set to true when running tests that
* explicitly create a bootstrap stack
*
* @default false
*/
readonly disableBootstrap?: boolean;
}
/**
* To be used in place of `withDefaultFixture` when the test
* should not create the default bootstrap stack
*/
export function withoutBootstrap(block: (context: TestFixture) => Promise<void>) {
return withAws(withCdkApp(block), true);
}
export interface CdkCliOptions extends ShellOptions {
options?: string[];
neverRequireApproval?: boolean;
verbose?: boolean;
}
export interface CdkDestroyCliOptions extends CdkCliOptions {
readonly force?: boolean;
}
/**
* Prepare a target dir byreplicating a source directory
*/
export async function cloneDirectory(source: string, target: string, output?: NodeJS.WritableStream) {
await shell(['rm', '-rf', target], { outputs: output ? [output] : [] });
await shell(['mkdir', '-p', target], { outputs: output ? [output] : [] });
await shell(['cp', '-R', source + '/*', target], { outputs: output ? [output] : [] });
}
interface CommonCdkBootstrapCommandOptions {
/**
* Path to a custom bootstrap template.
*
* @default - the default CDK bootstrap template.
*/
readonly bootstrapTemplate?: string;
readonly toolkitStackName: string;
/**
* @default false
*/
readonly verbose?: boolean;
/**
* @default - auto-generated CloudFormation name
*/
readonly bootstrapBucketName?: string;
readonly cliOptions?: CdkCliOptions;
/**
* @default - none
*/
readonly tags?: string;
/**
* @default - the default CDK qualifier
*/
readonly qualifier?: string;
}
export interface CdkLegacyBootstrapCommandOptions extends CommonCdkBootstrapCommandOptions {
/**
* @default false
*/
readonly noExecute?: boolean;
/**
* @default true
*/
readonly publicAccessBlockConfiguration?: boolean;
}
export interface CdkModernBootstrapCommandOptions extends CommonCdkBootstrapCommandOptions {
/**
* @default false
*/
readonly force?: boolean;
/**
* @default - none
*/
readonly cfnExecutionPolicy?: string;
/**
* @default false
*/
readonly showTemplate?: boolean;
readonly template?: string;
/**
* @default false
*/
readonly terminationProtection?: boolean;
/**
* @default undefined
*/
readonly examplePermissionsBoundary?: boolean;
/**
* @default undefined
*/
readonly customPermissionsBoundary?: string;
/**
* @default undefined
*/
readonly usePreviousParameters?: boolean;
readonly trust?: string[];
readonly untrust?: string[];
}
export interface CdkGarbageCollectionCommandOptions {
/**
* The amount of days an asset should stay isolated before deletion, to
* guard against some pipeline rollback scenarios
*
* @default 0
*/
readonly rollbackBufferDays?: number;
/**
* The type of asset that is getting garbage collected.
*
* @default 'all'
*/
readonly type?: 'ecr' | 's3' | 'all';
/**
* The name of the bootstrap stack
*
* @default 'CdkToolkit'
*/
readonly bootstrapStackName?: string;
}
export class TestFixture extends ShellHelper {
public readonly qualifier: string;
private readonly bucketsToDelete = new Array<string>();
public readonly packages: IPackageSource;
constructor(
public readonly integTestDir: string,
public readonly stackNamePrefix: string,
public readonly output: NodeJS.WritableStream,
public readonly aws: AwsClients,
public readonly randomString: string) {
super(integTestDir, output);
this.qualifier = this.randomString.slice(0, 10);
this.packages = packageSourceInSubprocess();
}
public log(s: string) {
this.output.write(`${s}\n`);
}
/**
* Login to the public ECR gallery using the current AWS credentials.
* Use this if your test needs to directly pull images outside of a `cdk` or `cdk-assets` command.
*/
public async ecrPublicLogin() {
const tokenResponse = await this.aws.ecrPublic.send(new GetAuthorizationTokenCommand({}));
const authData = tokenResponse.authorizationData?.authorizationToken;
const docker = process.env.CDK_DOCKER ?? 'docker';
if (!authData) {
throw new Error('Could not retrieve ECR public auth token.');
}
const decoded = Buffer.from(authData, 'base64').toString('utf-8');
const [username, password] = decoded.split(':');
await this.shell([docker, 'login',
'--username', username,
'--password', '${ECR_PASSWORD}',
'public.ecr.aws'], {
shell: true,
modEnv: {
ECR_PASSWORD: password,
},
});
}
public async cdkDeploy(stackNames: string | string[], options: CdkCliOptions = {}, skipStackRename?: boolean) {
return this.cdk(this.cdkDeployCommandLine(stackNames, options, skipStackRename), options);
}
public cdkDeployCommandLine(stackNames: string | string[], options: CdkCliOptions = {}, skipStackRename?: boolean) {
stackNames = typeof stackNames === 'string' ? [stackNames] : stackNames;
const neverRequireApproval = options.neverRequireApproval ?? true;
return [
'deploy',
...(neverRequireApproval ? ['--require-approval=never'] : []), // Default to no approval in an unattended test
...(options.options ?? []),
// use events because bar renders bad in tests
'--progress', 'events',
...(skipStackRename ? stackNames : this.fullStackName(stackNames)),
];
}
public async cdkSynth(options: CdkCliOptions = {}) {
return this.cdk([
'synth',
...(options.options ?? []),
], options);
}
public async cdkRefactor(options: CdkCliOptions = {}) {
return this.cdk([
'refactor',
...(options.options ?? []),
], options);
}
public async cdkDestroy(stackNames: string | string[], options: CdkDestroyCliOptions = {}) {
stackNames = typeof stackNames === 'string' ? [stackNames] : stackNames;
// default to true because most tests don't test user interaction
const force = options.force ?? true;
return this.cdk(['destroy',
...(force ? ['-f'] : []), // pass -f if user interaction is not desired
...(options.options ?? []),
...this.fullStackName(stackNames)], options);
}
public async cdkBootstrapLegacy(options: CdkLegacyBootstrapCommandOptions): Promise<string> {
const args = ['bootstrap'];
if (options.verbose) {
args.push('-v');
}
args.push('--toolkit-stack-name', options.toolkitStackName);
if (options.bootstrapBucketName) {
args.push('--bootstrap-bucket-name', options.bootstrapBucketName);
}
if (options.noExecute) {
args.push('--no-execute');
}
if (options.publicAccessBlockConfiguration !== undefined) {
args.push('--public-access-block-configuration', options.publicAccessBlockConfiguration.toString());
}
if (options.tags) {
args.push('--tags', options.tags);
}
return this.cdk(args, {
...options.cliOptions,
modEnv: {
...options.cliOptions?.modEnv,
// so that this works for V2,
// where the "new" bootstrap is the default
CDK_LEGACY_BOOTSTRAP: '1',
},
});
}
public async cdkBootstrapModern(options: CdkModernBootstrapCommandOptions): Promise<string> {
const args = ['bootstrap'];
if (options.verbose) {
args.push('-v');
}
if (options.showTemplate) {
args.push('--show-template');
}
if (options.template) {
args.push('--template', options.template);
}
args.push('--toolkit-stack-name', options.toolkitStackName);
if (options.bootstrapBucketName) {
args.push('--bootstrap-bucket-name', options.bootstrapBucketName);
}
args.push('--qualifier', options.qualifier ?? this.qualifier);
if (options.cfnExecutionPolicy) {
args.push('--cloudformation-execution-policies', options.cfnExecutionPolicy);
}
if (options.terminationProtection !== undefined) {
args.push('--termination-protection', options.terminationProtection.toString());
}
if (options.force) {
args.push('--force');
}
if (options.tags) {
args.push('--tags', options.tags);
}
if (options.customPermissionsBoundary !== undefined) {
args.push('--custom-permissions-boundary', options.customPermissionsBoundary);
} else if (options.examplePermissionsBoundary !== undefined) {
args.push('--example-permissions-boundary');
}
if (options.usePreviousParameters === false) {
args.push('--no-previous-parameters');
}
if (options.bootstrapTemplate) {
args.push('--template', options.bootstrapTemplate);
}
if (options.trust != null) {
args.push('--trust', options.trust.join(','));
}
if (options.untrust != null) {
args.push('--untrust', options.untrust.join(','));
}
return this.cdk(args, {
...options.cliOptions,
modEnv: {
...options.cliOptions?.modEnv,
// so that this works for V1,
// where the "old" bootstrap is the default
CDK_NEW_BOOTSTRAP: '1',
},
});
}
public async cdkGarbageCollect(options: CdkGarbageCollectionCommandOptions): Promise<string> {
const args = [
'gc',
'--unstable=gc', // TODO: remove when stabilizing
'--confirm=false',
'--created-buffer-days=0', // Otherwise all assets created during integ tests are too young
];
if (options.rollbackBufferDays) {
args.push('--rollback-buffer-days', String(options.rollbackBufferDays));
}
if (options.type) {
args.push('--type', options.type);
}
if (options.bootstrapStackName) {
args.push('--bootstrapStackName', options.bootstrapStackName);
}
return this.cdk(args);
}
public async cdkMigrate(language: string, stackName: string, inputPath?: string, options?: CdkCliOptions) {
return this.cdk([
'migrate',
'--language',
language,
'--stack-name',
stackName,
'--from-path',
inputPath ?? path.join(__dirname, '..', 'resources', 'templates', 'sqs-template.json').toString(),
...(options?.options ?? []),
], options);
}
public async cdk(args: string[], options: CdkCliOptions = {}) {
const verbose = options.verbose ?? true;
await this.packages.makeCliAvailable();
return this.shell(['cdk', ...(verbose ? ['-v'] : []), ...args], {
...options,
modEnv: {
...this.cdkShellEnv(),
...options.modEnv,
},
});
}
/**
* Return the environment variables with which to execute CDK
*/
public cdkShellEnv() {
// if tests are using an explicit aws identity already (i.e creds)
// force every cdk command to use the same identity.
const awsCreds = this.aws.identityEnv() ?? {};
return {
AWS_REGION: this.aws.region,
AWS_DEFAULT_REGION: this.aws.region,
STACK_NAME_PREFIX: this.stackNamePrefix,
PACKAGE_LAYOUT_VERSION: this.packages.majorVersion(),
// In these tests we want to make a distinction between stdout and sterr
CI: 'false',
...awsCreds,
};
}
public template(stackName: string): any {
const fullStackName = this.fullStackName(stackName);
const templatePath = path.join(this.integTestDir, 'cdk.out', `${fullStackName}.template.json`);
return JSON.parse(fs.readFileSync(templatePath, { encoding: 'utf-8' }).toString());
}
public async bootstrapRepoName(): Promise<string> {
await ensureBootstrapped(this);
const response = await this.aws.cloudFormation.send(new DescribeStacksCommand({}));
const stack = (response.Stacks ?? [])
.filter((s) => s.StackName && s.StackName == this.bootstrapStackName);
assert(stack.length == 1);
return outputFromStack('ImageRepositoryName', stack[0]) ?? '';
}
public get bootstrapStackName() {
return this.fullStackName('bootstrap-stack');
}
public fullStackName(stackName: string): string;
public fullStackName(stackNames: string[]): string[];
public fullStackName(stackNames: string | string[]): string | string[] {
if (typeof stackNames === 'string') {
return `${this.stackNamePrefix}-${stackNames}`;
} else {
return stackNames.map(s => `${this.stackNamePrefix}-${s}`);
}
}
/**
* Append this to the list of buckets to potentially delete
*
* At the end of a test, we clean up buckets that may not have gotten destroyed
* (for whatever reason).
*/
public rememberToDeleteBucket(bucketName: string) {
this.bucketsToDelete.push(bucketName);
}
/**
* Cleanup leftover stacks and bootstrapped resources
*/
public async dispose(success: boolean) {
// when using the atmosphere service, it does resource cleanup on our behalf
// so we don't have to wait for it.
if (!atmosphereEnabled()) {
const stacksToDelete = await this.deleteableStacks(this.stackNamePrefix);
this.sortBootstrapStacksToTheEnd(stacksToDelete);
// Bootstrap stacks have buckets that need to be cleaned
const bucketNames = stacksToDelete.map(stack => outputFromStack('BucketName', stack)).filter(defined);
// Parallelism will be reasonable
// eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism
await Promise.all(bucketNames.map(b => this.aws.emptyBucket(b)));
// The bootstrap bucket has a removal policy of RETAIN by default, so add it to the buckets to be cleaned up.
this.bucketsToDelete.push(...bucketNames);
// Bootstrap stacks have ECR repositories with images which should be deleted
const imageRepositoryNames = stacksToDelete.map(stack => outputFromStack('ImageRepositoryName', stack)).filter(defined);
// Parallelism will be reasonable
// eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism
await Promise.all(imageRepositoryNames.map(r => this.aws.deleteImageRepository(r)));
await this.aws.deleteStacks(
...stacksToDelete.map((s) => {
if (!s.StackName) {
throw new Error('Stack name is required to delete a stack.');
}
return s.StackName;
}),
);
// We might have leaked some buckets by upgrading the bootstrap stack. Be
// sure to clean everything.
for (const bucket of this.bucketsToDelete) {
await this.aws.deleteBucket(bucket);
}
}
// If the tests completed successfully, happily delete the fixture
// (otherwise leave it for humans to inspect)
if (success) {
const cleaned = rimraf(this.integTestDir);
if (!cleaned) {
console.error(`Failed to clean up ${this.integTestDir} due to permissions issues (Docker running as root?)`);
}
}
}
/**
* Return the stacks starting with our testing prefix that should be deleted
*/
private async deleteableStacks(prefix: string): Promise<Stack[]> {
const statusFilter = [
'CREATE_IN_PROGRESS', 'CREATE_FAILED', 'CREATE_COMPLETE',
'ROLLBACK_IN_PROGRESS', 'ROLLBACK_FAILED', 'ROLLBACK_COMPLETE',
'DELETE_FAILED',
'UPDATE_IN_PROGRESS', 'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS',
'UPDATE_COMPLETE', 'UPDATE_ROLLBACK_IN_PROGRESS',
'UPDATE_ROLLBACK_FAILED',
'UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS',
'UPDATE_ROLLBACK_COMPLETE', 'REVIEW_IN_PROGRESS',
'IMPORT_IN_PROGRESS', 'IMPORT_COMPLETE',
'IMPORT_ROLLBACK_IN_PROGRESS', 'IMPORT_ROLLBACK_FAILED',
'IMPORT_ROLLBACK_COMPLETE',
];
const response = await this.aws.cloudFormation.send(new DescribeStacksCommand({}));
return (response.Stacks ?? [])
.filter((s) => s.StackName && s.StackName.startsWith(prefix))
.filter((s) => s.StackStatus && statusFilter.includes(s.StackStatus))
.filter((s) => s.RootId === undefined); // Only delete parent stacks. Nested stacks are deleted in the process
}
private sortBootstrapStacksToTheEnd(stacks: Stack[]) {
stacks.sort((a, b) => {
if (!a.StackName || !b.StackName) {
throw new Error('Stack names do not exists. These are required for sorting the bootstrap stacks.');
}
const aBs = a.StackName.startsWith(this.bootstrapStackName);
const bBs = b.StackName.startsWith(this.bootstrapStackName);
return aBs != bBs
// '+' converts a boolean to 0 or 1
? (+aBs) - (+bBs)
: a.StackName.localeCompare(b.StackName);
});
}
}
/**
* Make sure that the given environment is bootstrapped
*
* Since we go striping across regions, it's going to suck doing this
* by hand so let's just mass-automate it.
*/
export async function ensureBootstrapped(fixture: TestFixture) {
// Always use the modern bootstrap stack, otherwise we may get the error
// "refusing to downgrade from version 7 to version 0" when bootstrapping with default
// settings using a v1 CLI.
//
// It doesn't matter for tests: when they want to test something about an actual legacy
// bootstrap stack, they'll create a bootstrap stack with a non-default name to test that exact property.
const envSpecifier = `aws://${await fixture.aws.account()}/${fixture.aws.region}`;
if (ALREADY_BOOTSTRAPPED_IN_THIS_RUN.has(envSpecifier)) {
return;
}
if (atmosphereEnabled()) {
// when atmosphere is enabled, each test starts with an empty environment
// and needs to deploy the bootstrap stack. in case environments are recylced too quickly,
// cloudformation may think the bootstrap bucket still exists even though it doesnt (because of s3 eventual consistency).
// so we retry on the specific error for a while.
await bootstrapWithRetryOnBucketExists(envSpecifier, fixture);
} else {
await doBootstrap(envSpecifier, fixture, false);
}
// when using the atmosphere service, every test needs to bootstrap
// its own environment.
if (!atmosphereEnabled()) {
ALREADY_BOOTSTRAPPED_IN_THIS_RUN.add(envSpecifier);
}
}
async function doBootstrap(envSpecifier: string, fixture: TestFixture, allowErrExit: boolean) {
return fixture.cdk(['bootstrap', envSpecifier], {
modEnv: {
// Even for v1, use new bootstrap
CDK_NEW_BOOTSTRAP: '1',
// when allowing error exit, we probably want to inspect
// and compare output, which is better done without color characters.
...(allowErrExit ? { FORCE_COLOR: '0' } : {}),
},
allowErrExit,
});
}
async function bootstrapWithRetryOnBucketExists(envSpecifier: string, fixture: TestFixture) {
const account = await fixture.aws.account();
const retryAfterSeconds = 30;
const bootstrapBucket = `cdk-hnb659fds-assets-${account}-${fixture.aws.region}`;
// s3 says that a bucket deletion can take up to an hour to be fully visible.
// empirically we see that a few minutes is enough though. lets give 10 to be on the safe(r) side.
const timeoutMinutes = 10;
const timeoutDate = new Date(Date.now() + timeoutMinutes * 60 * 1000);
while (true) {
const out = await doBootstrap(envSpecifier, fixture, true);
if (out.includes(`Environment ${envSpecifier} bootstrapped`)) {
break;
}
if (out.includes(`${bootstrapBucket} already exists`)) {
// might be an s3 eventualy consistency issue due to recycled environments.
if (Date.now() < timeoutDate.getTime()) {
fixture.log(`Bootstrap of ${envSpecifier} failed due to bucket existence check. Retrying in ${retryAfterSeconds} seconds...`);
await sleep(retryAfterSeconds * 1000);
continue;
}
}
throw new Error(`Failed bootstrapping ${envSpecifier}`);
}
}
function defined<A>(x: A): x is NonNullable<A> {
return x !== undefined;
}
/**
* Install the given NPM packages, identified by their names and versions
*
* Works by writing the packages to a `package.json` file, and
* then running NPM7's "install" on it. The use of NPM7 will automatically
* install required peerDependencies.
*
* If we're running in REPO mode and we find the package in the set of local
* packages in the repository, we'll write the directory name to `package.json`
* so that NPM will create a symlink (this allows running tests against
* built-but-unpackaged modules, and saves dev cycle time).
*
* Be aware you MUST install all the packages you directly depend upon! In the case
* of a repo/symlinking install, transitive dependencies WILL NOT be installed in the
* current directory's `node_modules` directory, because they will already have been
* symlinked from the TARGET directory's `node_modules` directory (which is sufficient
* for Node's dependency lookup mechanism).
*/
export async function installNpmPackages(fixture: TestFixture, packages: Record<string, string>) {
if (process.env.REPO_ROOT) {
const monoRepo = await findYarnPackages(process.env.REPO_ROOT);
// Replace the install target with the physical location of this package
for (const key of Object.keys(packages)) {
if (key in monoRepo) {
packages[key] = monoRepo[key];
}
}
}
fs.writeFileSync(path.join(fixture.integTestDir, 'package.json'), JSON.stringify({
name: 'cdk-integ-tests',
private: true,
version: '0.0.1',
devDependencies: packages,
}, undefined, 2), { encoding: 'utf-8' });
// we often ECONNRESET from NPM so lets retry. this might be because of high concurrency
// which overwhelmes system resources.
const timeoutMinutes = 10;
const timeoutDate = new Date(Date.now() + timeoutMinutes * 60 * 1000);
const retryAfterSeconds = 30;
while (true) {
try {
// Now install that `package.json` using NPM7
await fixture.shell(['node', require.resolve('npm'), 'install']);
break;
} catch (e: any) {
if (Date.now() < timeoutDate.getTime() && fixture.output.toString().includes('ECONNRESET' )) {
fixture.log(`npm install failed due to ECONNRESET. Retrying in ${retryAfterSeconds} seconds...`);
await sleep(retryAfterSeconds * 1000);
continue;
}
throw e;
}
}
}
const ALREADY_BOOTSTRAPPED_IN_THIS_RUN = new Set();