Skip to content

Commit

Permalink
feat(cli): added build field to cdk.json (#17176)
Browse files Browse the repository at this point in the history
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
(aws/aws-cdk-rfcs#383).

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
comcalvi committed Nov 4, 2021
1 parent bc00427 commit 57ad1e0
Show file tree
Hide file tree
Showing 6 changed files with 79 additions and 24 deletions.
7 changes: 7 additions & 0 deletions packages/aws-cdk/README.md
Expand Up @@ -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"
},
Expand All @@ -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:
Expand Down
11 changes: 8 additions & 3 deletions packages/aws-cdk/lib/api/cxapp/exec.ts
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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);

Expand All @@ -91,7 +96,7 @@ export async function execProgram(aws: SdkProvider, config: Configuration): Prom
}
}

async function exec() {
async function exec(commandAndArgs: string) {
return new Promise<void>((ok, fail) => {
// We use a slightly lower-level interface to:
//
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/aws-cdk/lib/settings.ts
Expand Up @@ -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(),
Expand Down
40 changes: 35 additions & 5 deletions packages/aws-cdk/test/api/exec.test.ts
Expand Up @@ -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(),
});

Expand All @@ -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(),
});

Expand All @@ -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(),
});

Expand All @@ -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(),
});

Expand All @@ -186,14 +186,44 @@ 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(),
});

// WHEN
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: [],
Expand Down
20 changes: 20 additions & 0 deletions packages/aws-cdk/test/usersettings.test.ts
Expand Up @@ -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<string, any> = 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'));
});
21 changes: 5 additions & 16 deletions packages/aws-cdk/test/util/mock-child_process.ts
Expand Up @@ -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
*/
Expand All @@ -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);
Expand All @@ -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}`);
});
}

Expand Down

0 comments on commit 57ad1e0

Please sign in to comment.