Skip to content

Commit c40a9e2

Browse files
authored
chore: add integ test that validates full proxy traversal (#33140)
(Re-roll of #33092) Add a CLI integ test to validate that `cdk deploy` works in a fully network-isolated environment, with only a proxy to go through. This validates that no parts of the CLI setup ignore the proxy configuration, which would otherwise be hard to test. We achieve the network isolation by running the code inside a Docker container where we use `iptables` to drop all network traffic that doesn't go through the Docker host, where we run a proxy. I temporarily bumped the `tsconfig` `target` to try out the `using` syntax (didn't work out with Jest), but that caused some compiler errors around class member initialization that I fixed as well. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent aec64f0 commit c40a9e2

File tree

12 files changed

+317
-74
lines changed

12 files changed

+317
-74
lines changed

packages/@aws-cdk-testing/cli-integ/lib/aws.ts

+20-4
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ import {
1919
import { SNSClient } from '@aws-sdk/client-sns';
2020
import { SSOClient } from '@aws-sdk/client-sso';
2121
import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
22-
import { fromIni } from '@aws-sdk/credential-providers';
22+
import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers';
2323
import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@smithy/types';
2424
import { ConfiguredRetryStrategy } from '@smithy/util-retry';
2525
interface ClientConfig {
26-
readonly credentials?: AwsCredentialIdentityProvider | AwsCredentialIdentity;
26+
readonly credentials: AwsCredentialIdentityProvider | AwsCredentialIdentity;
2727
readonly region: string;
2828
readonly retryStrategy: ConfiguredRetryStrategy;
2929
}
@@ -80,6 +80,17 @@ export class AwsClients {
8080
return (await stsClient.send(new GetCallerIdentityCommand({}))).Account!;
8181
}
8282

83+
/**
84+
* Resolve the current identity or identity provider to credentials
85+
*/
86+
public async credentials() {
87+
const x = this.config.credentials;
88+
if (isAwsCredentialIdentity(x)) {
89+
return x;
90+
}
91+
return x();
92+
}
93+
8394
public async deleteStacks(...stackNames: string[]) {
8495
if (stackNames.length === 0) {
8596
return;
@@ -251,7 +262,7 @@ export async function sleep(ms: number) {
251262
return new Promise((ok) => setTimeout(ok, ms));
252263
}
253264

254-
function chainableCredentials(region: string): AwsCredentialIdentityProvider | undefined {
265+
function chainableCredentials(region: string): AwsCredentialIdentityProvider {
255266
if ((process.env.CODEBUILD_BUILD_ARN || process.env.GITHUB_RUN_ID) && process.env.AWS_PROFILE) {
256267
// in codebuild we must assume the role that the cdk uses
257268
// otherwise credentials will just be picked up by the normal sdk
@@ -261,5 +272,10 @@ function chainableCredentials(region: string): AwsCredentialIdentityProvider | u
261272
});
262273
}
263274

264-
return undefined;
275+
// Otherwise just get what's default
276+
return fromNodeProviderChain({ clientConfig: { region } });
277+
}
278+
279+
function isAwsCredentialIdentity(x: any): x is AwsCredentialIdentity {
280+
return Boolean(x && typeof x === 'object' && x.accessKeyId);
265281
}

packages/@aws-cdk-testing/cli-integ/lib/cli/run-suite.ts

+1
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ async function main() {
121121

122122
try {
123123
await jest.run([
124+
'--randomize',
124125
...args.runInBand ? ['-i'] : [],
125126
...args.test ? ['-t', args.test] : [],
126127
...args.verbose ? ['--verbose'] : [],

packages/@aws-cdk-testing/cli-integ/lib/package-sources/release-source.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import { shell, rimraf, addToShellPath } from '../shell';
77

88
export class ReleasePackageSourceSetup implements IPackageSourceSetup {
99
readonly name = 'release';
10-
readonly description = `release @ ${this.version}`;
10+
readonly description: string;
1111

1212
private tempDir?: string;
1313

1414
constructor(private readonly version: string, private readonly frameworkVersion?: string) {
15+
this.description = `release @ ${this.version}`;
1516
}
1617

1718
public async prepare(): Promise<void> {

packages/@aws-cdk-testing/cli-integ/lib/package-sources/repo-source.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import { shell, addToShellPath } from '../shell';
77

88
export class RepoPackageSourceSetup implements IPackageSourceSetup {
99
readonly name = 'repo';
10-
readonly description = `repo(${this.repoRoot})`;
10+
readonly description: string;
1111

1212
constructor(private readonly repoRoot: string) {
13+
this.description = `repo(${this.repoRoot})`;
1314
}
1415

1516
public async prepare(): Promise<void> {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { promises as fs } from 'fs';
2+
import * as querystring from 'node:querystring';
3+
import * as os from 'os';
4+
import * as path from 'path';
5+
import * as mockttp from 'mockttp';
6+
import { CompletedRequest } from 'mockttp';
7+
8+
export async function startProxyServer(certDirRoot?: string): Promise<ProxyServer> {
9+
const certDir = await fs.mkdtemp(path.join(certDirRoot ?? os.tmpdir(), 'cdk-'));
10+
const certPath = path.join(certDir, 'cert.pem');
11+
const keyPath = path.join(certDir, 'key.pem');
12+
13+
// Set up key and certificate
14+
const { key, cert } = await mockttp.generateCACertificate();
15+
await fs.writeFile(keyPath, key);
16+
await fs.writeFile(certPath, cert);
17+
18+
const server = mockttp.getLocal({
19+
https: { keyPath: keyPath, certPath: certPath },
20+
});
21+
22+
// We don't need to modify any request, so the proxy
23+
// passes through all requests to the target host.
24+
const endpoint = await server
25+
.forAnyRequest()
26+
.thenPassThrough();
27+
28+
const port = 9000 + Math.floor(Math.random() * 10000);
29+
30+
// server.enableDebug();
31+
await server.start(port);
32+
33+
return {
34+
certPath,
35+
keyPath,
36+
server,
37+
url: server.url,
38+
port: server.port,
39+
getSeenRequests: () => endpoint.getSeenRequests(),
40+
async stop() {
41+
await server.stop();
42+
await fs.rm(certDir, { recursive: true, force: true });
43+
},
44+
};
45+
}
46+
47+
export interface ProxyServer {
48+
readonly certPath: string;
49+
readonly keyPath: string;
50+
readonly server: mockttp.Mockttp;
51+
readonly url: string;
52+
readonly port: number;
53+
54+
getSeenRequests(): Promise<CompletedRequest[]>;
55+
stop(): Promise<void>;
56+
}
57+
58+
export function awsActionsFromRequests(requests: CompletedRequest[]): string[] {
59+
return [...new Set(requests
60+
.map(req => req.body.buffer.toString('utf-8'))
61+
.map(body => querystring.decode(body))
62+
.map(x => x.Action as string)
63+
.filter(action => action != null))];
64+
}

packages/@aws-cdk-testing/cli-integ/lib/shell.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,15 @@ export async function shell(command: string[], options: ShellOptions = {}): Prom
2121
}
2222
};
2323

24-
// Always output the command
25-
writeToOutputs(`💻 ${command.join(' ')}\n`);
2624
const show = options.show ?? 'always';
25+
const verbose = Boolean(process.env.VERBOSE);
26+
27+
if (verbose) {
28+
outputs.add(process.stdout);
29+
}
30+
31+
// Always output the command
32+
writeToOutputs(`💻 ${command.join(' ')} (show: ${show}, verbose: ${verbose})\n`);
2733

2834
const env = options.env ?? (options.modEnv ? { ...process.env, ...options.modEnv } : process.env);
2935

packages/@aws-cdk-testing/cli-integ/lib/with-cdk-app.ts

+32-17
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ export interface CdkGarbageCollectionCommandOptions {
321321
}
322322

323323
export class TestFixture extends ShellHelper {
324-
public readonly qualifier = this.randomString.slice(0, 10);
324+
public readonly qualifier: string;
325325
private readonly bucketsToDelete = new Array<string>();
326326
public readonly packages: IPackageSource;
327327

@@ -333,6 +333,7 @@ export class TestFixture extends ShellHelper {
333333
public readonly randomString: string) {
334334
super(integTestDir, output);
335335

336+
this.qualifier = this.randomString.slice(0, 10);
336337
this.packages = packageSourceInSubprocess();
337338
}
338339

@@ -341,16 +342,21 @@ export class TestFixture extends ShellHelper {
341342
}
342343

343344
public async cdkDeploy(stackNames: string | string[], options: CdkCliOptions = {}, skipStackRename?: boolean) {
344-
stackNames = typeof stackNames === 'string' ? [stackNames] : stackNames;
345+
return this.cdk(this.cdkDeployCommandLine(stackNames, options, skipStackRename), options);
346+
}
345347

348+
public cdkDeployCommandLine(stackNames: string | string[], options: CdkCliOptions = {}, skipStackRename?: boolean) {
349+
stackNames = typeof stackNames === 'string' ? [stackNames] : stackNames;
346350
const neverRequireApproval = options.neverRequireApproval ?? true;
347351

348-
return this.cdk(['deploy',
352+
return [
353+
'deploy',
349354
...(neverRequireApproval ? ['--require-approval=never'] : []), // Default to no approval in an unattended test
350355
...(options.options ?? []),
351356
// use events because bar renders bad in tests
352357
'--progress', 'events',
353-
...(skipStackRename ? stackNames : this.fullStackName(stackNames))], options);
358+
...(skipStackRename ? stackNames : this.fullStackName(stackNames)),
359+
];
354360
}
355361

356362
public async cdkSynth(options: CdkCliOptions = {}) {
@@ -497,6 +503,19 @@ export class TestFixture extends ShellHelper {
497503

498504
await this.packages.makeCliAvailable();
499505

506+
return this.shell(['cdk', ...(verbose ? ['-v'] : []), ...args], {
507+
...options,
508+
modEnv: {
509+
...this.cdkShellEnv(),
510+
...options.modEnv,
511+
},
512+
});
513+
}
514+
515+
/**
516+
* Return the environment variables with which to execute CDK
517+
*/
518+
public cdkShellEnv() {
500519
// if tests are using an explicit aws identity already (i.e creds)
501520
// force every cdk command to use the same identity.
502521
const awsCreds: Record<string, string> = this.aws.identity ? {
@@ -505,19 +524,15 @@ export class TestFixture extends ShellHelper {
505524
AWS_SESSION_TOKEN: this.aws.identity.sessionToken!,
506525
} : {};
507526

508-
return this.shell(['cdk', ...(verbose ? ['-v'] : []), ...args], {
509-
...options,
510-
modEnv: {
511-
AWS_REGION: this.aws.region,
512-
AWS_DEFAULT_REGION: this.aws.region,
513-
STACK_NAME_PREFIX: this.stackNamePrefix,
514-
PACKAGE_LAYOUT_VERSION: this.packages.majorVersion(),
515-
// CI must be unset, because we're trying to capture stdout in a bunch of tests
516-
CI: 'false',
517-
...awsCreds,
518-
...options.modEnv,
519-
},
520-
});
527+
return {
528+
AWS_REGION: this.aws.region,
529+
AWS_DEFAULT_REGION: this.aws.region,
530+
STACK_NAME_PREFIX: this.stackNamePrefix,
531+
PACKAGE_LAYOUT_VERSION: this.packages.majorVersion(),
532+
// In these tests we want to make a distinction between stdout and sterr
533+
CI: 'false',
534+
...awsCreds,
535+
};
521536
}
522537

523538
public template(stackName: string): any {

packages/@aws-cdk-testing/cli-integ/lib/with-cli-lib.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,7 @@ __EOS__`], {
131131
AWS_DEFAULT_REGION: this.aws.region,
132132
STACK_NAME_PREFIX: this.stackNamePrefix,
133133
PACKAGE_LAYOUT_VERSION: this.packages.majorVersion(),
134-
// Unset CI because we need to distinguish stdout/stderr and this variable
135-
// makes everything go to stdout
134+
// In these tests we want to make a distinction between stdout and sterr
136135
CI: 'false',
137136
...options.modEnv,
138137
},

packages/@aws-cdk-testing/cli-integ/resources/integ.jest.config.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ module.exports = {
1414
moduleFileExtensions: ["js"],
1515

1616
testEnvironment: "node",
17-
testTimeout: 300000,
17+
18+
// Because of the way Jest concurrency works, this timeout includes waiting
19+
// for the lock. Which is almost never what we actually care about. Set it high.
20+
testTimeout: 600000,
1821

1922
// Affects test.concurrent(), these are self-limiting anyway
2023
maxConcurrency: 10,

packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts

+15-45
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { existsSync, promises as fs } from 'fs';
2-
import * as querystring from 'node:querystring';
32
import * as os from 'os';
43
import * as path from 'path';
54
import {
@@ -23,8 +22,6 @@ import { InvokeCommand } from '@aws-sdk/client-lambda';
2322
import { PutObjectLockConfigurationCommand } from '@aws-sdk/client-s3';
2423
import { CreateTopicCommand, DeleteTopicCommand } from '@aws-sdk/client-sns';
2524
import { AssumeRoleCommand, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
26-
import * as mockttp from 'mockttp';
27-
import { CompletedRequest } from 'mockttp';
2825
import {
2926
cloneDirectory,
3027
integTest,
@@ -41,6 +38,7 @@ import {
4138
withSamIntegrationFixture,
4239
withSpecificFixture,
4340
} from '../../lib';
41+
import { awsActionsFromRequests, startProxyServer } from '../../lib/proxy';
4442

4543
jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime
4644

@@ -2873,60 +2871,32 @@ integTest('cdk notices are displayed correctly', withDefaultFixture(async (fixtu
28732871

28742872
integTest('requests go through a proxy when configured',
28752873
withDefaultFixture(async (fixture) => {
2876-
// Set up key and certificate
2877-
const { key, cert } = await mockttp.generateCACertificate();
2878-
const certDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cdk-'));
2879-
const certPath = path.join(certDir, 'cert.pem');
2880-
const keyPath = path.join(certDir, 'key.pem');
2881-
await fs.writeFile(keyPath, key);
2882-
await fs.writeFile(certPath, cert);
2883-
2884-
const proxyServer = mockttp.getLocal({
2885-
https: { keyPath, certPath },
2886-
});
2887-
2888-
// We don't need to modify any request, so the proxy
2889-
// passes through all requests to the target host.
2890-
const endpoint = await proxyServer
2891-
.forAnyRequest()
2892-
.thenPassThrough();
2893-
2894-
proxyServer.enableDebug();
2895-
await proxyServer.start();
2896-
2897-
// The proxy is now ready to intercept requests
2898-
2874+
const proxyServer = await startProxyServer();
28992875
try {
2876+
// Delete notices cache if it exists
2877+
await fs.rm(path.join(process.env.HOME ?? os.userInfo().homedir, '.cdk/cache/notices.json'), { force: true });
2878+
29002879
await fixture.cdkDeploy('test-2', {
29012880
captureStderr: true,
29022881
options: [
29032882
'--proxy', proxyServer.url,
2904-
'--ca-bundle-path', certPath,
2883+
'--ca-bundle-path', proxyServer.certPath,
29052884
],
29062885
modEnv: {
29072886
CDK_HOME: fixture.integTestDir,
29082887
},
29092888
});
2910-
} finally {
2911-
await fs.rm(certDir, { recursive: true, force: true });
2912-
await proxyServer.stop();
2913-
}
29142889

2915-
const requests = await endpoint.getSeenRequests();
2890+
const requests = await proxyServer.getSeenRequests();
29162891

2917-
expect(requests.map(req => req.url))
2918-
.toContain('https://cli.cdk.dev-tools.aws.dev/notices.json');
2892+
expect(requests.map(req => req.url))
2893+
.toContain('https://cli.cdk.dev-tools.aws.dev/notices.json');
29192894

2920-
const actionsUsed = actions(requests);
2921-
expect(actionsUsed).toContain('AssumeRole');
2922-
expect(actionsUsed).toContain('CreateChangeSet');
2895+
const actionsUsed = awsActionsFromRequests(requests);
2896+
expect(actionsUsed).toContain('AssumeRole');
2897+
expect(actionsUsed).toContain('CreateChangeSet');
2898+
} finally {
2899+
await proxyServer.stop();
2900+
}
29232901
}),
29242902
);
2925-
2926-
function actions(requests: CompletedRequest[]): string[] {
2927-
return [...new Set(requests
2928-
.map(req => req.body.buffer.toString('utf-8'))
2929-
.map(body => querystring.decode(body))
2930-
.map(x => x.Action as string)
2931-
.filter(action => action != null))];
2932-
}

0 commit comments

Comments
 (0)