packages/@aws-cdk-testing/cli-integ/lib/with-sam.ts (218 lines of code) (raw):
import * as child_process from 'child_process';
import * as os from 'os';
import * as path from 'path';
import axios from 'axios';
import type { TestContext } from './integ-test';
import { RESOURCES_DIR } from './resources';
import type { ShellOptions } from './shell';
import { rimraf } from './shell';
import type { AwsContext } from './with-aws';
import { withAws } from './with-aws';
import { cloneDirectory, installNpmPackages, TestFixture, DEFAULT_TEST_TIMEOUT_S } from './with-cdk-app';
import { withTimeout } from './with-timeout';
export interface ActionOutput {
actionSucceeded?: boolean;
actionOutput?: any;
shellOutput?: string;
}
/**
* Higher order function to execute a block with a SAM Integration CDK app fixture
*/
export function withSamIntegrationCdkApp<A extends TestContext & AwsContext>(block: (context: SamIntegrationTestFixture) => Promise<void>) {
return async (context: A) => {
const randy = context.randomString;
const stackNamePrefix = `cdktest-${randy}`;
const integTestDir = path.join(os.tmpdir(), `cdk-integ-${randy}`);
context.log(` Stack prefix: ${stackNamePrefix}\n`);
context.log(` Test directory: ${integTestDir}\n`);
context.log(` Region: ${context.aws.region}\n`);
await cloneDirectory(path.join(RESOURCES_DIR, 'cdk-apps', 'sam_cdk_integ_app'), integTestDir, context.output);
const fixture = new SamIntegrationTestFixture(
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/aws-iam': installationVersion,
'@aws-cdk/aws-apigateway': installationVersion,
'@aws-cdk/aws-lambda': installationVersion,
'@aws-cdk/aws-lambda-go': installationVersion,
'@aws-cdk/aws-lambda-nodejs': installationVersion,
'@aws-cdk/aws-lambda-python': installationVersion,
'@aws-cdk/aws-logs': installationVersion,
'@aws-cdk/core': installationVersion,
'constructs': '^3',
});
} else {
const alphaInstallationVersion = fixture.packages.requestedAlphaVersion();
await installNpmPackages(fixture, {
'aws-cdk-lib': installationVersion,
'@aws-cdk/aws-lambda-go-alpha': alphaInstallationVersion,
'@aws-cdk/aws-lambda-python-alpha': alphaInstallationVersion,
'constructs': '^10',
});
}
await block(fixture);
} catch (e: any) {
// We survive certain cases involving gopkg.in
if (errorCausedByGoPkg(e.message)) {
return;
}
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);
}
}
};
}
/**
* Return whether or not the error is being caused by gopkg.in being down
*
* Our Go build depends on https://gopkg.in/, which has errors pretty often
* (every couple of days). It is run by a single volunteer.
*/
function errorCausedByGoPkg(error: string) {
// The error is different depending on what request fails. Messages recognized:
////////////////////////////////////////////////////////////////////
// go: github.com/aws/aws-lambda-go@v1.28.0 requires
// gopkg.in/yaml.v3@v3.0.0-20200615113413-eeeca48fe776: invalid version: git ls-remote -q origin in /go/pkg/mod/cache/vcs/0901dc1ef67fcce1c9b3ae51078740de4a0e2dc673e720584ac302973af82f36: exit status 128:
// remote: Cannot obtain refs from GitHub: cannot talk to GitHub: Get https://github.com/go-yaml/yaml.git/info/refs?service=git-upload-pack: net/http: request canceled (Client.Timeout exceeded while awaiting headers)
// fatal: unable to access 'https://gopkg.in/yaml.v3/': The requested URL returned error: 502
////////////////////////////////////////////////////////////////////
// go: downloading github.com/aws/aws-lambda-go v1.28.0
// go: github.com/aws/aws-lambda-go@v1.28.0 requires
// gopkg.in/yaml.v3@v3.0.0-20200615113413-eeeca48fe776: unrecognized import path "gopkg.in/yaml.v3": reading https://gopkg.in/yaml.v3?go-get=1: 502 Bad Gateway
// server response: Cannot obtain refs from GitHub: cannot talk to GitHub: Get https://github.com/go-yaml/yaml.git/info/refs?service=git-upload-pack: net/http: request canceled (Client.Timeout exceeded while awaiting headers)
////////////////////////////////////////////////////////////////////
// go: github.com/aws/aws-lambda-go@v1.28.0 requires
// gopkg.in/yaml.v3@v3.0.0-20200615113413-eeeca48fe776: invalid version: git fetch -f origin refs/heads/*:refs/heads/* refs/tags/*:refs/tags/* in /go/pkg/mod/cache/vcs/0901dc1ef67fcce1c9b3ae51078740de4a0e2dc673e720584ac302973af82f36: exit status 128:
// error: RPC failed; HTTP 502 curl 22 The requested URL returned error: 502
// fatal: the remote end hung up unexpectedly
////////////////////////////////////////////////////////////////////
return (error.includes('gopkg\.in.*invalid version.*exit status 128')
|| error.match(/unrecognized import path[^\n]gopkg\.in/));
}
/**
* SAM Integration test fixture for CDK - SAM integration test cases
*/
export function withSamIntegrationFixture(block: (context: SamIntegrationTestFixture) => Promise<void>) {
return withAws(withTimeout(DEFAULT_TEST_TIMEOUT_S, withSamIntegrationCdkApp(block)));
}
export class SamIntegrationTestFixture extends TestFixture {
public async samShell(command: string[], filter?: string, action?: () => any, options: Omit<ShellOptions, 'cwd' | 'output'> = {}): Promise<ActionOutput> {
return shellWithAction(command, filter, action, {
outputs: [this.output],
cwd: path.join(this.integTestDir, 'cdk.out').toString(),
...options,
});
}
public async samBuild(stackName: string) {
const fullStackName = this.fullStackName(stackName);
const templatePath = path.join(this.integTestDir, 'cdk.out', `${fullStackName}.template.json`);
const args = ['--template', templatePath.toString()];
return this.samShell(['sam', 'build', ...args]);
}
public async samLocalStartApi(stackName: string, isBuilt: boolean, port: number, apiPath: string): Promise<ActionOutput> {
const fullStackName = this.fullStackName(stackName);
const templatePath = path.join(this.integTestDir, 'cdk.out', `${fullStackName}.template.json`);
const args = isBuilt? [] : ['--template', templatePath.toString()];
args.push('--port');
args.push(port.toString());
// https://github.com/aws/aws-sam-cli/pull/7892
args.push('--no-memory-limit');
// "Press Ctrl+C to quit" looks to be printed by a Flask server built into SAM CLI.
return this.samShell(['sam', 'local', 'start-api', ...args], 'Press CTRL+C to quit', ()=>{
return new Promise<ActionOutput>((resolve, reject) => {
axios.get(`http://127.0.0.1:${port}${apiPath}`).then( resp => {
resolve(resp.data);
}).catch( error => {
reject(new Error(`Failed to invoke api path ${apiPath} on port ${port} with error ${error}`));
});
});
});
}
/**
* Cleanup leftover stacks and buckets
*/
public async dispose(success: boolean) {
// 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) {
// eslint-disable-next-line no-console
console.error(`Failed to clean up ${this.integTestDir} due to permissions issues (Docker running as root?)`);
}
}
}
}
export function randomInteger(min: number, max: number) {
return Math.floor(Math.random() * (max - min) + min);
}
/**
* A shell command that does what you want
*
* Is platform-aware, handles errors nicely.
*/
export async function shellWithAction(
command: string[],
filter?: string,
action?: () => Promise<any>,
options: ShellOptions = {},
actionTimeoutSeconds: number = 600,
): Promise<ActionOutput> {
if (options.modEnv && options.env) {
throw new Error('Use either env or modEnv but not both');
}
const writeToOutputs = (x: string) => {
for (const output of options.outputs ?? []) {
output.write(x);
}
};
writeToOutputs(`💻 ${command.join(' ')}\n`);
const env = options.env ?? (options.modEnv ? { ...process.env, ...options.modEnv } : undefined);
const child = child_process.spawn(command[0], command.slice(1), {
...options,
env,
// Need this for Windows where we want .cmd and .bat to be found as well.
shell: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
return new Promise<ActionOutput>((resolve, reject) => {
const out = new Array<Buffer>();
const stdout = new Array<Buffer>();
const stderr = new Array<Buffer>();
let actionSucceeded = false;
let actionOutput: any;
let actionExecuted = false;
async function maybeExecuteAction(chunk: any) {
out.push(Buffer.from(chunk));
if (!actionExecuted && typeof filter === 'string' && Buffer.concat(out).toString('utf-8').includes(filter) && typeof action === 'function') {
actionExecuted = true;
writeToOutputs('before executing action\n');
try {
const output = await action();
writeToOutputs(`action output is ${JSON.stringify(output)}\n`);
actionOutput = output;
actionSucceeded = true;
} catch (error: any) {
writeToOutputs(`action error is ${error}\n`);
actionSucceeded = false;
actionOutput = error;
} finally {
writeToOutputs('terminate sam sub process\n');
killSubProcess(child, command.join(' '));
}
}
}
if (typeof filter === 'string' && typeof action === 'function') {
// Reject with an error if an action is configured, but the filter failed
// to show up in the output before the timeout occurred.
setTimeout(
() => {
if (!actionExecuted) {
reject(new Error(`Timed out waiting for filter ${JSON.stringify(filter)} to appear in command output after ${actionTimeoutSeconds} seconds\nOutput so far:\n${Buffer.concat(out).toString('utf-8')}`));
killSubProcess(child, command.join(' '));
}
}, actionTimeoutSeconds * 1_000,
).unref();
}
child.stdout!.on('data', chunk => {
writeToOutputs(chunk);
stdout.push(chunk);
void maybeExecuteAction(chunk);
});
child.stderr!.on('data', chunk => {
writeToOutputs(chunk);
if (options.captureStderr ?? true) {
stderr.push(chunk);
}
void maybeExecuteAction(chunk);
});
child.once('error', reject);
// Wait for 'exit' instead of close, don't care about reading the streams all the way to the end
child.once('exit', (code, signal) => {
writeToOutputs(`Subprocess has exited with code ${code}, signal ${signal}\n`);
const output = (Buffer.concat(stdout).toString('utf-8') + Buffer.concat(stderr).toString('utf-8')).trim();
if (code == null || code === 0 || options.allowErrExit) {
let result = new Array<string>();
result.push(actionOutput);
result.push(output);
resolve({
actionSucceeded: actionSucceeded,
actionOutput: actionOutput,
shellOutput: output,
});
} else {
reject(new Error(`'${command.join(' ')}' exited with error code ${code}. Output: \n${output}`));
}
});
});
}
function killSubProcess(child: child_process.ChildProcess, command: string) {
/**
* Check if the sub process is running in container, so child_process.spawn will
* create multiple processes, and to kill all of them we need to run different logic
*/
child.kill('SIGINT');
child_process.exec(`for pid in $(ps -ef | grep "${command}" | awk '{print $2}'); do kill -2 $pid; done`);
}