diff --git a/packages/@aws-cdk-testing/cli-integ/lib/package-sources/release-source.ts b/packages/@aws-cdk-testing/cli-integ/lib/package-sources/release-source.ts index 10fb9d302c7f8..ad39f813020df 100644 --- a/packages/@aws-cdk-testing/cli-integ/lib/package-sources/release-source.ts +++ b/packages/@aws-cdk-testing/cli-integ/lib/package-sources/release-source.ts @@ -7,11 +7,12 @@ import { shell, rimraf, addToShellPath } from '../shell'; export class ReleasePackageSourceSetup implements IPackageSourceSetup { readonly name = 'release'; - readonly description = `release @ ${this.version}`; + readonly description: string; private tempDir?: string; constructor(private readonly version: string, private readonly frameworkVersion?: string) { + this.description = `release @ ${this.version}`; } public async prepare(): Promise { diff --git a/packages/@aws-cdk-testing/cli-integ/lib/package-sources/repo-source.ts b/packages/@aws-cdk-testing/cli-integ/lib/package-sources/repo-source.ts index e6d3714aa4b64..28e7c02239927 100644 --- a/packages/@aws-cdk-testing/cli-integ/lib/package-sources/repo-source.ts +++ b/packages/@aws-cdk-testing/cli-integ/lib/package-sources/repo-source.ts @@ -7,9 +7,10 @@ import { shell, addToShellPath } from '../shell'; export class RepoPackageSourceSetup implements IPackageSourceSetup { readonly name = 'repo'; - readonly description = `repo(${this.repoRoot})`; + readonly description: string; constructor(private readonly repoRoot: string) { + this.description = `repo(${this.repoRoot})`; } public async prepare(): Promise { diff --git a/packages/@aws-cdk-testing/cli-integ/lib/proxy.ts b/packages/@aws-cdk-testing/cli-integ/lib/proxy.ts new file mode 100644 index 0000000000000..0addbd8cc3231 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/lib/proxy.ts @@ -0,0 +1,62 @@ +import { promises as fs } from 'fs'; +import * as querystring from 'node:querystring'; +import * as os from 'os'; +import * as path from 'path'; +import * as mockttp from 'mockttp'; +import { CompletedRequest } from 'mockttp'; + +export async function startProxyServer(certDirRoot?: string): Promise { + const certDir = await fs.mkdtemp(path.join(certDirRoot ?? os.tmpdir(), 'cdk-')); + const certPath = path.join(certDir, 'cert.pem'); + const keyPath = path.join(certDir, 'key.pem'); + + // Set up key and certificate + const { key, cert } = await mockttp.generateCACertificate(); + await fs.writeFile(keyPath, key); + await fs.writeFile(certPath, cert); + + const server = mockttp.getLocal({ + https: { keyPath: keyPath, certPath: certPath }, + }); + + // We don't need to modify any request, so the proxy + // passes through all requests to the target host. + const endpoint = await server + .forAnyRequest() + .thenPassThrough(); + + server.enableDebug(); + await server.start(); + + return { + certPath, + keyPath, + server, + url: server.url, + port: server.port, + getSeenRequests: () => endpoint.getSeenRequests(), + async stop() { + await server.stop(); + await fs.rm(certDir, { recursive: true, force: true }); + }, + }; +} + +export interface ProxyServer { + readonly certPath: string; + readonly keyPath: string; + readonly server: mockttp.Mockttp; + readonly url: string; + readonly port: number; + + getSeenRequests(): Promise; + stop(): Promise; +} + +export function awsActionsFromRequests(requests: CompletedRequest[]): string[] { + return [...new Set(requests + .map(req => req.body.buffer.toString('utf-8')) + .map(body => querystring.decode(body)) + .map(x => x.Action as string) + .filter(action => action != null))]; +} diff --git a/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts b/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts index b7f58c3b81af5..c2a09073b4cb5 100644 --- a/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts +++ b/packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts @@ -317,7 +317,7 @@ export interface CdkGarbageCollectionCommandOptions { } export class TestFixture extends ShellHelper { - public readonly qualifier = this.randomString.slice(0, 10); + public readonly qualifier: string; private readonly bucketsToDelete = new Array(); public readonly packages: IPackageSource; @@ -330,6 +330,7 @@ export class TestFixture extends ShellHelper { super(integTestDir, output); + this.qualifier = this.randomString.slice(0, 10); this.packages = packageSourceInSubprocess(); } @@ -338,16 +339,22 @@ export class TestFixture extends ShellHelper { } public async cdkDeploy(stackNames: string | string[], options: CdkCliOptions = {}, skipStackRename?: boolean) { - stackNames = typeof stackNames === 'string' ? [stackNames] : stackNames; + return this.cdk(this.cdkDeployCommandLine(stackNames, options, skipStackRename)); + } + public cdkDeployCommandLine(stackNames: string | string[], options: CdkCliOptions = {}, skipStackRename?: boolean) { + stackNames = typeof stackNames === 'string' ? [stackNames] : stackNames; const neverRequireApproval = options.neverRequireApproval ?? true; - return this.cdk(['deploy', + return [ + 'deploy', ...(neverRequireApproval ? ['--require-approval=never'] : []), // Default to no approval in an unattended test ...(options.options ?? []), + ...(options.verbose ? ['-v'] : []), // use events because bar renders bad in tests '--progress', 'events', - ...(skipStackRename ? stackNames : this.fullStackName(stackNames))], options); + ...(skipStackRename ? stackNames : this.fullStackName(stackNames)), + ]; } public async cdkSynth(options: CdkCliOptions = {}) { @@ -490,15 +497,24 @@ export class TestFixture extends ShellHelper { return this.shell(['cdk', ...(verbose ? ['-v'] : []), ...args], { ...options, modEnv: { - AWS_REGION: this.aws.region, - AWS_DEFAULT_REGION: this.aws.region, - STACK_NAME_PREFIX: this.stackNamePrefix, - PACKAGE_LAYOUT_VERSION: this.packages.majorVersion(), + ...this.cdkShellEnv(), ...options.modEnv, }, }); } + /** + * Return the environment variables with which to execute CDK + */ + public cdkShellEnv() { + return { + AWS_REGION: this.aws.region, + AWS_DEFAULT_REGION: this.aws.region, + STACK_NAME_PREFIX: this.stackNamePrefix, + PACKAGE_LAYOUT_VERSION: this.packages.majorVersion(), + }; + } + public template(stackName: string): any { const fullStackName = this.fullStackName(stackName); const templatePath = path.join(this.integTestDir, 'cdk.out', `${fullStackName}.template.json`); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts index 7e3f193d45a6b..3659c17430459 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts @@ -1,5 +1,4 @@ import { existsSync, promises as fs } from 'fs'; -import * as querystring from 'node:querystring'; import * as os from 'os'; import * as path from 'path'; import { @@ -23,8 +22,6 @@ import { InvokeCommand } from '@aws-sdk/client-lambda'; import { PutObjectLockConfigurationCommand } from '@aws-sdk/client-s3'; import { CreateTopicCommand, DeleteTopicCommand } from '@aws-sdk/client-sns'; import { AssumeRoleCommand, GetCallerIdentityCommand } from '@aws-sdk/client-sts'; -import * as mockttp from 'mockttp'; -import { CompletedRequest } from 'mockttp'; import { cloneDirectory, integTest, @@ -41,6 +38,7 @@ import { withSamIntegrationFixture, withSpecificFixture, } from '../../lib'; +import { awsActionsFromRequests, startProxyServer } from '../../lib/proxy'; jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime @@ -2876,60 +2874,29 @@ integTest('cdk notices are displayed correctly', withDefaultFixture(async (fixtu integTest('requests go through a proxy when configured', withDefaultFixture(async (fixture) => { - // Set up key and certificate - const { key, cert } = await mockttp.generateCACertificate(); - const certDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cdk-')); - const certPath = path.join(certDir, 'cert.pem'); - const keyPath = path.join(certDir, 'key.pem'); - await fs.writeFile(keyPath, key); - await fs.writeFile(certPath, cert); - - const proxyServer = mockttp.getLocal({ - https: { keyPath, certPath }, - }); - - // We don't need to modify any request, so the proxy - // passes through all requests to the target host. - const endpoint = await proxyServer - .forAnyRequest() - .thenPassThrough(); - - proxyServer.enableDebug(); - await proxyServer.start(); - - // The proxy is now ready to intercept requests - + const proxyServer = await startProxyServer(); try { await fixture.cdkDeploy('test-2', { captureStderr: true, options: [ '--proxy', proxyServer.url, - '--ca-bundle-path', certPath, + '--ca-bundle-path', proxyServer.certPath, ], modEnv: { CDK_HOME: fixture.integTestDir, }, }); - } finally { - await fs.rm(certDir, { recursive: true, force: true }); - await proxyServer.stop(); - } - const requests = await endpoint.getSeenRequests(); + const requests = await proxyServer.getSeenRequests(); - expect(requests.map(req => req.url)) - .toContain('https://cli.cdk.dev-tools.aws.dev/notices.json'); + expect(requests.map(req => req.url)) + .toContain('https://cli.cdk.dev-tools.aws.dev/notices.json'); - const actionsUsed = actions(requests); - expect(actionsUsed).toContain('AssumeRole'); - expect(actionsUsed).toContain('CreateChangeSet'); + const actionsUsed = awsActionsFromRequests(requests); + expect(actionsUsed).toContain('AssumeRole'); + expect(actionsUsed).toContain('CreateChangeSet'); + } finally { + await proxyServer.stop(); + } }), ); - -function actions(requests: CompletedRequest[]): string[] { - return [...new Set(requests - .map(req => req.body.buffer.toString('utf-8')) - .map(body => querystring.decode(body)) - .map(x => x.Action as string) - .filter(action => action != null))]; -} diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/proxy.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/proxy.integtest.ts new file mode 100644 index 0000000000000..1b27f6f236456 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/proxy.integtest.ts @@ -0,0 +1,125 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { integTest } from '../../lib/integ-test'; +import { startProxyServer } from '../../lib/proxy'; +import { TestFixture, withDefaultFixture } from '../../lib/with-cdk-app'; + +const docker = process.env.CDK_DOCKER ?? 'docker'; + +integTest( + 'deploy in isolated container', + withDefaultFixture(async (fixture) => { + // Find the 'cdk' command and make sure it is mounted into the container + const cdkFullpath = (await fixture.shell(['which', 'cdk'])).trim(); + const cdkTop = topLevelDirectory(cdkFullpath); + + // Run a 'cdk deploy' inside the container + const commands = [ + `env ${renderEnv(fixture.cdkShellEnv())} ${cdkFullpath} ${fixture.cdkDeployCommandLine('test-2', { verbose: true }).join(' ')}`, + ]; + + await runInIsolatedContainer(fixture, [cdkTop], commands); + }), +); + +async function runInIsolatedContainer(fixture: TestFixture, pathsToMount: string[], testCommands: string[]) { + pathsToMount.push( + `${process.env.HOME}`, + fixture.integTestDir, + ); + + const proxy = await startProxyServer(fixture.integTestDir); + try { + const proxyPort = proxy.port; + + const setupCommands = [ + 'apt-get update -qq', + 'apt-get install -qqy nodejs > /dev/null', + ...isolatedDockerCommands(proxyPort, proxy.certPath), + ]; + + const scriptName = path.join(fixture.integTestDir, 'script.sh'); + + // Write a script file + await fs.writeFile(scriptName, [ + '#!/bin/bash', + 'set -x', + 'set -eu', + ...setupCommands, + ...testCommands, + ].join('\n'), 'utf-8'); + + await fs.chmod(scriptName, 0o755); + + // Run commands in a Docker shell + await fixture.shell([ + docker, 'run', '--net=bridge', '--rm', + ...pathsToMount.flatMap(p => ['-v', `${p}:${p}`]), + ...['HOME', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN'].flatMap(e => ['-e', e]), + '-w', fixture.integTestDir, + '--cap-add=NET_ADMIN', + 'ubuntu:latest', + `${scriptName}`, + ], { + stdio: 'inherit', + }); + } finally { + await proxy.stop(); + } +} + +function topLevelDirectory(dir: string) { + while (true) { + let parent = path.dirname(dir); + if (parent === '/') { + return dir; + } + dir = parent; + } +} + +/** + * Return the commands necessary to isolate the inside of the container from the internet, + * except by going through the proxy + */ +function isolatedDockerCommands(proxyPort: number, caBundlePath: string) { + return [ + 'echo Working...', + 'apt-get install -qqy curl net-tools iputils-ping dnsutils iptables > /dev/null', + '', + 'gateway=$(dig +short host.docker.internal)', + '', + '# Some iptables manipulation; there might be unnecessary commands in here, not an expert', + 'iptables -F', + 'iptables -X', + 'iptables -P INPUT DROP', + 'iptables -P OUTPUT DROP', + 'iptables -P FORWARD DROP', + 'iptables -A INPUT -i lo -j ACCEPT', + 'iptables -A OUTPUT -o lo -j ACCEPT', + 'iptables -A OUTPUT -d $gateway -j ACCEPT', + 'iptables -A INPUT -s $gateway -j ACCEPT', + '', + '', + `if [[ ! -f ${caBundlePath} ]]; then`, + ` echo "Could not find ${caBundlePath}, this will probably not go well. Exiting." >&2`, + ' exit 1', + 'fi', + '', + '# Configure a bunch of tools to work with the proxy', + 'echo "+-------------------------------------------------------------------------------------+"', + 'echo "| Direct network traffic has been blocked, everything must go through the proxy. |"', + 'echo "+-------------------------------------------------------------------------------------+"', + `export HTTP_PROXY=http://$gateway:${proxyPort}/`, + `export HTTPS_PROXY=http://$gateway:${proxyPort}/`, + `export NODE_EXTRA_CA_CERTS=${caBundlePath}`, + `export AWS_CA_BUNDLE=${caBundlePath}`, + `export SSL_CERT_FILE=${caBundlePath}`, + 'echo "Acquire::http::proxy \"$HTTP_PROXY\";" >> /etc/apt/apt.conf.d/95proxies', + 'echo "Acquire::https::proxy \"$HTTPS_PROXY\";" >> /etc/apt/apt.conf.d/95proxies', + ]; +} + +function renderEnv(env: Record) { + return Object.entries(env).map(([k, v]) => `${k}='${v}'`).join(' '); +} diff --git a/packages/@aws-cdk-testing/cli-integ/tsconfig.json b/packages/@aws-cdk-testing/cli-integ/tsconfig.json index f03765e562835..254f5c9c630d9 100644 --- a/packages/@aws-cdk-testing/cli-integ/tsconfig.json +++ b/packages/@aws-cdk-testing/cli-integ/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2020", "module": "commonjs", - "lib": ["es2019", "es2020", "dom"], + "lib": ["ES2020", "dom"], "strict": true, "alwaysStrict": true, "declaration": true,