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}`); }); }