Skip to content

Commit 757ae69

Browse files
authored
feat(cli): can match notices against Node version (#128)
This allows publishing notices that match against the version of Node we're executing on. Implemented by generalizing the code that matches against CLI version and bootstrap versions: it can now match against arbitrary named components. Also implement more complex matching rules: currently we match the pattern if one of them matches: ```ts // Matches if one of A, B or C matches components: [A, B, C] ``` Instead, generalize to Disjunctive Normal Form and treat the current case as a special case of DNF where every conjunction has one element: ```ts // The above gets interpreted as components: [[A], [B], [C]] // More complex rules: A and B together, or C components: [[A, B], [C]] ``` This way we can write rules to say that "component X on Node version Y" should get a notice. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent 2a57bc9 commit 757ae69

File tree

4 files changed

+312
-100
lines changed

4 files changed

+312
-100
lines changed

packages/aws-cdk/lib/notices.ts

Lines changed: 187 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ import type { Context } from './api/context';
1111
import { versionNumber } from './cli/version';
1212
import { debug, info, warning, error } from './logging';
1313
import { ToolkitError } from './toolkit/error';
14-
import { loadTreeFromDir, some } from './tree';
15-
import { flatMap } from './util';
14+
import { ConstructTreeNode, loadTreeFromDir } from './tree';
1615
import { cdkCacheDir } from './util/directories';
1716
import { formatErrorMessage } from './util/format-error';
1817

@@ -84,116 +83,189 @@ export interface NoticesFilterFilterOptions {
8483
readonly bootstrappedEnvironments: BootstrappedEnvironment[];
8584
}
8685

87-
export class NoticesFilter {
86+
export abstract class NoticesFilter {
8887
public static filter(options: NoticesFilterFilterOptions): FilteredNotice[] {
89-
return [
90-
...this.findForCliVersion(options.data, options.cliVersion),
91-
...this.findForFrameworkVersion(options.data, options.outDir),
92-
...this.findForBootstrapVersion(options.data, options.bootstrappedEnvironments),
88+
const components = [
89+
...NoticesFilter.constructTreeComponents(options.outDir),
90+
...NoticesFilter.otherComponents(options),
9391
];
92+
93+
return NoticesFilter.findForNamedComponents(options.data, components);
9494
}
9595

96-
private static findForCliVersion(data: Notice[], cliVersion: string): FilteredNotice[] {
97-
return flatMap(data, notice => {
98-
const affectedComponent = notice.components.find(component => component.name === 'cli');
99-
const affectedRange = affectedComponent?.version;
96+
/**
97+
* From a set of input options, return the notices components we are searching for
98+
*/
99+
private static otherComponents(options: NoticesFilterFilterOptions): ActualComponent[] {
100+
return [
101+
// CLI
102+
{
103+
name: 'cli',
104+
version: options.cliVersion,
105+
},
106+
107+
// Node version
108+
{
109+
name: 'node',
110+
version: process.version.replace(/^v/, ''), // remove the 'v' prefix.
111+
dynamicName: 'node',
112+
},
113+
114+
// Bootstrap environments
115+
...options.bootstrappedEnvironments.flatMap(env => {
116+
const semverBootstrapVersion = semver.coerce(env.bootstrapStackVersion);
117+
if (!semverBootstrapVersion) {
118+
// we don't throw because notices should never crash the cli.
119+
warning(`While filtering notices, could not coerce bootstrap version '${env.bootstrapStackVersion}' into semver`);
120+
return [];
121+
}
100122

101-
if (affectedRange == null) {
102-
return [];
103-
}
123+
return [{
124+
name: 'bootstrap',
125+
version: `${semverBootstrapVersion}`,
126+
dynamicName: 'ENVIRONMENTS',
127+
dynamicValue: env.environment.name,
128+
}];
129+
}),
130+
];
131+
}
104132

105-
if (!semver.satisfies(cliVersion, affectedRange)) {
106-
return [];
133+
/**
134+
* Based on a set of component names, find all notices that match one of the given components
135+
*/
136+
private static findForNamedComponents(data: Notice[], actualComponents: ActualComponent[]): FilteredNotice[] {
137+
return data.flatMap(notice => {
138+
const ors = this.resolveAliases(normalizeComponents(notice.components));
139+
140+
// Find the first set of the disjunctions of which all components match against the actual components.
141+
// Return the actual components we found so that we can inject their dynamic values. A single filter
142+
// component can match more than one actual component
143+
for (const ands of ors) {
144+
const matched = ands.map(affected => actualComponents.filter(actual =>
145+
NoticesFilter.componentNameMatches(affected, actual) && semver.satisfies(actual.version, affected.version)));
146+
147+
// For every clause in the filter we matched one or more components
148+
if (matched.every(xs => xs.length > 0)) {
149+
const ret = new FilteredNotice(notice);
150+
NoticesFilter.addDynamicValues(matched.flatMap(x => x), ret);
151+
return [ret];
152+
}
107153
}
108154

109-
return [new FilteredNotice(notice)];
155+
return [];
110156
});
111157
}
112158

113-
private static findForFrameworkVersion(data: Notice[], outDir: string): FilteredNotice[] {
114-
const tree = loadTreeFromDir(outDir);
115-
return flatMap(data, notice => {
116-
// A match happens when:
117-
//
118-
// 1. The version of the node matches the version in the notice, interpreted
119-
// as a semver range.
120-
//
121-
// AND
122-
//
123-
// 2. The name in the notice is a prefix of the node name when the query ends in '.',
124-
// or the two names are exactly the same, otherwise.
125-
126-
const matched = some(tree, node => {
127-
return this.resolveAliases(notice.components).some(component =>
128-
compareNames(component.name, node.constructInfo?.fqn) &&
129-
compareVersions(component.version, node.constructInfo?.version));
130-
});
131-
132-
if (!matched) {
133-
return [];
134-
}
135-
136-
return [new FilteredNotice(notice)];
159+
/**
160+
* Whether the given "affected component" name applies to the given actual component name.
161+
*
162+
* The name matches if the name is exactly the same, or the name in the notice
163+
* is a prefix of the node name when the query ends in '.'.
164+
*/
165+
private static componentNameMatches(pattern: Component, actual: ActualComponent): boolean {
166+
return pattern.name.endsWith('.') ? actual.name.startsWith(pattern.name) : pattern.name === actual.name;
167+
}
137168

138-
function compareNames(pattern: string, target: string | undefined): boolean {
139-
if (target == null) {
140-
return false;
141-
}
142-
return pattern.endsWith('.') ? target.startsWith(pattern) : pattern === target;
169+
/**
170+
* Adds dynamic values from the given ActualComponents
171+
*
172+
* If there are multiple components with the same dynamic name, they are joined
173+
* by a comma.
174+
*/
175+
private static addDynamicValues(comps: ActualComponent[], notice: FilteredNotice) {
176+
const dynamicValues: Record<string, string[]> = {};
177+
for (const comp of comps) {
178+
if (comp.dynamicName) {
179+
dynamicValues[comp.dynamicName] = dynamicValues[comp.dynamicName] ?? [];
180+
dynamicValues[comp.dynamicName].push(comp.dynamicValue ?? comp.version);
143181
}
182+
}
183+
for (const [key, values] of Object.entries(dynamicValues)) {
184+
notice.addDynamicValue(key, values.join(','));
185+
}
186+
}
144187

145-
function compareVersions(pattern: string, target: string | undefined): boolean {
146-
return semver.satisfies(target ?? '', pattern);
188+
/**
189+
* Treat 'framework' as an alias for either `aws-cdk-lib.` or `@aws-cdk/core.`.
190+
*
191+
* Because it's EITHER `aws-cdk-lib` or `@aws-cdk/core`, we need to add multiple
192+
* arrays at the top level.
193+
*/
194+
private static resolveAliases(ors: Component[][]): Component[][] {
195+
return ors.flatMap(ands => {
196+
const hasFramework = ands.find(c => c.name === 'framework');
197+
if (!hasFramework) {
198+
return [ands];
147199
}
200+
201+
return [
202+
ands.map(c => c.name === 'framework' ? { ...c, name: '@aws-cdk/core.' } : c),
203+
ands.map(c => c.name === 'framework' ? { ...c, name: 'aws-cdk-lib.' } : c),
204+
];
148205
});
149206
}
150207

151-
private static findForBootstrapVersion(data: Notice[], bootstrappedEnvironments: BootstrappedEnvironment[]): FilteredNotice[] {
152-
return flatMap(data, notice => {
153-
const affectedComponent = notice.components.find(component => component.name === 'bootstrap');
154-
const affectedRange = affectedComponent?.version;
208+
/**
209+
* Load the construct tree from the given directory and return its components
210+
*/
211+
private static constructTreeComponents(manifestDir: string): ActualComponent[] {
212+
const tree = loadTreeFromDir(manifestDir);
213+
if (!tree) {
214+
return [];
215+
}
155216

156-
if (affectedRange == null) {
157-
return [];
158-
}
217+
const ret: ActualComponent[] = [];
218+
recurse(tree);
219+
return ret;
159220

160-
const affected = bootstrappedEnvironments.filter(i => {
161-
const semverBootstrapVersion = semver.coerce(i.bootstrapStackVersion);
162-
if (!semverBootstrapVersion) {
163-
// we don't throw because notices should never crash the cli.
164-
warning(`While filtering notices, could not coerce bootstrap version '${i.bootstrapStackVersion}' into semver`);
165-
return false;
166-
}
167-
168-
return semver.satisfies(semverBootstrapVersion, affectedRange);
169-
});
221+
function recurse(x: ConstructTreeNode) {
222+
if (x.constructInfo?.fqn && x.constructInfo?.version) {
223+
ret.push({
224+
name: x.constructInfo?.fqn,
225+
version: x.constructInfo?.version,
226+
});
227+
}
170228

171-
if (affected.length === 0) {
172-
return [];
229+
for (const child of Object.values(x.children ?? {})) {
230+
recurse(child);
173231
}
232+
}
233+
}
234+
}
174235

175-
const filtered = new FilteredNotice(notice);
176-
filtered.addDynamicValue('ENVIRONMENTS', affected.map(s => s.environment.name).join(','));
236+
interface ActualComponent {
237+
/**
238+
* Name of the component
239+
*/
240+
readonly name: string;
177241

178-
return [filtered];
179-
});
180-
}
242+
/**
243+
* Version of the component
244+
*/
245+
readonly version: string;
181246

182-
private static resolveAliases(components: Component[]): Component[] {
183-
return flatMap(components, component => {
184-
if (component.name === 'framework') {
185-
return [{
186-
name: '@aws-cdk/core.',
187-
version: component.version,
188-
}, {
189-
name: 'aws-cdk-lib.',
190-
version: component.version,
191-
}];
192-
} else {
193-
return [component];
194-
}
195-
});
196-
}
247+
/**
248+
* If matched, under what name should it be added to the set of dynamic values
249+
*
250+
* These will be used to substitute placeholders in the message string, where
251+
* placeholders look like `{resolve:XYZ}`.
252+
*
253+
* If there is more than one component with the same dynamic name, they are
254+
* joined by ','.
255+
*
256+
* @default - Don't add to the set of dynamic values.
257+
*/
258+
readonly dynamicName?: string;
259+
260+
/**
261+
* If matched, what we should put in the set of dynamic values insstead of the version.
262+
*
263+
* Only used if `dynamicName` is set; by default we will add the actual version
264+
* of the component.
265+
*
266+
* @default - The version.
267+
*/
268+
readonly dynamicValue?: string;
197269
}
198270

199271
/**
@@ -327,18 +399,44 @@ export class Notices {
327399

328400
export interface Component {
329401
name: string;
402+
403+
/**
404+
* The range of affected versions
405+
*/
330406
version: string;
331407
}
332408

333409
export interface Notice {
334410
title: string;
335411
issueNumber: number;
336412
overview: string;
337-
components: Component[];
413+
/**
414+
* A set of affected components
415+
*
416+
* The canonical form of a list of components is in Disjunctive Normal Form
417+
* (i.e., an OR of ANDs). This is the form when the list of components is a
418+
* doubly nested array: the notice matches if all components of at least one
419+
* of the top-level array matches.
420+
*
421+
* If the `components` is a single-level array, it is evaluated as an OR; it
422+
* matches if any of the components matches.
423+
*/
424+
components: Array<Component | Component[]>;
338425
schemaVersion: string;
339426
severity?: string;
340427
}
341428

429+
/**
430+
* Normalizes the given components structure into DNF form
431+
*/
432+
function normalizeComponents(xs: Array<Component | Component[]>): Component[][] {
433+
return xs.map(x => Array.isArray(x) ? x : [x]);
434+
}
435+
436+
function renderConjunction(xs: Component[]): string {
437+
return xs.map(c => `${c.name}: ${c.version}`).join(' AND ');
438+
}
439+
342440
/**
343441
* Notice after passing the filter. A filter can augment a notice with
344442
* dynamic values as it has access to the dynamic matching data.
@@ -354,7 +452,7 @@ export class FilteredNotice {
354452
}
355453

356454
public format(): string {
357-
const componentsValue = this.notice.components.map(c => `${c.name}: ${c.version}`).join(', ');
455+
const componentsValue = normalizeComponents(this.notice.components).map(renderConjunction).join(', ');
358456
return this.resolveDynamicValues([
359457
`${this.notice.issueNumber}\t${this.notice.title}`,
360458
this.formatOverview(),

packages/aws-cdk/lib/tree.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,30 +29,30 @@ export interface ConstructTreeNode {
2929
/**
3030
* Whether the provided predicate is true for at least one element in the construct (sub-)tree.
3131
*/
32-
export function some(node: ConstructTreeNode, predicate: (n: ConstructTreeNode) => boolean): boolean {
32+
export function some(node: ConstructTreeNode | undefined, predicate: (n: ConstructTreeNode) => boolean): boolean {
3333
return node != null && (predicate(node) || findInChildren());
3434

3535
function findInChildren(): boolean {
36-
return Object.values(node.children ?? {}).some(child => some(child, predicate));
36+
return Object.values(node?.children ?? {}).some(child => some(child, predicate));
3737
}
3838
}
3939

40-
export function loadTree(assembly: CloudAssembly) {
40+
export function loadTree(assembly: CloudAssembly): ConstructTreeNode | undefined {
4141
try {
4242
const outdir = assembly.directory;
4343
const fileName = assembly.tree()?.file;
4444
return fileName ? fs.readJSONSync(path.join(outdir, fileName)).tree : {};
4545
} catch (e) {
4646
trace(`Failed to get tree.json file: ${e}. Proceeding with empty tree.`);
47-
return {};
47+
return undefined;
4848
}
4949
}
5050

51-
export function loadTreeFromDir(outdir: string) {
51+
export function loadTreeFromDir(outdir: string): ConstructTreeNode | undefined {
5252
try {
5353
return fs.readJSONSync(path.join(outdir, 'tree.json')).tree;
5454
} catch (e) {
5555
trace(`Failed to get tree.json file: ${e}. Proceeding with empty tree.`);
56-
return {};
56+
return undefined;
5757
}
5858
}

0 commit comments

Comments
 (0)