From 789a12ddb34923ba26f3eb81880be9fcdcb1b0bf Mon Sep 17 00:00:00 2001 From: Calvin Combs <66279577+comcalvi@users.noreply.github.com> Date: Thu, 4 Nov 2021 12:15:06 -0700 Subject: [PATCH] feat(cli): added `build` field to cdk.json (#17176) Adds a `build` field to `cdk.json`. The command specified in the `build` will be executed before synthesis. This can be used to build any code that needs to be built before synthesis (for example, CDK App code or Lambda Function code). This is part of the changes needed for the `cdk watch` command (https://github.com/aws/aws-cdk-rfcs/pull/383). ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/aws-cdk/README.md | 7 ++++ packages/aws-cdk/lib/api/cxapp/exec.ts | 11 +++-- packages/aws-cdk/lib/settings.ts | 4 ++ packages/aws-cdk/test/api/exec.test.ts | 40 ++++++++++++++++--- packages/aws-cdk/test/usersettings.test.ts | 20 ++++++++++ .../aws-cdk/test/util/mock-child_process.ts | 21 +++------- 6 files changed, 79 insertions(+), 24 deletions(-) diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index e4566b7bbb690..0a2270b08fc90 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -464,6 +464,7 @@ Some of the interesting keys that can be used in the JSON configuration files: ```json5 { "app": "node bin/main.js", // Command to start the CDK app (--app='node bin/main.js') + "build": "mvn package", // Specify pre-synth build (no command line option) "context": { // Context entries (--context=key=value) "key": "value" }, @@ -473,6 +474,12 @@ Some of the interesting keys that can be used in the JSON configuration files: } ``` +If specified, the command in the `build` key will be executed immediately before synthesis. +This can be used to build Lambda Functions, CDK Application code, or other assets. +`build` cannot be specified on the command line or in the User configuration, +and must be specified in the Project configuration. The command specified +in `build` will be executed by the "watch" process before deployment. + ### Environment The following environment variables affect aws-cdk: diff --git a/packages/aws-cdk/lib/api/cxapp/exec.ts b/packages/aws-cdk/lib/api/cxapp/exec.ts index facaf24a3bfe0..bcc298deaeea2 100644 --- a/packages/aws-cdk/lib/api/cxapp/exec.ts +++ b/packages/aws-cdk/lib/api/cxapp/exec.ts @@ -46,6 +46,11 @@ export async function execProgram(aws: SdkProvider, config: Configuration): Prom debug('context:', context); env[cxapi.CONTEXT_ENV] = JSON.stringify(context); + const build = config.settings.get(['build']); + if (build) { + await exec(build); + } + const app = config.settings.get(['app']); if (!app) { throw new Error(`--app is required either in command-line, in ${PROJECT_CONFIG} or in ${USER_DEFAULTS}`); @@ -74,7 +79,7 @@ export async function execProgram(aws: SdkProvider, config: Configuration): Prom debug('env:', env); - await exec(); + await exec(commandLine.join(' ')); return createAssembly(outdir); @@ -91,7 +96,7 @@ export async function execProgram(aws: SdkProvider, config: Configuration): Prom } } - async function exec() { + async function exec(commandAndArgs: string) { return new Promise((ok, fail) => { // We use a slightly lower-level interface to: // @@ -103,7 +108,7 @@ export async function execProgram(aws: SdkProvider, config: Configuration): Prom // anyway, and if the subprocess is printing to it for debugging purposes the // user gets to see it sooner. Plus, capturing doesn't interact nicely with some // processes like Maven. - const proc = childProcess.spawn(commandLine[0], commandLine.slice(1), { + const proc = childProcess.spawn(commandAndArgs, { stdio: ['ignore', 'inherit', 'inherit'], detached: false, shell: true, diff --git a/packages/aws-cdk/lib/settings.ts b/packages/aws-cdk/lib/settings.ts index 4bfafd447d607..6182ecc26e0c3 100644 --- a/packages/aws-cdk/lib/settings.ts +++ b/packages/aws-cdk/lib/settings.ts @@ -113,6 +113,10 @@ export class Configuration { const readUserContext = this.props.readUserContext ?? true; + if (userConfig.get(['build'])) { + throw new Error('The `build` key cannot be specified in the user config (~/.cdk.json), specify it in the project config (cdk.json) instead'); + } + const contextSources = [ this.commandLineContext, this.projectConfig.subSettings([CONTEXT_KEY]).makeReadOnly(), diff --git a/packages/aws-cdk/test/api/exec.test.ts b/packages/aws-cdk/test/api/exec.test.ts index d2e65907527fa..b9c5637acce1a 100644 --- a/packages/aws-cdk/test/api/exec.test.ts +++ b/packages/aws-cdk/test/api/exec.test.ts @@ -137,7 +137,7 @@ test('the application set in --app is executed', async () => { // GIVEN config.settings.set(['app'], 'cloud-executable'); mockSpawn({ - commandLine: ['cloud-executable'], + commandLine: 'cloud-executable', sideEffect: () => writeOutputAssembly(), }); @@ -149,7 +149,7 @@ test('the application set in --app is executed as-is if it contains a filename t // GIVEN config.settings.set(['app'], 'does-not-exist'); mockSpawn({ - commandLine: ['does-not-exist'], + commandLine: 'does-not-exist', sideEffect: () => writeOutputAssembly(), }); @@ -161,7 +161,7 @@ test('the application set in --app is executed with arguments', async () => { // GIVEN config.settings.set(['app'], 'cloud-executable an-arg'); mockSpawn({ - commandLine: ['cloud-executable', 'an-arg'], + commandLine: 'cloud-executable an-arg', sideEffect: () => writeOutputAssembly(), }); @@ -174,7 +174,7 @@ test('application set in --app as `*.js` always uses handler on windows', async sinon.stub(process, 'platform').value('win32'); config.settings.set(['app'], 'windows.js'); mockSpawn({ - commandLine: [process.execPath, 'windows.js'], + commandLine: process.execPath + ' windows.js', sideEffect: () => writeOutputAssembly(), }); @@ -186,7 +186,7 @@ test('application set in --app is `*.js` and executable', async () => { // GIVEN config.settings.set(['app'], 'executable-app.js'); mockSpawn({ - commandLine: ['executable-app.js'], + commandLine: 'executable-app.js', sideEffect: () => writeOutputAssembly(), }); @@ -194,6 +194,36 @@ test('application set in --app is `*.js` and executable', async () => { await execProgram(sdkProvider, config); }); +test('cli throws when the `build` script fails', async () => { + // GIVEN + config.settings.set(['build'], 'fake-command'); + mockSpawn({ + commandLine: 'fake-command', + exitCode: 127, + }); + + // WHEN + await expect(execProgram(sdkProvider, config)).rejects.toEqual(new Error('Subprocess exited with error 127')); +}, TEN_SECOND_TIMEOUT); + +test('cli does not throw when the `build` script succeeds', async () => { + // GIVEN + config.settings.set(['build'], 'real command'); + config.settings.set(['app'], 'executable-app.js'); + mockSpawn({ + commandLine: 'real command', // `build` key is not split on whitespace + exitCode: 0, + }, + { + commandLine: 'executable-app.js', + sideEffect: () => writeOutputAssembly(), + }); + + // WHEN + await execProgram(sdkProvider, config); +}, TEN_SECOND_TIMEOUT); + + function writeOutputAssembly() { const asm = testAssembly({ stacks: [], diff --git a/packages/aws-cdk/test/usersettings.test.ts b/packages/aws-cdk/test/usersettings.test.ts index 948b3b3f907bc..92d7db4a44025 100644 --- a/packages/aws-cdk/test/usersettings.test.ts +++ b/packages/aws-cdk/test/usersettings.test.ts @@ -69,4 +69,24 @@ test('load context from all 3 files if available', async () => { expect(config.context.get('project')).toBe('foobar'); expect(config.context.get('foo')).toBe('bar'); expect(config.context.get('test')).toBe('bar'); +}); + +test('throws an error if the `build` key is specified in the user config', async () => { + // GIVEN + const GIVEN_CONFIG: Map = new Map([ + [USER_CONFIG, { + build: 'foobar', + }], + ]); + + // WHEN + mockedFs.pathExists.mockImplementation(path => { + return GIVEN_CONFIG.has(path); + }); + mockedFs.readJSON.mockImplementation(path => { + return GIVEN_CONFIG.get(path); + }); + + // THEN + await expect(new Configuration().load()).rejects.toEqual(new Error('The `build` key cannot be specified in the user config (~/.cdk.json), specify it in the project config (cdk.json) instead')); }); \ No newline at end of file diff --git a/packages/aws-cdk/test/util/mock-child_process.ts b/packages/aws-cdk/test/util/mock-child_process.ts index 539fd06273399..d7645b4c2ce1a 100644 --- a/packages/aws-cdk/test/util/mock-child_process.ts +++ b/packages/aws-cdk/test/util/mock-child_process.ts @@ -6,16 +6,11 @@ if (!(child_process as any).spawn.mockImplementationOnce) { } export interface Invocation { - commandLine: string[]; + commandLine: string; cwd?: string; exitCode?: number; stdout?: string; - /** - * Only match a prefix of the command (don't care about the details of the arguments) - */ - prefix?: boolean; - /** * Run this function as a side effect, if present */ @@ -26,14 +21,8 @@ export function mockSpawn(...invocations: Invocation[]) { let mock = (child_process.spawn as any); for (const _invocation of invocations) { const invocation = _invocation; // Mirror into variable for closure - mock = mock.mockImplementationOnce((binary: string, args: string[], options: child_process.SpawnOptions) => { - if (invocation.prefix) { - // Match command line prefix - expect([binary, ...args].slice(0, invocation.commandLine.length)).toEqual(invocation.commandLine); - } else { - // Match full command line - expect([binary, ...args]).toEqual(invocation.commandLine); - } + mock = mock.mockImplementationOnce((binary: string, options: child_process.SpawnOptions) => { + expect(binary).toEqual(invocation.commandLine); if (invocation.cwd != null) { expect(options.cwd).toBe(invocation.cwd); @@ -60,8 +49,8 @@ export function mockSpawn(...invocations: Invocation[]) { }); } - mock.mockImplementation((binary: string, args: string[], _options: any) => { - throw new Error(`Did not expect call of ${JSON.stringify([binary, ...args])}`); + mock.mockImplementation((binary: string, _options: any) => { + throw new Error(`Did not expect call of ${binary}`); }); }