Skip to content

Commit 7040168

Browse files
authoredOct 5, 2022
fix(cli): large context causes E2BIG error during synthesis on Linux (#21373)
Linux systems don't support environment variables larger than 128KiB. This change splits the context into two if it's larger than that and stores the overflow into a temporary file. The application then re-constructs the original context from these two sources. A special case is when this new version of the CLI is used to synthesize an application that depends on an old version of the framework. The application will still consume part of the context, but the CLI warns the user that some of it has been lost. Since the tree manipulation logic is basically the same as the one used for displaying notices, it was extracted into its own file. Re-roll #21230 Fixes #19261 ---- ### All Submissions: * [x] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) ### Adding new Unconventional Dependencies: * [ ] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md/#adding-new-unconventional-dependencies) ### New Features * [ ] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/main/INTEGRATION_TESTS.md)? * [ ] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* Instead of passing the context in an environment variable, the CLI now writes the context to a temporary file and sets an environment variable only with the location. The app then uses that location to read from the file. Also tested manually on a Linux machine. Re-roll #21230 Fixes #19261
1 parent 61b2ab7 commit 7040168

File tree

11 files changed

+344
-67
lines changed

11 files changed

+344
-67
lines changed
 

‎packages/@aws-cdk/core/lib/app.ts

+17-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as cxapi from '@aws-cdk/cx-api';
22
import { Construct } from 'constructs';
3+
import * as fs from 'fs-extra';
34
import { addCustomSynthesis, ICustomSynthesis } from './private/synthesis';
45
import { TreeMetadata } from './private/tree-metadata';
56
import { Stage } from './stage';
@@ -173,13 +174,13 @@ export class App extends Stage {
173174
this.node.setContext(k, v);
174175
}
175176

176-
// read from environment
177-
const contextJson = process.env[cxapi.CONTEXT_ENV];
178-
const contextFromEnvironment = contextJson
179-
? JSON.parse(contextJson)
180-
: { };
177+
// reconstructing the context from the two possible sources:
178+
const context = {
179+
...this.readContextFromEnvironment(),
180+
...this.readContextFromTempFile(),
181+
};
181182

182-
for (const [k, v] of Object.entries(contextFromEnvironment)) {
183+
for (const [k, v] of Object.entries(context)) {
183184
this.node.setContext(k, v);
184185
}
185186

@@ -188,6 +189,16 @@ export class App extends Stage {
188189
this.node.setContext(k, v);
189190
}
190191
}
192+
193+
private readContextFromTempFile() {
194+
const location = process.env[cxapi.CONTEXT_OVERFLOW_LOCATION_ENV];
195+
return location ? fs.readJSONSync(location) : {};
196+
}
197+
198+
private readContextFromEnvironment() {
199+
const contextJson = process.env[cxapi.CONTEXT_ENV];
200+
return contextJson ? JSON.parse(contextJson) : {};
201+
}
191202
}
192203

193204
/**

‎packages/@aws-cdk/core/test/app.test.ts

+34-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import * as os from 'os';
2+
import * as path from 'path';
13
import { ContextProvider } from '@aws-cdk/cloud-assembly-schema';
24
import * as cxapi from '@aws-cdk/cx-api';
35
import { Construct } from 'constructs';
6+
import * as fs from 'fs-extra';
47
import { CfnResource, DefaultStackSynthesizer, Stack, StackProps } from '../lib';
58
import { Annotations } from '../lib/annotations';
69
import { App, AppProps } from '../lib/app';
@@ -101,29 +104,55 @@ describe('app', () => {
101104
});
102105
});
103106

104-
test('context can be passed through CDK_CONTEXT', () => {
105-
process.env[cxapi.CONTEXT_ENV] = JSON.stringify({
107+
test('context can be passed through CONTEXT_OVERFLOW_LOCATION_ENV', async () => {
108+
const contextDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cdk-context'));
109+
const overflow = path.join(contextDir, 'overflow.json');
110+
fs.writeJSONSync(overflow, {
106111
key1: 'val1',
107112
key2: 'val2',
108113
});
114+
process.env[cxapi.CONTEXT_OVERFLOW_LOCATION_ENV] = overflow;
115+
109116
const prog = new App();
110117
expect(prog.node.tryGetContext('key1')).toEqual('val1');
111118
expect(prog.node.tryGetContext('key2')).toEqual('val2');
112119
});
113120

114-
test('context passed through CDK_CONTEXT has precedence', () => {
121+
test('context can be passed through CDK_CONTEXT', async () => {
115122
process.env[cxapi.CONTEXT_ENV] = JSON.stringify({
116123
key1: 'val1',
117124
key2: 'val2',
118125
});
126+
127+
const prog = new App();
128+
expect(prog.node.tryGetContext('key1')).toEqual('val1');
129+
expect(prog.node.tryGetContext('key2')).toEqual('val2');
130+
});
131+
132+
test('context passed through CONTEXT_OVERFLOW_LOCATION_ENV is merged with the context passed through CONTEXT_ENV', async () => {
133+
const contextDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cdk-context'));
134+
const contextLocation = path.join(contextDir, 'context-temp.json');
135+
fs.writeJSONSync(contextLocation, {
136+
key1: 'val1',
137+
key2: 'val2',
138+
});
139+
process.env[cxapi.CONTEXT_OVERFLOW_LOCATION_ENV] = contextLocation;
140+
141+
process.env[cxapi.CONTEXT_ENV] = JSON.stringify({
142+
key3: 'val3',
143+
key4: 'val4',
144+
});
145+
119146
const prog = new App({
120147
context: {
121-
key1: 'val3',
122-
key2: 'val4',
148+
key1: 'val5',
149+
key2: 'val6',
123150
},
124151
});
125152
expect(prog.node.tryGetContext('key1')).toEqual('val1');
126153
expect(prog.node.tryGetContext('key2')).toEqual('val2');
154+
expect(prog.node.tryGetContext('key3')).toEqual('val3');
155+
expect(prog.node.tryGetContext('key4')).toEqual('val4');
127156
});
128157

129158
test('context passed through finalContext prop has precedence', () => {

‎packages/@aws-cdk/cx-api/lib/cxapi.ts

+5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
export const OUTDIR_ENV = 'CDK_OUTDIR';
55
export const CONTEXT_ENV = 'CDK_CONTEXT_JSON';
66

7+
/**
8+
* The name of the temporary file where the context is stored.
9+
*/
10+
export const CONTEXT_OVERFLOW_LOCATION_ENV = 'CONTEXT_OVERFLOW_LOCATION_ENV';
11+
712
/**
813
* Environment variable set by the CDK CLI with the default AWS account ID.
914
*/

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

+55-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import * as childProcess from 'child_process';
2+
import * as os from 'os';
23
import * as path from 'path';
34
import * as cxschema from '@aws-cdk/cloud-assembly-schema';
45
import * as cxapi from '@aws-cdk/cx-api';
56
import * as fs from 'fs-extra';
6-
import { debug } from '../../logging';
7+
import * as semver from 'semver';
8+
import { debug, warning } from '../../logging';
79
import { Configuration, PROJECT_CONFIG, USER_DEFAULTS } from '../../settings';
10+
import { loadTree, some } from '../../tree';
11+
import { splitBySize } from '../../util/objects';
812
import { versionNumber } from '../../version';
913
import { SdkProvider } from '../aws-auth';
1014

@@ -44,7 +48,6 @@ export async function execProgram(aws: SdkProvider, config: Configuration): Prom
4448
context[cxapi.BUNDLING_STACKS] = bundlingStacks;
4549

4650
debug('context:', context);
47-
env[cxapi.CONTEXT_ENV] = JSON.stringify(context);
4851

4952
const build = config.settings.get(['build']);
5053
if (build) {
@@ -83,9 +86,28 @@ export async function execProgram(aws: SdkProvider, config: Configuration): Prom
8386

8487
debug('env:', env);
8588

89+
const envVariableSizeLimit = os.platform() === 'win32' ? 32760 : 131072;
90+
const [smallContext, overflow] = splitBySize(context, spaceAvailableForContext(env, envVariableSizeLimit));
91+
92+
// Store the safe part in the environment variable
93+
env[cxapi.CONTEXT_ENV] = JSON.stringify(smallContext);
94+
95+
// If there was any overflow, write it to a temporary file
96+
let contextOverflowLocation;
97+
if (Object.keys(overflow ?? {}).length > 0) {
98+
const contextDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cdk-context'));
99+
contextOverflowLocation = path.join(contextDir, 'context-overflow.json');
100+
fs.writeJSONSync(contextOverflowLocation, overflow);
101+
env[cxapi.CONTEXT_OVERFLOW_LOCATION_ENV] = contextOverflowLocation;
102+
}
103+
86104
await exec(commandLine.join(' '));
87105

88-
return createAssembly(outdir);
106+
const assembly = createAssembly(outdir);
107+
108+
contextOverflowCleanup(contextOverflowLocation, assembly);
109+
110+
return assembly;
89111

90112
function createAssembly(appDir: string) {
91113
try {
@@ -215,3 +237,33 @@ async function guessExecutable(commandLine: string[]) {
215237
}
216238
return commandLine;
217239
}
240+
241+
function contextOverflowCleanup(location: string | undefined, assembly: cxapi.CloudAssembly) {
242+
if (location) {
243+
fs.removeSync(path.dirname(location));
244+
245+
const tree = loadTree(assembly);
246+
const frameworkDoesNotSupportContextOverflow = some(tree, node => {
247+
const fqn = node.constructInfo?.fqn;
248+
const version = node.constructInfo?.version;
249+
return (fqn === 'aws-cdk-lib.App' && version != null && semver.lte(version, '2.38.0'))
250+
|| fqn === '@aws-cdk/core.App'; // v1
251+
});
252+
253+
// We're dealing with an old version of the framework here. It is unaware of the temporary
254+
// file, which means that it will ignore the context overflow.
255+
if (frameworkDoesNotSupportContextOverflow) {
256+
warning('Part of the context could not be sent to the application. Please update the AWS CDK library to the latest version.');
257+
}
258+
}
259+
}
260+
261+
function spaceAvailableForContext(env: { [key: string]: string }, limit: number) {
262+
const size = (value: string) => value != null ? Buffer.byteLength(value) : 0;
263+
264+
const usedSpace = Object.entries(env)
265+
.map(([k, v]) => k === cxapi.CONTEXT_ENV ? size(k) : size(k) + size(v))
266+
.reduce((a, b) => a + b, 0);
267+
268+
return Math.max(0, limit - usedSpace);
269+
}

‎packages/aws-cdk/lib/commands/doctor.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ function displayCdkEnvironmentVariables() {
5151
print('ℹ️ CDK environment variables:');
5252
let healthy = true;
5353
for (const key of keys.sort()) {
54-
if (key === cxapi.CONTEXT_ENV || key === cxapi.OUTDIR_ENV) {
54+
if (key === cxapi.CONTEXT_ENV || key === cxapi.CONTEXT_OVERFLOW_LOCATION_ENV || key === cxapi.OUTDIR_ENV) {
5555
print(` - ${chalk.red(key)} = ${chalk.green(process.env[key]!)} (⚠️ reserved for use by the CDK toolkit)`);
5656
healthy = false;
5757
} else {

‎packages/aws-cdk/lib/notices.ts

+3-50
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import * as https from 'https';
33
import * as path from 'path';
44
import * as fs from 'fs-extra';
55
import * as semver from 'semver';
6-
import { debug, print, trace } from './logging';
6+
import { debug, print } from './logging';
7+
import { some, ConstructTreeNode, loadTreeFromDir } from './tree';
78
import { flatMap } from './util';
89
import { cdkCacheDir } from './util/directories';
910
import { versionNumber } from './version';
@@ -79,7 +80,7 @@ export function filterNotices(data: Notice[], options: FilterNoticeOptions): Not
7980
const filter = new NoticeFilter({
8081
cliVersion: options.cliVersion ?? versionNumber(),
8182
acknowledgedIssueNumbers: options.acknowledgedIssueNumbers ?? new Set(),
82-
tree: loadTree(options.outdir ?? 'cdk.out').tree,
83+
tree: loadTreeFromDir(options.outdir ?? 'cdk.out'),
8384
});
8485
return data.filter(notice => filter.apply(notice));
8586
}
@@ -336,51 +337,3 @@ function match(query: Component[], tree: ConstructTreeNode): boolean {
336337
return semver.satisfies(target ?? '', pattern);
337338
}
338339
}
339-
340-
function loadTree(outdir: string) {
341-
try {
342-
return fs.readJSONSync(path.join(outdir, 'tree.json'));
343-
} catch (e) {
344-
trace(`Failed to get tree.json file: ${e}. Proceeding with empty tree.`);
345-
return {};
346-
}
347-
}
348-
349-
/**
350-
* Source information on a construct (class fqn and version)
351-
*/
352-
interface ConstructInfo {
353-
readonly fqn: string;
354-
readonly version: string;
355-
}
356-
357-
/**
358-
* A node in the construct tree.
359-
* @internal
360-
*/
361-
interface ConstructTreeNode {
362-
readonly id: string;
363-
readonly path: string;
364-
readonly children?: { [key: string]: ConstructTreeNode };
365-
readonly attributes?: { [key: string]: any };
366-
367-
/**
368-
* Information on the construct class that led to this node, if available
369-
*/
370-
readonly constructInfo?: ConstructInfo;
371-
}
372-
373-
function some(node: ConstructTreeNode, predicate: (n: ConstructTreeNode) => boolean): boolean {
374-
return node != null && (predicate(node) || findInChildren());
375-
376-
function findInChildren(): boolean {
377-
if (node.children == null) { return false; }
378-
379-
for (const name in node.children) {
380-
if (some(node.children[name], predicate)) {
381-
return true;
382-
}
383-
}
384-
return false;
385-
}
386-
}

‎packages/aws-cdk/lib/tree.ts

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import * as path from 'path';
2+
import { CloudAssembly } from '@aws-cdk/cx-api';
3+
import * as fs from 'fs-extra';
4+
import { trace } from './logging';
5+
6+
/**
7+
* Source information on a construct (class fqn and version)
8+
*/
9+
export interface ConstructInfo {
10+
readonly fqn: string;
11+
readonly version: string;
12+
}
13+
14+
/**
15+
* A node in the construct tree.
16+
*/
17+
export interface ConstructTreeNode {
18+
readonly id: string;
19+
readonly path: string;
20+
readonly children?: { [key: string]: ConstructTreeNode };
21+
readonly attributes?: { [key: string]: any };
22+
23+
/**
24+
* Information on the construct class that led to this node, if available
25+
*/
26+
readonly constructInfo?: ConstructInfo;
27+
}
28+
29+
/**
30+
* Whether the provided predicate is true for at least one element in the construct (sub-)tree.
31+
*/
32+
export function some(node: ConstructTreeNode, predicate: (n: ConstructTreeNode) => boolean): boolean {
33+
return node != null && (predicate(node) || findInChildren());
34+
35+
function findInChildren(): boolean {
36+
return Object.values(node.children ?? {}).some(child => some(child, predicate));
37+
}
38+
}
39+
40+
export function loadTree(assembly: CloudAssembly) {
41+
try {
42+
const outdir = assembly.directory;
43+
const fileName = assembly.tree()?.file;
44+
return fileName ? fs.readJSONSync(path.join(outdir, fileName)).tree : {};
45+
} catch (e) {
46+
trace(`Failed to get tree.json file: ${e}. Proceeding with empty tree.`);
47+
return {};
48+
}
49+
}
50+
51+
export function loadTreeFromDir(outdir: string) {
52+
try {
53+
return fs.readJSONSync(path.join(outdir, 'tree.json')).tree;
54+
} catch (e) {
55+
trace(`Failed to get tree.json file: ${e}. Proceeding with empty tree.`);
56+
return {};
57+
}
58+
}

‎packages/aws-cdk/lib/util/objects.ts

+36
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,39 @@ export function deepMerge(...objects: Array<Obj<any> | undefined>) {
134134
others.forEach(other => mergeOne(into, other));
135135
return into;
136136
}
137+
138+
/**
139+
* Splits the given object into two, such that:
140+
*
141+
* 1. The size of the first object (after stringified in UTF-8) is less than or equal to the provided size limit.
142+
* 2. Merging the two objects results in the original one.
143+
*/
144+
export function splitBySize(data: any, maxSizeBytes: number): [any, any] {
145+
if (maxSizeBytes < 2) {
146+
// It's impossible to fit anything in the first object
147+
return [undefined, data];
148+
}
149+
const entries = Object.entries(data);
150+
return recurse(0, 0);
151+
152+
function recurse(index: number, runningTotalSize: number): [any, any] {
153+
if (index >= entries.length) {
154+
// Everything fits in the first object
155+
return [data, undefined];
156+
}
157+
158+
const size = runningTotalSize + entrySize(entries[index]);
159+
return (size > maxSizeBytes) ? cutAt(index) : recurse(index + 1, size);
160+
}
161+
162+
function entrySize(entry: [string, unknown]) {
163+
return Buffer.byteLength(JSON.stringify(Object.fromEntries([entry])));
164+
}
165+
166+
function cutAt(index: number): [any, any] {
167+
return [
168+
Object.fromEntries(entries.slice(0, index)),
169+
Object.fromEntries(entries.slice(index)),
170+
];
171+
}
172+
}

‎packages/aws-cdk/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@
8888
"ts-jest": "^27.1.5",
8989
"ts-mock-imports": "^1.3.8",
9090
"xml-js": "^1.6.11",
91-
"axios": "^0.27.2"
91+
"axios": "^0.27.2",
92+
"fast-check": "^2.25.0"
9293
},
9394
"dependencies": {
9495
"@aws-cdk/cloud-assembly-schema": "0.0.0",

‎packages/aws-cdk/test/tree.test.ts

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import * as path from 'path';
2+
import { ConstructTreeNode, loadTreeFromDir, some } from '../lib/tree';
3+
4+
describe('some', () => {
5+
const tree: ConstructTreeNode = {
6+
id: 'App',
7+
path: '',
8+
children: {
9+
Tree: {
10+
id: 'Tree',
11+
path: 'Tree',
12+
constructInfo: {
13+
fqn: '@aws-cdk/core.Construct',
14+
version: '1.162.0',
15+
},
16+
},
17+
stack: {
18+
id: 'stack',
19+
path: 'stack',
20+
children: {
21+
bucket: {
22+
id: 'bucket',
23+
path: 'stack/bucket',
24+
children: {
25+
Resource: {
26+
id: 'Resource',
27+
path: 'stack/bucket/Resource',
28+
attributes: {
29+
'aws:cdk:cloudformation:type': 'AWS::S3::Bucket',
30+
'aws:cdk:cloudformation:props': {},
31+
},
32+
constructInfo: {
33+
fqn: '@aws-cdk/aws-s3.CfnBucket',
34+
version: '1.162.0',
35+
},
36+
},
37+
},
38+
constructInfo: {
39+
fqn: '@aws-cdk/aws-s3.Bucket',
40+
version: '1.162.0',
41+
},
42+
},
43+
CDKMetadata: {
44+
id: 'CDKMetadata',
45+
path: 'stack/CDKMetadata',
46+
children: {
47+
Default: {
48+
id: 'Default',
49+
path: 'stack/CDKMetadata/Default',
50+
constructInfo: {
51+
fqn: '@aws-cdk/core.CfnResource',
52+
version: '1.162.0',
53+
},
54+
},
55+
Condition: {
56+
id: 'Condition',
57+
path: 'stack/CDKMetadata/Condition',
58+
constructInfo: {
59+
fqn: '@aws-cdk/core.CfnCondition',
60+
version: '1.162.0',
61+
},
62+
},
63+
},
64+
constructInfo: {
65+
fqn: '@aws-cdk/core.Construct',
66+
version: '1.162.0',
67+
},
68+
},
69+
},
70+
constructInfo: {
71+
fqn: '@aws-cdk/core.Stack',
72+
version: '1.162.0',
73+
},
74+
},
75+
},
76+
constructInfo: {
77+
fqn: '@aws-cdk/core.App',
78+
version: '1.162.0',
79+
},
80+
};
81+
82+
test('tree matches predicate', () => {
83+
expect(some(tree, node => node.constructInfo?.fqn === '@aws-cdk/aws-s3.Bucket')).toBe(true);
84+
});
85+
86+
test('tree does not match predicate', () => {
87+
expect(some(tree, node => node.constructInfo?.fqn === '@aws-cdk/aws-lambda.Function')).toBe(false);
88+
});
89+
90+
test('childless tree', () => {
91+
const childless = {
92+
id: 'App',
93+
path: '',
94+
constructInfo: {
95+
fqn: '@aws-cdk/core.App',
96+
version: '1.162.0',
97+
},
98+
};
99+
100+
expect(some(childless, node => node.path.length > 0)).toBe(false);
101+
});
102+
});
103+
104+
describe('loadTreeFromDir', () => {
105+
test('can find tree', () => {
106+
const tree = loadTreeFromDir(path.join(__dirname, 'cloud-assembly-trees/built-with-1_144_0'));
107+
expect(tree.id).toEqual('App');
108+
});
109+
110+
test('cannot find tree', () => {
111+
const tree = loadTreeFromDir(path.join(__dirname, 'cloud-assembly-trees/foo'));
112+
expect(tree).toEqual({});
113+
});
114+
});

‎packages/aws-cdk/test/util/objects.test.ts

+19-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { deepClone, deepGet, deepMerge, deepSet } from '../../lib/util';
1+
import * as fc from 'fast-check';
2+
import { deepClone, deepGet, deepMerge, deepSet, splitBySize } from '../../lib/util';
23

34
test('deepSet can set deeply', () => {
45
const obj = {};
@@ -44,3 +45,20 @@ test('deepMerge does not overwrite if rightmost is "undefined"', () => {
4445

4546
expect(original).toEqual({ a: 1 });
4647
});
48+
49+
describe('splitBySize', () => {
50+
test('objects are split at the right place', () => {
51+
fc.assert(
52+
fc.property(fc.object(), fc.integer({ min: 2 }), (data, size) => {
53+
const [first, second] = splitBySize(data, size);
54+
55+
expect(Buffer.from(JSON.stringify(first)).length).toBeLessThanOrEqual(size);
56+
expect(merge(first, second)).toEqual(data);
57+
}),
58+
);
59+
60+
function merge(fst: any, snd: any) {
61+
return { ...(fst ?? {}), ...(snd ?? {}) };
62+
}
63+
});
64+
});

0 commit comments

Comments
 (0)
Please sign in to comment.