Skip to content

Commit 27aad24

Browse files
authored
refactor(toolkit): split notices code into separate files (#445)
No code changes. This is in preparation of #399 to make the diff more manageable. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent f065877 commit 27aad24

File tree

9 files changed

+651
-643
lines changed

9 files changed

+651
-643
lines changed

packages/@aws-cdk/toolkit-lib/lib/api/notices.ts

Lines changed: 0 additions & 627 deletions
This file was deleted.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import * as fs from 'fs-extra';
2+
import type { Notice, NoticeDataSource } from './types';
3+
import type { IoDefaultMessages } from '../io/private';
4+
5+
interface CachedNotices {
6+
expiration: number;
7+
notices: Notice[];
8+
}
9+
10+
const TIME_TO_LIVE_SUCCESS = 60 * 60 * 1000; // 1 hour
11+
const TIME_TO_LIVE_ERROR = 1 * 60 * 1000; // 1 minute
12+
13+
export class CachedDataSource implements NoticeDataSource {
14+
constructor(
15+
private readonly ioMessages: IoDefaultMessages,
16+
private readonly fileName: string,
17+
private readonly dataSource: NoticeDataSource,
18+
private readonly skipCache?: boolean) {
19+
}
20+
21+
async fetch(): Promise<Notice[]> {
22+
const cachedData = await this.load();
23+
const data = cachedData.notices;
24+
const expiration = cachedData.expiration ?? 0;
25+
26+
if (Date.now() > expiration || this.skipCache) {
27+
const freshData = await this.fetchInner();
28+
await this.save(freshData);
29+
return freshData.notices;
30+
} else {
31+
this.ioMessages.debug(`Reading cached notices from ${this.fileName}`);
32+
return data;
33+
}
34+
}
35+
36+
private async fetchInner(): Promise<CachedNotices> {
37+
try {
38+
return {
39+
expiration: Date.now() + TIME_TO_LIVE_SUCCESS,
40+
notices: await this.dataSource.fetch(),
41+
};
42+
} catch (e) {
43+
this.ioMessages.debug(`Could not refresh notices: ${e}`);
44+
return {
45+
expiration: Date.now() + TIME_TO_LIVE_ERROR,
46+
notices: [],
47+
};
48+
}
49+
}
50+
51+
private async load(): Promise<CachedNotices> {
52+
const defaultValue = {
53+
expiration: 0,
54+
notices: [],
55+
};
56+
57+
try {
58+
return fs.existsSync(this.fileName)
59+
? await fs.readJSON(this.fileName) as CachedNotices
60+
: defaultValue;
61+
} catch (e) {
62+
this.ioMessages.debug(`Failed to load notices from cache: ${e}`);
63+
return defaultValue;
64+
}
65+
}
66+
67+
private async save(cached: CachedNotices): Promise<void> {
68+
try {
69+
await fs.writeJSON(this.fileName, cached);
70+
} catch (e) {
71+
this.ioMessages.debug(`Failed to store notices in the cache: ${e}`);
72+
}
73+
}
74+
}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import * as semver from 'semver';
2+
import { IO, type IoDefaultMessages } from '../io/private';
3+
import type { ConstructTreeNode } from '../tree';
4+
import { loadTreeFromDir } from '../tree';
5+
import type { BootstrappedEnvironment, Component, Notice } from './types';
6+
7+
/**
8+
* Normalizes the given components structure into DNF form
9+
*/
10+
function normalizeComponents(xs: Array<Component | Component[]>): Component[][] {
11+
return xs.map(x => Array.isArray(x) ? x : [x]);
12+
}
13+
14+
function renderConjunction(xs: Component[]): string {
15+
return xs.map(c => `${c.name}: ${c.version}`).join(' AND ');
16+
}
17+
18+
interface ActualComponent {
19+
/**
20+
* Name of the component
21+
*/
22+
readonly name: string;
23+
24+
/**
25+
* Version of the component
26+
*/
27+
readonly version: string;
28+
29+
/**
30+
* If matched, under what name should it be added to the set of dynamic values
31+
*
32+
* These will be used to substitute placeholders in the message string, where
33+
* placeholders look like `{resolve:XYZ}`.
34+
*
35+
* If there is more than one component with the same dynamic name, they are
36+
* joined by ','.
37+
*
38+
* @default - Don't add to the set of dynamic values.
39+
*/
40+
readonly dynamicName?: string;
41+
42+
/**
43+
* If matched, what we should put in the set of dynamic values insstead of the version.
44+
*
45+
* Only used if `dynamicName` is set; by default we will add the actual version
46+
* of the component.
47+
*
48+
* @default - The version.
49+
*/
50+
readonly dynamicValue?: string;
51+
}
52+
53+
export interface NoticesFilterFilterOptions {
54+
readonly data: Notice[];
55+
readonly cliVersion: string;
56+
readonly outDir: string;
57+
readonly bootstrappedEnvironments: BootstrappedEnvironment[];
58+
}
59+
60+
export class NoticesFilter {
61+
constructor(private readonly ioMessages: IoDefaultMessages) {
62+
}
63+
64+
public filter(options: NoticesFilterFilterOptions): FilteredNotice[] {
65+
const components = [
66+
...this.constructTreeComponents(options.outDir),
67+
...this.otherComponents(options),
68+
];
69+
70+
return this.findForNamedComponents(options.data, components);
71+
}
72+
73+
/**
74+
* From a set of input options, return the notices components we are searching for
75+
*/
76+
private otherComponents(options: NoticesFilterFilterOptions): ActualComponent[] {
77+
return [
78+
// CLI
79+
{
80+
name: 'cli',
81+
version: options.cliVersion,
82+
},
83+
84+
// Node version
85+
{
86+
name: 'node',
87+
version: process.version.replace(/^v/, ''), // remove the 'v' prefix.
88+
dynamicName: 'node',
89+
},
90+
91+
// Bootstrap environments
92+
...options.bootstrappedEnvironments.flatMap(env => {
93+
const semverBootstrapVersion = semver.coerce(env.bootstrapStackVersion);
94+
if (!semverBootstrapVersion) {
95+
// we don't throw because notices should never crash the cli.
96+
this.ioMessages.warning(`While filtering notices, could not coerce bootstrap version '${env.bootstrapStackVersion}' into semver`);
97+
return [];
98+
}
99+
100+
return [{
101+
name: 'bootstrap',
102+
version: `${semverBootstrapVersion}`,
103+
dynamicName: 'ENVIRONMENTS',
104+
dynamicValue: env.environment.name,
105+
}];
106+
}),
107+
];
108+
}
109+
110+
/**
111+
* Based on a set of component names, find all notices that match one of the given components
112+
*/
113+
private findForNamedComponents(data: Notice[], actualComponents: ActualComponent[]): FilteredNotice[] {
114+
return data.flatMap(notice => {
115+
const ors = this.resolveAliases(normalizeComponents(notice.components));
116+
117+
// Find the first set of the disjunctions of which all components match against the actual components.
118+
// Return the actual components we found so that we can inject their dynamic values. A single filter
119+
// component can match more than one actual component
120+
for (const ands of ors) {
121+
const matched = ands.map(affected => actualComponents.filter(actual =>
122+
this.componentNameMatches(affected, actual) && semver.satisfies(actual.version, affected.version, { includePrerelease: true })));
123+
124+
// For every clause in the filter we matched one or more components
125+
if (matched.every(xs => xs.length > 0)) {
126+
const ret = new FilteredNotice(notice);
127+
this.addDynamicValues(matched.flatMap(x => x), ret);
128+
return [ret];
129+
}
130+
}
131+
132+
return [];
133+
});
134+
}
135+
136+
/**
137+
* Whether the given "affected component" name applies to the given actual component name.
138+
*
139+
* The name matches if the name is exactly the same, or the name in the notice
140+
* is a prefix of the node name when the query ends in '.'.
141+
*/
142+
private componentNameMatches(pattern: Component, actual: ActualComponent): boolean {
143+
return pattern.name.endsWith('.') ? actual.name.startsWith(pattern.name) : pattern.name === actual.name;
144+
}
145+
146+
/**
147+
* Adds dynamic values from the given ActualComponents
148+
*
149+
* If there are multiple components with the same dynamic name, they are joined
150+
* by a comma.
151+
*/
152+
private addDynamicValues(comps: ActualComponent[], notice: FilteredNotice) {
153+
const dynamicValues: Record<string, string[]> = {};
154+
for (const comp of comps) {
155+
if (comp.dynamicName) {
156+
dynamicValues[comp.dynamicName] = dynamicValues[comp.dynamicName] ?? [];
157+
dynamicValues[comp.dynamicName].push(comp.dynamicValue ?? comp.version);
158+
}
159+
}
160+
for (const [key, values] of Object.entries(dynamicValues)) {
161+
notice.addDynamicValue(key, values.join(','));
162+
}
163+
}
164+
165+
/**
166+
* Treat 'framework' as an alias for either `aws-cdk-lib.` or `@aws-cdk/core.`.
167+
*
168+
* Because it's EITHER `aws-cdk-lib` or `@aws-cdk/core`, we need to add multiple
169+
* arrays at the top level.
170+
*/
171+
private resolveAliases(ors: Component[][]): Component[][] {
172+
return ors.flatMap(ands => {
173+
const hasFramework = ands.find(c => c.name === 'framework');
174+
if (!hasFramework) {
175+
return [ands];
176+
}
177+
178+
return [
179+
ands.map(c => c.name === 'framework' ? { ...c, name: '@aws-cdk/core.' } : c),
180+
ands.map(c => c.name === 'framework' ? { ...c, name: 'aws-cdk-lib.' } : c),
181+
];
182+
});
183+
}
184+
185+
/**
186+
* Load the construct tree from the given directory and return its components
187+
*/
188+
private constructTreeComponents(manifestDir: string): ActualComponent[] {
189+
const tree = loadTreeFromDir(manifestDir, (msg: string) => void this.ioMessages.notify(IO.DEFAULT_ASSEMBLY_TRACE.msg(msg)));
190+
if (!tree) {
191+
return [];
192+
}
193+
194+
const ret: ActualComponent[] = [];
195+
recurse(tree);
196+
return ret;
197+
198+
function recurse(x: ConstructTreeNode) {
199+
if (x.constructInfo?.fqn && x.constructInfo?.version) {
200+
ret.push({
201+
name: x.constructInfo?.fqn,
202+
version: x.constructInfo?.version,
203+
});
204+
}
205+
206+
for (const child of Object.values(x.children ?? {})) {
207+
recurse(child);
208+
}
209+
}
210+
}
211+
}
212+
213+
/**
214+
* Notice after passing the filter. A filter can augment a notice with
215+
* dynamic values as it has access to the dynamic matching data.
216+
*/
217+
export class FilteredNotice {
218+
private readonly dynamicValues: { [key: string]: string } = {};
219+
220+
public constructor(public readonly notice: Notice) {
221+
}
222+
223+
public addDynamicValue(key: string, value: string) {
224+
this.dynamicValues[`{resolve:${key}}`] = value;
225+
}
226+
227+
public format(): string {
228+
const componentsValue = normalizeComponents(this.notice.components).map(renderConjunction).join(', ');
229+
return this.resolveDynamicValues([
230+
`${this.notice.issueNumber}\t${this.notice.title}`,
231+
this.formatOverview(),
232+
`\tAffected versions: ${componentsValue}`,
233+
`\tMore information at: https://github.com/aws/aws-cdk/issues/${this.notice.issueNumber}`,
234+
].join('\n\n') + '\n');
235+
}
236+
237+
private formatOverview() {
238+
const wrap = (s: string) => s.replace(/(?![^\n]{1,60}$)([^\n]{1,60})\s/g, '$1\n');
239+
240+
const heading = 'Overview: ';
241+
const separator = `\n\t${' '.repeat(heading.length)}`;
242+
const content = wrap(this.notice.overview)
243+
.split('\n')
244+
.join(separator);
245+
246+
return '\t' + heading + content;
247+
}
248+
249+
private resolveDynamicValues(input: string): string {
250+
const pattern = new RegExp(Object.keys(this.dynamicValues).join('|'), 'g');
251+
return input.replace(pattern, (matched) => this.dynamicValues[matched] ?? matched);
252+
}
253+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './notices';

0 commit comments

Comments
 (0)