Skip to content

Commit 95971c0

Browse files
rix0rrrmrgrain
andauthored
feat(toolkit-lib)!: parameterize context storage (#534)
Previously, context was always being written to `cdk.context.json` in some directory, even for `fromAssemblyBuilder()` or `fromAssemblyDirectory()` which should probably both not write context anywhere. In this PR, we replace the context parameter to the toolkit with the concept of a `contextStore`, which has two operations: `read()` and `update()`. There are simple file and in-memory implementations for simple cases, as well as the more complex CDK app case which is implemented as `CdkAppMultiContext`. That last one is the default for `fromCdkApp()`. The complex one is implemented in terms of the prior `Context/ContextBag/Settings` classes, which are probably overly complex for the job this class is doing but keeping the old classes is an easy way to make sure that the behavior stays compatible. BREAKING CHANGES: `context` is now called `contextStore`. Message `CDK_ASSEMBLY_I0042` no longer knows about the filename where changes get written. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --------- Co-authored-by: Momo Kornher <kornherm@amazon.co.uk>
1 parent c48b685 commit 95971c0

File tree

15 files changed

+353
-82
lines changed

15 files changed

+353
-82
lines changed

packages/@aws-cdk/toolkit-lib/docs/message-registry.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu
135135
| `CDK_TOOLKIT_I0101` | A notice that is marked as informational | `info` | n/a |
136136
| `CDK_ASSEMBLY_I0010` | Generic environment preparation debug messages | `debug` | n/a |
137137
| `CDK_ASSEMBLY_W0010` | Emitted if the found framework version does not support context overflow | `warn` | n/a |
138-
| `CDK_ASSEMBLY_I0042` | Writing updated context | `debug` | {@link UpdatedContext} |
138+
| `CDK_ASSEMBLY_I0042` | Writing context updates | `debug` | {@link UpdatedContext} |
139139
| `CDK_ASSEMBLY_I0240` | Context lookup was stopped as no further progress was made. | `debug` | {@link MissingContext} |
140140
| `CDK_ASSEMBLY_I0241` | Fetching missing context. This is an iterative message that may appear multiple times with different missing keys. | `debug` | {@link MissingContext} |
141141
| `CDK_ASSEMBLY_I1000` | Cloud assembly output starts | `debug` | n/a |
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { promises as fs } from 'fs';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
import { ToolkitError } from '../../toolkit/toolkit-error';
5+
import type { ContextBag } from '../context';
6+
import { Context } from '../context';
7+
import { Settings, TRANSIENT_CONTEXT_KEY } from '../settings';
8+
9+
/**
10+
* A storage place for context used in synthesis
11+
*/
12+
export interface IContextStore {
13+
/**
14+
* Read the context from the context store, plus all updates we have made so far.
15+
*/
16+
read(): Promise<Record<string, unknown>>;
17+
18+
/**
19+
* Commit the given updates to the context store
20+
*
21+
* `undefined` is used as a value to indicate that the key needs to be removed.
22+
*
23+
* If a context value is an object that is a superset of `{ [TRANSIENT_CONTEXT_KEY]: true }`
24+
* it *should* be returned by subsequent `read()` operations on this object,
25+
* but it *should not* be persisted to permanent storage.
26+
*
27+
* You can use the `persistableContext()` function to filter a context dictionary
28+
* down to remove all values that shouldn't be persisted.
29+
*/
30+
update(updates: Record<string, unknown>): Promise<void>;
31+
}
32+
33+
/**
34+
* A context store as used by a CDK app.
35+
*
36+
* Will source context from the following locations:
37+
*
38+
* - Any context values passed to the constructor (expected
39+
* to come from the command line, treated as ephemeral).
40+
* - The `context` key in `<appDirectory>/cdk.json`.
41+
* - `<appDirectory>/cdk.context.json`.
42+
* - The `context` key in `~/.cdk.json`.
43+
*
44+
* Updates will be written to `<appDirectory>/cdk.context.json`.
45+
*/
46+
export class CdkAppMultiContext implements IContextStore {
47+
private _context?: Context;
48+
private configContextFile: string;
49+
private projectContextFile: string;
50+
private userConfigFile: string;
51+
52+
constructor(appDirectory: string, private readonly commandlineContext?: Record<string, unknown>) {
53+
this.configContextFile = path.join(appDirectory, 'cdk.json');
54+
this.projectContextFile = path.join(appDirectory, 'cdk.context.json');
55+
this.userConfigFile = path.join(os.homedir() ?? '/tmp', '.cdk.json');
56+
}
57+
58+
public async read(): Promise<Record<string, unknown>> {
59+
const context = await this.asyncInitialize();
60+
return context.all;
61+
}
62+
63+
public async update(updates: Record<string, unknown>): Promise<void> {
64+
const context = await this.asyncInitialize();
65+
for (const [key, value] of Object.entries(updates)) {
66+
context.set(key, value);
67+
}
68+
69+
await context.save(this.projectContextFile);
70+
}
71+
72+
/**
73+
* Initialize the `Context` object
74+
*
75+
* This code all exists to reuse code that's already there, to minimize
76+
* the chances of the new code behaving subtly differently than the
77+
* old code.
78+
*
79+
* It might be most of this is unnecessary now...
80+
*/
81+
private async asyncInitialize(): Promise<Context> {
82+
if (this._context) {
83+
return this._context;
84+
}
85+
86+
const contextSources: ContextBag[] = [
87+
{ bag: new Settings(this.commandlineContext, true) },
88+
{
89+
fileName: this.configContextFile,
90+
bag: (await settingsFromFile(this.configContextFile)).subSettings(['context']).makeReadOnly(),
91+
},
92+
{
93+
fileName: this.projectContextFile,
94+
bag: await settingsFromFile(this.projectContextFile),
95+
},
96+
{
97+
fileName: this.userConfigFile,
98+
bag: (await settingsFromFile(this.userConfigFile)).subSettings(['context']).makeReadOnly(),
99+
},
100+
];
101+
102+
this._context = new Context(...contextSources);
103+
return this._context;
104+
}
105+
}
106+
107+
/**
108+
* On-disk context stored in a single file
109+
*/
110+
export class FileContext implements IContextStore {
111+
private _cache?: Record<string, unknown>;
112+
113+
constructor(private readonly fileName: string) {
114+
}
115+
116+
public async read(): Promise<Record<string, unknown>> {
117+
if (!this._cache) {
118+
try {
119+
this._cache = JSON.parse(await fs.readFile(this.fileName, 'utf-8'));
120+
} catch (e: any) {
121+
if (e.code === 'ENOENT') {
122+
this._cache = {};
123+
} else {
124+
throw e;
125+
}
126+
}
127+
}
128+
if (!this._cache || typeof this._cache !== 'object') {
129+
throw new ToolkitError(`${this.fileName} must contain an object, got: ${JSON.stringify(this._cache)}`);
130+
}
131+
return this._cache;
132+
}
133+
134+
public async update(updates: Record<string, unknown>): Promise<void> {
135+
this._cache = {
136+
...await this.read(),
137+
...updates,
138+
};
139+
140+
const persistable = persistableContext(this._cache);
141+
await fs.writeFile(this.fileName, JSON.stringify(persistable, undefined, 2), 'utf-8');
142+
}
143+
}
144+
145+
/**
146+
* An in-memory context store
147+
*/
148+
export class MemoryContext implements IContextStore {
149+
private context: Record<string, unknown> = {};
150+
151+
constructor(initialContext?: Record<string, unknown>) {
152+
this.context = { ...initialContext };
153+
}
154+
155+
public read(): Promise<Record<string, unknown>> {
156+
return Promise.resolve(this.context);
157+
}
158+
159+
public update(updates: Record<string, unknown>): Promise<void> {
160+
this.context = {
161+
...this.context,
162+
...updates,
163+
};
164+
165+
return Promise.resolve();
166+
}
167+
}
168+
169+
/**
170+
* Filter the given context, leaving only entries that should be persisted
171+
*/
172+
export function persistableContext(context: Record<string, unknown>): Record<string, unknown> {
173+
return Object.fromEntries(Object.entries(context)
174+
.filter(([_, value]) => !isTransientValue(value)));
175+
}
176+
177+
function isTransientValue(x: unknown) {
178+
return x && typeof x === 'object' && (x as any)[TRANSIENT_CONTEXT_KEY];
179+
}
180+
181+
async function settingsFromFile(filename: string): Promise<Settings> {
182+
try {
183+
const data = JSON.parse(await fs.readFile(filename, 'utf-8'));
184+
return new Settings(data);
185+
} catch (e: any) {
186+
if (e.code === 'ENOENT') {
187+
return new Settings();
188+
}
189+
throw e;
190+
}
191+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export type { StackSelector } from './stack-selector';
33
export * from './cached-source';
44
export * from './source-builder';
55
export * from './types';
6+
export * from './context-store';

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

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import type { MissingContext } from '@aws-cdk/cloud-assembly-schema';
22
import * as contextproviders from '../../../context-providers';
33
import type { ToolkitServices } from '../../../toolkit/private';
44
import { ToolkitError } from '../../../toolkit/toolkit-error';
5-
import { PROJECT_CONTEXT, type Context } from '../../context';
65
import type { IoHelper } from '../../io/private';
76
import { IO } from '../../io/private';
7+
import type { IContextStore } from '../context-store';
88
import type { ICloudAssemblySource, IReadableCloudAssembly } from '../types';
99

1010
export interface ContextAwareCloudAssemblyProps {
@@ -15,16 +15,9 @@ export interface ContextAwareCloudAssemblyProps {
1515
readonly services: ToolkitServices;
1616

1717
/**
18-
* Application context
18+
* Location to read and write context
1919
*/
20-
readonly context: Context;
21-
22-
/**
23-
* The file used to store application context in (relative to cwd).
24-
*
25-
* @default "cdk.context.json"
26-
*/
27-
readonly contextFile?: string;
20+
readonly contextStore: IContextStore;
2821

2922
/**
3023
* Enable context lookups.
@@ -55,14 +48,12 @@ export interface ContextAwareCloudAssemblyProps {
5548
*/
5649
export class ContextAwareCloudAssemblySource implements ICloudAssemblySource {
5750
private canLookup: boolean;
58-
private context: Context;
59-
private contextFile: string;
51+
private context: IContextStore;
6052
private ioHelper: IoHelper;
6153

6254
constructor(private readonly source: ICloudAssemblySource, private readonly props: ContextAwareCloudAssemblyProps) {
6355
this.canLookup = props.lookups ?? true;
64-
this.context = props.context;
65-
this.contextFile = props.contextFile ?? PROJECT_CONTEXT; // @todo new feature not needed right now
56+
this.context = props.contextStore;
6657
this.ioHelper = props.services.ioHelper;
6758
}
6859

@@ -100,20 +91,17 @@ export class ContextAwareCloudAssemblySource implements ICloudAssemblySource {
10091

10192
if (tryLookup) {
10293
await this.ioHelper.notify(IO.CDK_ASSEMBLY_I0241.msg('Some context information is missing. Fetching...', { missingKeys }));
103-
await contextproviders.provideContextValues(
94+
const contextUpdates = await contextproviders.provideContextValues(
10495
assembly.manifest.missing,
105-
this.context,
10696
this.props.services.sdkProvider,
10797
this.props.services.pluginHost,
10898
this.ioHelper,
10999
);
110100

111-
// Cache the new context to disk
112-
await this.ioHelper.notify(IO.CDK_ASSEMBLY_I0042.msg(`Writing updated context to ${this.contextFile}...`, {
113-
contextFile: this.contextFile,
114-
context: this.context.all,
101+
await this.ioHelper.notify(IO.CDK_ASSEMBLY_I0042.msg('Writing context updates...', {
102+
context: contextUpdates,
115103
}));
116-
await this.context.save(this.contextFile);
104+
await this.context.update(contextUpdates);
117105

118106
// Execute again. Unlock the assembly here so that the producer can acquire
119107
// a read lock on the directory again.

0 commit comments

Comments
 (0)