Skip to content

Commit 994414c

Browse files
authored
fix(cli): --app command does not work when executing a command without arguments (#7249)
If the application passed with the `--app` parameter is executable, we were failing on a `fs.stat` call as it was not a file that was provided. This change swallows up that error and returns the provided command without modification. Also added some tests (thank you rix0rrr@ for getting this started) and some other minor improvements. Closes #6930
1 parent 7a892cc commit 994414c

File tree

5 files changed

+249
-28
lines changed

5 files changed

+249
-28
lines changed

packages/aws-cdk/lib/api/cxapp/exec.ts

+19-22
Original file line numberDiff line numberDiff line change
@@ -14,37 +14,26 @@ export async function execProgram(aws: SdkProvider, config: Configuration): Prom
1414
const context = config.context.all;
1515
await populateDefaultEnvironmentIfNeeded(aws, env);
1616

17-
let pathMetadata: boolean = config.settings.get(['pathMetadata']);
18-
if (pathMetadata === undefined) {
19-
pathMetadata = true; // defaults to true
20-
}
17+
const pathMetadata: boolean = config.settings.get(['pathMetadata']) ?? true;
2118

2219
if (pathMetadata) {
2320
context[cxapi.PATH_METADATA_ENABLE_CONTEXT] = true;
2421
}
2522

26-
let assetMetadata: boolean = config.settings.get(['assetMetadata']);
27-
if (assetMetadata === undefined) {
28-
assetMetadata = true; // defaults to true
29-
}
23+
const assetMetadata: boolean = config.settings.get(['assetMetadata']) ?? true;
3024

3125
if (assetMetadata) {
3226
context[cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT] = true;
3327
}
3428

35-
let versionReporting: boolean = config.settings.get(['versionReporting']);
36-
if (versionReporting === undefined) {
37-
versionReporting = true; // defaults to true
38-
}
29+
const versionReporting: boolean = config.settings.get(['versionReporting']) ?? true;
3930

4031
if (!versionReporting) {
4132
context[cxapi.DISABLE_VERSION_REPORTING] = true;
4233
}
4334

44-
let stagingEnabled = config.settings.get(['staging']);
45-
if (stagingEnabled === undefined) {
46-
stagingEnabled = true;
47-
}
35+
const stagingEnabled = config.settings.get(['staging']) ?? true;
36+
4837
if (!stagingEnabled) {
4938
context[cxapi.DISABLE_ASSET_STAGING_CONTEXT] = true;
5039
}
@@ -57,9 +46,9 @@ export async function execProgram(aws: SdkProvider, config: Configuration): Prom
5746
throw new Error(`--app is required either in command-line, in ${PROJECT_CONFIG} or in ${USER_DEFAULTS}`);
5847
}
5948

60-
// by pass "synth" if app points to a cloud assembly
49+
// bypass "synth" if app points to a cloud assembly
6150
if (await fs.pathExists(app) && (await fs.stat(app)).isDirectory()) {
62-
debug('--app points to a cloud assembly, so we by pass synth');
51+
debug('--app points to a cloud assembly, so we bypass synth');
6352
return new cxapi.CloudAssembly(app);
6453
}
6554

@@ -93,7 +82,7 @@ export async function execProgram(aws: SdkProvider, config: Configuration): Prom
9382
// (which would be different between Linux and Windows).
9483
//
9584
// - Inherit stderr from controlling terminal. We don't use the captured value
96-
// anway, and if the subprocess is printing to it for debugging purposes the
85+
// anyway, and if the subprocess is printing to it for debugging purposes the
9786
// user gets to see it sooner. Plus, capturing doesn't interact nicely with some
9887
// processes like Maven.
9988
const proc = childProcess.spawn(commandLine[0], commandLine.slice(1), {
@@ -121,10 +110,10 @@ export async function execProgram(aws: SdkProvider, config: Configuration): Prom
121110

122111
/**
123112
* If we don't have region/account defined in context, we fall back to the default SDK behavior
124-
* where region is retreived from ~/.aws/config and account is based on default credentials provider
113+
* where region is retrieved from ~/.aws/config and account is based on default credentials provider
125114
* chain and then STS is queried.
126115
*
127-
* This is done opportunistically: for example, if we can't acccess STS for some reason or the region
116+
* This is done opportunistically: for example, if we can't access STS for some reason or the region
128117
* is not configured, the context value will be 'null' and there could failures down the line. In
129118
* some cases, synthesis does not require region/account information at all, so that might be perfectly
130119
* fine in certain scenarios.
@@ -179,7 +168,15 @@ const EXTENSION_MAP = new Map<string, CommandGenerator>([
179168
*/
180169
async function guessExecutable(commandLine: string[]) {
181170
if (commandLine.length === 1) {
182-
const fstat = await fs.stat(commandLine[0]);
171+
let fstat;
172+
173+
try {
174+
fstat = await fs.stat(commandLine[0]);
175+
} catch (error) {
176+
debug(`Not a file: '${commandLine[0]}'. Using '${commandLine}' as command-line`);
177+
return commandLine;
178+
}
179+
183180
// tslint:disable-next-line:no-bitwise
184181
const isExecutable = (fstat.mode & fs.constants.X_OK) !== 0;
185182
const isWindows = process.platform === 'win32';
+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
jest.mock('child_process');
2+
import * as sinon from 'sinon';
3+
import { execProgram } from '../../lib/api/cxapp/exec';
4+
import { setVerbose } from '../../lib/logging';
5+
import { Configuration } from '../../lib/settings';
6+
import * as bockfs from '../bockfs';
7+
import { testAssembly } from '../util';
8+
import { mockSpawn } from '../util/mock-child_process';
9+
import { MockSdkProvider } from '../util/mock-sdk';
10+
11+
setVerbose(true);
12+
13+
let sdkProvider: MockSdkProvider;
14+
let config: Configuration;
15+
beforeEach(() => {
16+
sdkProvider = new MockSdkProvider();
17+
config = new Configuration();
18+
19+
config.settings.set(['output'], 'cdk.out');
20+
21+
// insert contents in fake filesystem
22+
bockfs({
23+
'/home/project/cloud-executable': 'ARBITRARY',
24+
'/home/project/windows.js': 'ARBITRARY',
25+
'home/project/executable-app.js': 'ARBITRARY'
26+
});
27+
bockfs.workingDirectory('/home/project');
28+
bockfs.executable('/home/project/cloud-executable');
29+
bockfs.executable('/home/project/executable-app.js');
30+
});
31+
32+
afterEach(() => {
33+
sinon.restore();
34+
bockfs.restore();
35+
});
36+
37+
test('validates --app key is present', async () => {
38+
// GIVEN no config key for `app`
39+
await expect(execProgram(sdkProvider, config)).rejects.toThrow(
40+
'--app is required either in command-line, in cdk.json or in ~/.cdk.json'
41+
);
42+
43+
});
44+
45+
test('bypasses synth when app points to a cloud assembly', async () => {
46+
// GIVEN
47+
config.settings.set(['app'], 'cdk.out');
48+
writeOutputAssembly();
49+
50+
// WHEN
51+
const cloudAssembly = await execProgram(sdkProvider, config);
52+
expect(cloudAssembly.artifacts).toEqual([]);
53+
expect(cloudAssembly.directory).toEqual('cdk.out');
54+
});
55+
56+
test('the application set in --app is executed', async () => {
57+
// GIVEN
58+
config.settings.set(['app'], 'cloud-executable');
59+
mockSpawn({
60+
commandLine: ['cloud-executable'],
61+
sideEffect: () => writeOutputAssembly(),
62+
});
63+
64+
// WHEN
65+
await execProgram(sdkProvider, config);
66+
});
67+
68+
test('the application set in --app is executed as-is if it contains a filename that does not exist', async () => {
69+
// GIVEN
70+
config.settings.set(['app'], 'does-not-exist');
71+
mockSpawn({
72+
commandLine: ['does-not-exist'],
73+
sideEffect: () => writeOutputAssembly(),
74+
});
75+
76+
// WHEN
77+
await execProgram(sdkProvider, config);
78+
});
79+
80+
test('the application set in --app is executed with arguments', async () => {
81+
// GIVEN
82+
config.settings.set(['app'], 'cloud-executable an-arg');
83+
mockSpawn({
84+
commandLine: ['cloud-executable', 'an-arg'],
85+
sideEffect: () => writeOutputAssembly(),
86+
});
87+
88+
// WHEN
89+
await execProgram(sdkProvider, config);
90+
});
91+
92+
test('application set in --app as `*.js` always uses handler on windows', async () => {
93+
// GIVEN
94+
sinon.stub(process, 'platform').value('win32');
95+
config.settings.set(['app'], 'windows.js');
96+
mockSpawn({
97+
commandLine: [process.execPath, 'windows.js'],
98+
sideEffect: () => writeOutputAssembly(),
99+
});
100+
101+
// WHEN
102+
await execProgram(sdkProvider, config);
103+
});
104+
105+
test('application set in --app is `*.js` and executable', async () => {
106+
// GIVEN
107+
config.settings.set(['app'], 'executable-app.js');
108+
mockSpawn({
109+
commandLine: ['executable-app.js'],
110+
sideEffect: () => writeOutputAssembly(),
111+
});
112+
113+
// WHEN
114+
await execProgram(sdkProvider, config);
115+
});
116+
117+
function writeOutputAssembly() {
118+
const asm = testAssembly({
119+
stacks: []
120+
});
121+
bockfs.write('/home/project/cdk.out/manifest.json', JSON.stringify(asm));
122+
}

packages/aws-cdk/test/bockfs.ts

+32-5
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,53 @@ import * as os from 'os';
1313
import * as path_ from 'path';
1414

1515
const bockFsRoot = path_.join(os.tmpdir(), 'bockfs');
16+
let oldCwd: string | undefined;
1617

1718
function bockfs(files: Record<string, string>) {
19+
oldCwd = process.cwd();
1820
for (const [fileName, contents] of Object.entries(files)) {
1921
bockfs.write(fileName, contents);
2022
}
2123
}
2224

2325
namespace bockfs {
24-
export function write(fileName: string, contents: string) {
25-
const fullPath = path(fileName);
26+
/**
27+
* Write contents to a fake file
28+
*/
29+
export function write(fakeFilename: string, contents: string) {
30+
const fullPath = path(fakeFilename);
2631
fs.mkdirSync(path_.dirname(fullPath), { recursive: true });
2732
fs.writeFileSync(fullPath, contents, { encoding: 'utf-8' });
2833
}
2934

30-
export function path(x: string) {
31-
if (x.startsWith('/')) { x = x.substr(1); } // Force path to be non-absolute
32-
return path_.join(bockFsRoot, x);
35+
/**
36+
* Turn a fake path into a real path
37+
*/
38+
export function path(fakePath: string) {
39+
if (fakePath.startsWith('/')) { fakePath = fakePath.substr(1); } // Force path to be non-absolute
40+
return path_.join(bockFsRoot, fakePath);
3341
}
3442

43+
/**
44+
* Change to a fake directory
45+
*/
46+
export function workingDirectory(fakePath: string) {
47+
process.chdir(path(fakePath));
48+
}
49+
50+
export function executable(...fakePaths: string[]) {
51+
for (const fakepath of fakePaths) {
52+
fs.chmodSync(path(fakepath), '755');
53+
}
54+
}
55+
56+
/**
57+
* Remove all files and restore working directory
58+
*/
3559
export function restore() {
60+
if (oldCwd) {
61+
process.chdir(oldCwd);
62+
}
3663
fs.removeSync(bockFsRoot);
3764
}
3865
}

packages/aws-cdk/test/util.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export class MockCloudExecutable extends CloudExecutable {
3838
}
3939
}
4040

41-
function testAssembly(assembly: TestAssembly): cxapi.CloudAssembly {
41+
export function testAssembly(assembly: TestAssembly): cxapi.CloudAssembly {
4242
const builder = new cxapi.CloudAssemblyBuilder();
4343

4444
for (const stack of assembly.stacks) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import * as child_process from 'child_process';
2+
import * as events from 'events';
3+
4+
if (!(child_process as any).spawn.mockImplementationOnce) {
5+
throw new Error('Call "jest.mock(\'child_process\');" at the top of the test file!');
6+
}
7+
8+
export interface Invocation {
9+
commandLine: string[];
10+
cwd?: string;
11+
exitCode?: number;
12+
stdout?: string;
13+
14+
/**
15+
* Only match a prefix of the command (don't care about the details of the arguments)
16+
*/
17+
prefix?: boolean;
18+
19+
/**
20+
* Run this function as a side effect, if present
21+
*/
22+
sideEffect?: () => void;
23+
}
24+
25+
export function mockSpawn(...invocations: Invocation[]) {
26+
let mock = (child_process.spawn as any);
27+
for (const _invocation of invocations) {
28+
const invocation = _invocation; // Mirror into variable for closure
29+
mock = mock.mockImplementationOnce((binary: string, args: string[], options: child_process.SpawnOptions) => {
30+
if (invocation.prefix) {
31+
// Match command line prefix
32+
expect([binary, ...args].slice(0, invocation.commandLine.length)).toEqual(invocation.commandLine);
33+
} else {
34+
// Match full command line
35+
expect([binary, ...args]).toEqual(invocation.commandLine);
36+
}
37+
38+
if (invocation.cwd != null) {
39+
expect(options.cwd).toBe(invocation.cwd);
40+
}
41+
42+
if (invocation.sideEffect) {
43+
invocation.sideEffect();
44+
}
45+
46+
const child: any = new events.EventEmitter();
47+
child.stdin = new events.EventEmitter();
48+
child.stdin.write = jest.fn();
49+
child.stdin.end = jest.fn();
50+
child.stdout = new events.EventEmitter();
51+
child.stderr = new events.EventEmitter();
52+
53+
if (invocation.stdout) {
54+
mockEmit(child.stdout, 'data', invocation.stdout);
55+
}
56+
mockEmit(child, 'close', invocation.exitCode ?? 0);
57+
mockEmit(child, 'exit', invocation.exitCode ?? 0);
58+
59+
return child;
60+
});
61+
}
62+
63+
mock.mockImplementation((binary: string, args: string[], _options: any) => {
64+
throw new Error(`Did not expect call of ${JSON.stringify([binary, ...args])}`);
65+
});
66+
}
67+
68+
/**
69+
* Must do this on the next tick, as emitter.emit() expects all listeners to have been attached already
70+
*/
71+
function mockEmit(emitter: events.EventEmitter, event: string, data: any) {
72+
setImmediate(() => {
73+
emitter.emit(event, data);
74+
});
75+
}

0 commit comments

Comments
 (0)