From c90def208707ea310aa8eac7c319a15a130d8cf8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdelrahman Date: Thu, 21 May 2026 11:24:41 +0000 Subject: [PATCH] feat(integ-runner): add --role-arn CLI option to override CFN execution role Add a `--role-arn` CLI flag to `integ-runner` that overrides the CloudFormation execution role for deploy, destroy, and watch operations. The CLI flag takes precedence over any `roleArn` set in the integ manifest via `cdkCommandOptions.destroy.args.roleArn`. Closes cdklabs/cdk-ops#5147 --- packages/@aws-cdk/integ-runner/lib/cli.ts | 4 ++ .../lib/runner/integ-test-runner.ts | 17 ++++- .../integ-runner/lib/workers/common.ts | 7 ++ .../lib/workers/extract/extract_worker.ts | 2 + .../lib/workers/integ-test-worker.ts | 1 + .../lib/workers/integ-watch-worker.ts | 1 + .../@aws-cdk/integ-runner/test/cli.test.ts | 12 ++++ .../test/runner/integ-test-runner.test.ts | 68 +++++++++++++++++++ .../test/workers/integ-worker.test.ts | 33 +++++++++ 9 files changed, 144 insertions(+), 1 deletion(-) diff --git a/packages/@aws-cdk/integ-runner/lib/cli.ts b/packages/@aws-cdk/integ-runner/lib/cli.ts index a6819e654..bf6994b7b 100644 --- a/packages/@aws-cdk/integ-runner/lib/cli.ts +++ b/packages/@aws-cdk/integ-runner/lib/cli.ts @@ -55,6 +55,7 @@ export function parseCliArgs(args: string[] = []) { .option('proxy', { type: 'string', desc: 'Use the indicated proxy. Will read from HTTPS_PROXY environment variable if not specified', requiresArg: true }) .option('ca-bundle-path', { type: 'string', desc: 'Path to CA certificate to use when validating HTTPS requests. Will read from AWS_CA_BUNDLE environment variable if not specified', requiresArg: true }) .option('unstable', { type: 'array', desc: `Opt-in to using unstable features. By using these flags you acknowledge that scope and API of unstable features may change without notice. Specify multiple times for each unstable feature you want to opt-in to. ${availableFeaturesDescription()}`, nargs: 1, default: [] }) + .option('role-arn', { type: 'string', desc: 'ARN of the IAM role for CloudFormation to assume during deploy/destroy', requiresArg: true }) .strict() .parse(args); @@ -119,6 +120,7 @@ export function parseCliArgs(args: string[] = []) { unstable: arrayFromYargs(argv.unstable) ?? [], proxy: argv.proxy as (string | undefined), caBundlePath: argv['ca-bundle-path'] as (string | undefined), + roleArn: argv['role-arn'] as (string | undefined), }; } @@ -200,6 +202,7 @@ async function run(options: ReturnType) { watch: options.watch, proxy: options.proxy, caBundlePath: options.caBundlePath, + roleArn: options.roleArn, }); testsSucceeded = success; @@ -223,6 +226,7 @@ async function run(options: ReturnType) { region: options.testRegions[0], proxy: options.proxy, caBundlePath: options.caBundlePath, + roleArn: options.roleArn, }); } } finally { diff --git a/packages/@aws-cdk/integ-runner/lib/runner/integ-test-runner.ts b/packages/@aws-cdk/integ-runner/lib/runner/integ-test-runner.ts index 3bc967baf..6cdaae3f1 100644 --- a/packages/@aws-cdk/integ-runner/lib/runner/integ-test-runner.ts +++ b/packages/@aws-cdk/integ-runner/lib/runner/integ-test-runner.ts @@ -43,7 +43,12 @@ export interface CommonOptions { } export interface WatchOptions extends CommonOptions { - + /** + * ARN of the IAM role for CloudFormation to assume during deploy/destroy + * + * @default - use the bootstrap cfn-exec role + */ + readonly roleArn?: string; } /** @@ -82,6 +87,13 @@ export interface RunOptions extends CommonOptions { * @default true */ readonly updateWorkflow?: boolean; + + /** + * ARN of the IAM role for CloudFormation to assume during deploy/destroy + * + * @default - use the bootstrap cfn-exec role + */ + readonly roleArn?: string; } /** @@ -206,6 +218,7 @@ export class IntegTestRunner extends IntegRunner { traceLogs: enableForVerbosityLevel(2) ?? false, verbose: enableForVerbosityLevel(3), debug: enableForVerbosityLevel(4), + roleArn: options.roleArn, }, options.testCaseName, options.verbosity ?? 0, @@ -250,6 +263,7 @@ export class IntegTestRunner extends IntegRunner { requireApproval: RequireApproval.NEVER, verbose: enableForVerbosityLevel(3), debug: enableForVerbosityLevel(4), + roleArn: options.roleArn, }, updateWorkflowEnabled, options.testCaseName, @@ -275,6 +289,7 @@ export class IntegTestRunner extends IntegRunner { output: path.relative(this.directory, this.cdkOutDir), ...actualTestCase.cdkCommandOptions?.destroy?.args, context: this.getContext(actualTestCase.cdkCommandOptions?.destroy?.args?.context), + roleArn: options.roleArn ?? actualTestCase.cdkCommandOptions?.destroy?.args?.roleArn, verbose: enableForVerbosityLevel(3), debug: enableForVerbosityLevel(4), }); diff --git a/packages/@aws-cdk/integ-runner/lib/workers/common.ts b/packages/@aws-cdk/integ-runner/lib/workers/common.ts index 9437f21e0..f47f95078 100644 --- a/packages/@aws-cdk/integ-runner/lib/workers/common.ts +++ b/packages/@aws-cdk/integ-runner/lib/workers/common.ts @@ -182,6 +182,13 @@ export interface IntegTestOptions { * @default - no additional CA bundle */ readonly caBundlePath?: string; + + /** + * ARN of the IAM role for CloudFormation to assume during deploy/destroy + * + * @default - use the bootstrap cfn-exec role + */ + readonly roleArn?: string; } /** diff --git a/packages/@aws-cdk/integ-runner/lib/workers/extract/extract_worker.ts b/packages/@aws-cdk/integ-runner/lib/workers/extract/extract_worker.ts index d5c9a1fd0..27d1de250 100644 --- a/packages/@aws-cdk/integ-runner/lib/workers/extract/extract_worker.ts +++ b/packages/@aws-cdk/integ-runner/lib/workers/extract/extract_worker.ts @@ -52,6 +52,7 @@ export async function integTestWorker(request: IntegTestBatchRequest): Promise result.status === 'fail')) { failures.push(testInfo); @@ -117,6 +118,7 @@ export async function watchTestWorker(options: IntegWatchOptions): Promise await runner.watchIntegTest({ testCaseName, verbosity, + roleArn: options.roleArn, }); } } diff --git a/packages/@aws-cdk/integ-runner/lib/workers/integ-test-worker.ts b/packages/@aws-cdk/integ-runner/lib/workers/integ-test-worker.ts index ea7a02615..1f93b5243 100644 --- a/packages/@aws-cdk/integ-runner/lib/workers/integ-test-worker.ts +++ b/packages/@aws-cdk/integ-runner/lib/workers/integ-test-worker.ts @@ -138,6 +138,7 @@ export async function runIntegrationTestsInParallel( updateWorkflow: options.updateWorkflow, proxy: options.proxy, caBundlePath: options.caBundlePath, + roleArn: options.roleArn, }], { on: printResults, }); diff --git a/packages/@aws-cdk/integ-runner/lib/workers/integ-watch-worker.ts b/packages/@aws-cdk/integ-runner/lib/workers/integ-watch-worker.ts index bccf798c9..6788c54ca 100644 --- a/packages/@aws-cdk/integ-runner/lib/workers/integ-watch-worker.ts +++ b/packages/@aws-cdk/integ-runner/lib/workers/integ-watch-worker.ts @@ -8,6 +8,7 @@ export interface IntegWatchOptions extends IntegTestInfo { readonly verbosity?: number; readonly proxy?: string; readonly caBundlePath?: string; + readonly roleArn?: string; } export async function watchIntegrationTest(pool: workerpool.WorkerPool, options: IntegWatchOptions): Promise { await pool.exec('watchTestWorker', [options], { diff --git a/packages/@aws-cdk/integ-runner/test/cli.test.ts b/packages/@aws-cdk/integ-runner/test/cli.test.ts index 312386337..525ea704f 100644 --- a/packages/@aws-cdk/integ-runner/test/cli.test.ts +++ b/packages/@aws-cdk/integ-runner/test/cli.test.ts @@ -317,3 +317,15 @@ describe('Proxy options', () => { } }); }); + +describe('Role ARN option', () => { + test('--role-arn is parsed', () => { + const options = parseCliArgs(['--role-arn', 'arn:aws:iam::123456789012:role/MyRole']); + expect(options.roleArn).toBe('arn:aws:iam::123456789012:role/MyRole'); + }); + + test('role-arn defaults to undefined', () => { + const options = parseCliArgs([]); + expect(options.roleArn).toBeUndefined(); + }); +}); diff --git a/packages/@aws-cdk/integ-runner/test/runner/integ-test-runner.test.ts b/packages/@aws-cdk/integ-runner/test/runner/integ-test-runner.test.ts index 93eaad9fb..a058bef1d 100644 --- a/packages/@aws-cdk/integ-runner/test/runner/integ-test-runner.test.ts +++ b/packages/@aws-cdk/integ-runner/test/runner/integ-test-runner.test.ts @@ -775,3 +775,71 @@ describe('IntegTest watchIntegTest', () => { }).rejects.toThrow('xxxxx.test-with-error is a new test. Please use the IntegTest construct to configure the test\nhttps://github.com/aws/aws-cdk/tree/main/packages/%40aws-cdk/integ-tests-alpha'); }); }); + +describe('IntegTest roleArn', () => { + test('roleArn is passed to deploy and destroy', async () => { + // WHEN + const integTest = new IntegTestRunner({ + cdk: cdkMock.cdk, + region: 'eu-west-1', + test: new IntegTest({ + fileName: 'test/test-data/xxxxx.test-with-snapshot.js', + discoveryRoot: 'test/test-data', + }), + }); + await integTest.runIntegTestCase({ + testCaseName: 'xxxxx.test-with-snapshot', + roleArn: 'arn:aws:iam::123456789012:role/MyRole', + }); + + // THEN + expect(cdkMock.mocks.deploy).toHaveBeenCalledWith(expect.objectContaining({ + roleArn: 'arn:aws:iam::123456789012:role/MyRole', + })); + expect(cdkMock.mocks.destroy).toHaveBeenCalledWith(expect.objectContaining({ + roleArn: 'arn:aws:iam::123456789012:role/MyRole', + })); + }); + + test('roleArn is passed to watch', async () => { + // WHEN + const integTest = new IntegTestRunner({ + cdk: cdkMock.cdk, + region: 'eu-west-1', + test: new IntegTest({ + fileName: 'test/test-data/xxxxx.test-with-snapshot.js', + discoveryRoot: 'test/test-data', + }), + }); + await integTest.watchIntegTest({ + testCaseName: 'xxxxx.test-with-snapshot', + roleArn: 'arn:aws:iam::123456789012:role/MyRole', + }); + + // THEN + expect(cdkMock.mocks.watch).toHaveBeenCalledWith(expect.objectContaining({ + roleArn: 'arn:aws:iam::123456789012:role/MyRole', + }), expect.anything()); + }); + + test('roleArn overrides manifest destroy args', async () => { + // WHEN + const integTest = new IntegTestRunner({ + cdk: cdkMock.cdk, + region: 'eu-west-1', + test: new IntegTest({ + fileName: 'test/test-data/xxxxx.test-with-snapshot.js', + discoveryRoot: 'test/test-data', + }), + }); + await integTest.runIntegTestCase({ + testCaseName: 'xxxxx.test-with-snapshot', + roleArn: 'arn:aws:iam::123456789012:role/CliRole', + }); + + // THEN - CLI roleArn takes precedence + expect(cdkMock.mocks.destroy).toHaveBeenCalledWith(expect.objectContaining({ + roleArn: 'arn:aws:iam::123456789012:role/CliRole', + })); + }); +}); diff --git a/packages/@aws-cdk/integ-runner/test/workers/integ-worker.test.ts b/packages/@aws-cdk/integ-runner/test/workers/integ-worker.test.ts index dc73c2264..f9d795187 100644 --- a/packages/@aws-cdk/integ-runner/test/workers/integ-worker.test.ts +++ b/packages/@aws-cdk/integ-runner/test/workers/integ-worker.test.ts @@ -615,3 +615,36 @@ describe('parallel worker', () => { }); }); }); + +describe('integTestWorker roleArn', () => { + let mockActualTests: jest.Mock; + let mockRunIntegTestCase: jest.Mock; + + beforeEach(() => { + mockActualTests = jest.fn(); + mockRunIntegTestCase = jest.fn(); + + jest.spyOn(IntegTestRunner.prototype, 'actualTests').mockImplementation(mockActualTests); + jest.spyOn(IntegTestRunner.prototype, 'runIntegTestCase').mockImplementation(mockRunIntegTestCase); + }); + + test('passes roleArn to runIntegTestCase', async () => { + mockActualTests.mockResolvedValue({ + 'test-case-1': { stacks: ['Stack1'] }, + }); + mockRunIntegTestCase.mockResolvedValue(undefined); + + await integTestWorker({ + tests: [{ + fileName: 'test/test-data/xxxxx.test-with-snapshot.js', + discoveryRoot: 'test/test-data', + }], + region: 'us-east-1', + roleArn: 'arn:aws:iam::123456789012:role/MyRole', + }); + + expect(mockRunIntegTestCase).toHaveBeenCalledWith( + expect.objectContaining({ roleArn: 'arn:aws:iam::123456789012:role/MyRole' }), + ); + }); +});