Skip to content

Commit 09c8402

Browse files
authored
fix(toolkit-lib): fromAssemblyBuilder mutates globally shared process.env (#528)
The Toolkit communicates with the CDK app passing environment variables (containing context, and others). If the CDK app is executed as a subprocess it is trivial to set those environment variables when executing the subprocess; but for an in-memory synthesis as allowed by `fromAssemblyBuilder` mutating `process.env` is not the correct approach: it is clobbering global state, and multiple async assembly builders might fight with each other over the single shared global object. At the same time, it is *extreeeeeemely* convenient for an app author to not have to think about passing props. So we leave the current behavior as a convenient default, but add the option `clobberEnv: false` to explicitly disable this. In `fromAssemblyBuilder()`, we now pass an additional `env` prop containing the environment variables we applied (or didn't). There were already parametes for `outdir` and `context`; with `clobberEnv: false` those *must* now be passed to an `App` instantiated inside the builder (whereas otherwise the `App` will pick up on the globally mutated `process.env` changes). The rest of this PR is reorganizing the code to make the code that touches the environment return values instead of directly applying them. * Remove `withEnv()` and `withContext()`. * Move logging of the final context object out to the call site instead of the builder function. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent 6274e22 commit 09c8402

File tree

10 files changed

+332
-155
lines changed

10 files changed

+332
-155
lines changed
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-disable import/no-extraneous-dependencies */
22
/* eslint-disable import/no-relative-packages */
3-
export { prepareContext, prepareDefaultEnvironment } from '../../../aws-cdk/lib/api/cloud-assembly';
3+
export { contextFromSettings, prepareDefaultEnvironment } from '../../../aws-cdk/lib/api/cloud-assembly';
44
export { createAssembly } from '../../../aws-cdk/lib/cxapp/exec';
55
export { exec } from '../../../aws-cdk';
66
export { debug } from '../../../aws-cdk/lib/logging';
7+
export { synthParametersFromSettings, writeContextToEnv } from '../../../aws-cdk/lib/api/cloud-assembly';

packages/@aws-cdk/cli-lib-alpha/lib/cli.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { exec as runCli, debug, createAssembly, prepareContext, prepareDefaultEnvironment } from './aws-cdk';
1+
import { format } from 'util';
2+
import { exec as runCli, debug, createAssembly, prepareDefaultEnvironment, synthParametersFromSettings, writeContextToEnv } from './aws-cdk';
23
import type { SharedOptions, DeployOptions, DestroyOptions, BootstrapOptions, SynthOptions, ListOptions } from './commands';
34
import { StackActivityProgress, HotswapMode } from './commands';
45

@@ -122,10 +123,26 @@ export class AwsCdkCli implements IAwsCdkCli {
122123
public static fromCloudAssemblyDirectoryProducer(producer: ICloudAssemblyDirectoryProducer) {
123124
return new AwsCdkCli(async (args) => changeDir(
124125
() => runCli(args, async (sdk, config) => {
125-
const env = await prepareDefaultEnvironment(sdk, debugFn);
126-
const context = await prepareContext(config.settings, config.context.all, env, debugFn);
127-
128-
return withEnv(async() => createAssembly(await producer.produce(context)), env);
126+
const params = synthParametersFromSettings(config.settings);
127+
128+
const fullCtx = {
129+
...config.context.all,
130+
...params.context,
131+
};
132+
await debugFn(format('context:', fullCtx));
133+
134+
const env = {
135+
...process.env,
136+
...await prepareDefaultEnvironment(sdk, debugFn),
137+
...params.env,
138+
};
139+
140+
const cleanupContext = await writeContextToEnv(env, fullCtx);
141+
try {
142+
return await withEnv(async() => createAssembly(await producer.produce(fullCtx)), env);
143+
} finally {
144+
await cleanupContext();
145+
}
129146
}),
130147
producer.workingDirectory,
131148
));

packages/@aws-cdk/toolkit-lib/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,11 @@ const cx = await cdk.fromAssemblyBuilder(async () => {
331331
});
332332
```
333333

334+
> Note that synthesis using the `AssemblyBuilder` is not safe to be performed
335+
> concurrently, since it mutates the globally shared `process.env`. You can set
336+
> `clobberEnv: false` to avoid this, but your builder function will need to do a
337+
> bit more work. See the documentation of `clobberEnv` for an example.
338+
334339
Existing _Cloud Assembly_ directories can be used as source like this:
335340

336341
```ts

packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import * as path from 'path';
2-
import { format } from 'util';
32
import * as cxapi from '@aws-cdk/cx-api';
43
import * as fs from 'fs-extra';
54
import type { SdkProvider } from '../aws-auth/private';
@@ -36,20 +35,14 @@ export async function prepareDefaultEnvironment(
3635
}
3736

3837
/**
39-
* Settings related to synthesis are read from context.
40-
* The merging of various configuration sources like cli args or cdk.json has already happened.
41-
* We now need to set the final values to the context.
38+
* Create context from settings.
39+
*
40+
* Mutates the `context` object and returns it.
4241
*/
43-
export async function prepareContext(
42+
export function contextFromSettings(
4443
settings: Settings,
45-
context: { [key: string]: any },
46-
env: { [key: string]: string | undefined },
47-
debugFn: (msg: string) => Promise<void>,
4844
) {
49-
const debugMode: boolean = settings.get(['debug']) ?? true;
50-
if (debugMode) {
51-
env.CDK_DEBUG = 'true';
52-
}
45+
const context: Record<string, unknown> = {};
5346

5447
const pathMetadata: boolean = settings.get(['pathMetadata']) ?? true;
5548
if (pathMetadata) {
@@ -78,11 +71,23 @@ export async function prepareContext(
7871
const bundlingStacks = settings.get(['bundlingStacks']) ?? ['**'];
7972
context[cxapi.BUNDLING_STACKS] = bundlingStacks;
8073

81-
await debugFn(format('context:', context));
82-
8374
return context;
8475
}
8576

77+
/**
78+
* Convert settings to context/environment variables
79+
*/
80+
export function synthParametersFromSettings(settings: Settings) {
81+
return {
82+
context: contextFromSettings(settings),
83+
env: {
84+
// An environment variable instead of a context variable, so it can also
85+
// be accessed in framework code where we don't have access to a construct tree.
86+
...settings.get(['debug']) ? { CDK_DEBUG: 'true' } : {},
87+
},
88+
};
89+
}
90+
8691
export function spaceAvailableForContext(env: { [key: string]: string }, limit: number) {
8792
const size = (value: string) => value != null ? Buffer.byteLength(value) : 0;
8893

packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ type EventPublisher = (event: 'open' | 'data_stdout' | 'data_stderr' | 'close',
77

88
interface ExecOptions {
99
eventPublisher?: EventPublisher;
10-
extraEnv?: { [key: string]: string | undefined };
10+
env?: { [key: string]: string | undefined };
1111
cwd?: string;
1212
}
1313

@@ -29,10 +29,7 @@ export async function execInChildProcess(commandAndArgs: string, options: ExecOp
2929
detached: false,
3030
shell: true,
3131
cwd: options.cwd,
32-
env: {
33-
...process.env,
34-
...(options.extraEnv ?? {}),
35-
},
32+
env: options.env,
3633
});
3734

3835
const eventPublisher: EventPublisher = options.eventPublisher ?? ((type, line) => {

packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/prepare-source.ts

Lines changed: 35 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type { IReadLock, IWriteLock } from '../../rwlock';
1616
import { RWLock } from '../../rwlock';
1717
import { Settings } from '../../settings';
1818
import { loadTree, some } from '../../tree';
19-
import { prepareDefaultEnvironment as oldPrepare, prepareContext, spaceAvailableForContext, guessExecutable } from '../environment';
19+
import { prepareDefaultEnvironment as oldPrepare, spaceAvailableForContext, guessExecutable } from '../environment';
2020
import type { AppSynthOptions, LoadAssemblyOptions } from '../source-builder';
2121

2222
type Env = { [key: string]: string };
@@ -168,59 +168,45 @@ export class ExecutionEnvironment implements AsyncDisposable {
168168
}
169169
}
170170
}
171+
}
171172

172-
/**
173-
* Run code with additional environment variables
174-
*/
175-
public async withEnv<T>(env: Env = {}, block: () => Promise<T>) {
176-
const originalEnv = process.env;
177-
try {
178-
process.env = {
179-
...originalEnv,
180-
...env,
181-
};
182-
183-
return await block();
184-
} finally {
185-
process.env = originalEnv;
186-
}
187-
}
188-
189-
/**
190-
* Run code with context setup inside the environment
191-
*/
192-
public async withContext<T>(
193-
inputContext: Context,
194-
env: Env,
195-
synthOpts: AppSynthOptions = {},
196-
block: (env: Env, context: Context) => Promise<T>,
197-
) {
198-
const context = await prepareContext(synthOptsDefaults(synthOpts), inputContext, env, this.debugFn);
199-
let contextOverflowLocation = null;
173+
/**
174+
* Serializes the given context to a set if environment variables environment variables
175+
*
176+
* Needs to know the size of the rest of the env because that's necessary to do
177+
* an overflow computation on Windows. This function will mutate the given
178+
* environment in-place. It should be called as the very last operation on the
179+
* environment, because afterwards is might be at the maximum size.
180+
*
181+
* This *would* have returned an `IAsyncDisposable` but that requires messing
182+
* with TypeScript type definitions to use it in aws-cdk, so returning an
183+
* explicit cleanup function is easier.
184+
*/
185+
export function writeContextToEnv(env: Env, context: Context) {
186+
let contextOverflowLocation = null;
200187

201-
try {
202-
const envVariableSizeLimit = os.platform() === 'win32' ? 32760 : 131072;
203-
const [smallContext, overflow] = splitBySize(context, spaceAvailableForContext(env, envVariableSizeLimit));
188+
// On Windows, all envvars together must fit in a 32k block (<https://devblogs.microsoft.com/oldnewthing/20100203-00>)
189+
// On Linux, a single entry may not exceed 131k; but we're treating it as all together because that's safe
190+
// and it's a single execution path for both platforms.
191+
const envVariableSizeLimit = os.platform() === 'win32' ? 32760 : 131072;
192+
const [smallContext, overflow] = splitBySize(context, spaceAvailableForContext(env, envVariableSizeLimit));
204193

205-
// Store the safe part in the environment variable
206-
env[cxapi.CONTEXT_ENV] = JSON.stringify(smallContext);
194+
// Store the safe part in the environment variable
195+
env[cxapi.CONTEXT_ENV] = JSON.stringify(smallContext);
207196

208-
// If there was any overflow, write it to a temporary file
209-
if (Object.keys(overflow ?? {}).length > 0) {
210-
const contextDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-context'));
211-
contextOverflowLocation = path.join(contextDir, 'context-overflow.json');
212-
fs.writeJSONSync(contextOverflowLocation, overflow);
213-
env[cxapi.CONTEXT_OVERFLOW_LOCATION_ENV] = contextOverflowLocation;
214-
}
197+
// If there was any overflow, write it to a temporary file
198+
if (Object.keys(overflow ?? {}).length > 0) {
199+
const contextDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-context'));
200+
contextOverflowLocation = path.join(contextDir, 'context-overflow.json');
201+
fs.writeJSONSync(contextOverflowLocation, overflow);
202+
env[cxapi.CONTEXT_OVERFLOW_LOCATION_ENV] = contextOverflowLocation;
203+
}
215204

216-
// call the block code with new environment
217-
return await block(env, context);
218-
} finally {
219-
if (contextOverflowLocation) {
220-
fs.removeSync(path.dirname(contextOverflowLocation));
221-
}
205+
return async () => {
206+
if (contextOverflowLocation) {
207+
await fs.promises.rm(path.dirname(contextOverflowLocation), { recursive: true, force: true });
222208
}
223-
}
209+
};
224210
}
225211

226212
/**
@@ -270,7 +256,7 @@ export async function assemblyFromDirectory(assemblyDir: string, ioHelper: IoHel
270256
}
271257
}
272258

273-
function synthOptsDefaults(synthOpts: AppSynthOptions = {}): Settings {
259+
export function settingsFromSynthOptions(synthOpts: AppSynthOptions = {}): Settings {
274260
return new Settings({
275261
debug: false,
276262
pathMetadata: true,

0 commit comments

Comments
 (0)