diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index 027dc574572f5..3c05f296e2dc9 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -11,18 +11,20 @@ The AWS CDK Toolkit provides the `cdk` command-line interface that can be used to work with AWS CDK applications. -Command | Description -----------------------------------|------------------------------------------------------------------------------------- -[`cdk docs`](#cdk-docs) | Access the online documentation -[`cdk init`](#cdk-init) | Start a new CDK project (app or library) -[`cdk list`](#cdk-list) | List stacks in an application -[`cdk synth`](#cdk-synthesize) | Synthesize a CDK app to CloudFormation template(s) -[`cdk diff`](#cdk-diff) | Diff stacks against current state -[`cdk deploy`](#cdk-deploy) | Deploy a stack into an AWS account -[`cdk watch`](#cdk-watch) | Watches a CDK app for deployable and hotswappable changes -[`cdk destroy`](#cdk-destroy) | Deletes a stack from an AWS account -[`cdk bootstrap`](#cdk-bootstrap) | Deploy a toolkit stack to support deploying large stacks & artifacts -[`cdk doctor`](#cdk-doctor) | Inspect the environment and produce information useful for troubleshooting +Command | Description +--------------------------------------|--------------------------------------------------------------------------------- +[`cdk docs`](#cdk-docs) | Access the online documentation +[`cdk init`](#cdk-init) | Start a new CDK project (app or library) +[`cdk list`](#cdk-list) | List stacks in an application +[`cdk synth`](#cdk-synthesize) | Synthesize a CDK app to CloudFormation template(s) +[`cdk diff`](#cdk-diff) | Diff stacks against current state +[`cdk deploy`](#cdk-deploy) | Deploy a stack into an AWS account +[`cdk watch`](#cdk-watch) | Watches a CDK app for deployable and hotswappable changes +[`cdk destroy`](#cdk-destroy) | Deletes a stack from an AWS account +[`cdk bootstrap`](#cdk-bootstrap) | Deploy a toolkit stack to support deploying large stacks & artifacts +[`cdk doctor`](#cdk-doctor) | Inspect the environment and produce information useful for troubleshooting +[`cdk acknowledge`](#cdk-acknowledge) | Acknowledge (and hide) a notice by issue number +[`cdk notices`](#cdk-notices) | List all relevant notices for the application This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project. @@ -503,6 +505,97 @@ $ cdk doctor - AWS_SDK_LOAD_CONFIG = 1 ``` +## Notices + +> This feature exists on CDK CLI version 2.14.0 and up. + +CDK Notices are important messages regarding security vulnerabilities, regressions, and usage of unsupported +versions. Relevant notices appear on every command by default. For example, + +```console +$ cdk deploy + +... # Normal output of the command + +NOTICES + +16603 Toggling off auto_delete_objects for Bucket empties the bucket + + Overview: If a stack is deployed with an S3 bucket with + auto_delete_objects=True, and then re-deployed with + auto_delete_objects=False, all the objects in the bucket + will be deleted. + + Affected versions: <1.126.0. + + More information at: https://github.com/aws/aws-cdk/issues/16603 + +17061 Error when building EKS cluster with monocdk import + + Overview: When using monocdk/aws-eks to build a stack containing + an EKS cluster, error is thrown about missing + lambda-layer-node-proxy-agent/layer/package.json. + + Affected versions: >=1.126.0 <=1.130.0. + + More information at: https://github.com/aws/aws-cdk/issues/17061 + +If you don’t want to see an notice anymore, use "cdk acknowledge ID". For example, "cdk acknowledge 16603". +``` + +You can suppress warnings in a variety of ways: + +- per individual execution: + + `cdk deploy --no-notices` + +- disable all notices indefinitely through context in `cdk.json`: + + ```json + { + "context": { + "notices": false + } + } + ``` + +- acknowleding individual notices via `cdk acknowledge` (see below). + +### `cdk acknowledge` + +To hide a particular notice that has been addressed or does not apply, call `cdk acknowledge` with the ID of +the notice: + +```console +$cdk acknowledge 16603 +``` + +> Please note that the acknowledgements are made project by project. If you acknowledge an notice in one CDK +> project, it will still appear on other projects when you run any CDK commands, unless you have suppressed +> or disabled notices. + + +### `cdk notices` + +List the notices that are relevant to the current CDK repository, regardless of context flags or notices that +have been acknowledged: + +```console +$ cdk notices + +NOTICES + +16603 Toggling off auto_delete_objects for Bucket empties the bucket + + Overview: if a stack is deployed with an S3 bucket with auto_delete_objects=True, and then re-deployed with auto_delete_objects=False, all the objects in the bucket will be deleted. + + Affected versions: framework: <=2.15.0 >=2.10.0 + + More information at: https://github.com/aws/aws-cdk/issues/16603 + +If you don’t want to see a notice anymore, use "cdk acknowledge ". For example, "cdk acknowledge 16603". +``` + ### Bundling By default asset bundling is skipped for `cdk list` and `cdk destroy`. For `cdk deploy`, `cdk diff` diff --git a/packages/aws-cdk/lib/cdk-toolkit.ts b/packages/aws-cdk/lib/cdk-toolkit.ts index 56c5302544a84..6d95bb76ac81b 100644 --- a/packages/aws-cdk/lib/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cdk-toolkit.ts @@ -16,7 +16,7 @@ import { CloudWatchLogEventMonitor } from './api/logs/logs-monitor'; import { StackActivityProgress } from './api/util/cloudformation/stack-activity-monitor'; import { printSecurityDiff, printStackDiff, RequireApproval } from './diff'; import { data, debug, error, highlight, print, success, warning } from './logging'; -import { deserializeStructure } from './serialize'; +import { deserializeStructure, serializeStructure } from './serialize'; import { Configuration, PROJECT_CONFIG } from './settings'; import { numberFromBool, partition } from './util'; @@ -74,9 +74,16 @@ export class CdkToolkit { constructor(private readonly props: CdkToolkitProps) { } - public async metadata(stackName: string) { + public async metadata(stackName: string, json: boolean) { const stacks = await this.selectSingleStackByName(stackName); - return stacks.firstStack.manifest.metadata ?? {}; + data(serializeStructure(stacks.firstStack.manifest.metadata ?? {}, json)); + } + + public async acknowledge(noticeId: string) { + const acks = this.props.configuration.context.get('acknowledged-issue-numbers') ?? []; + acks.push(Number(noticeId)); + this.props.configuration.context.set('acknowledged-issue-numbers', acks); + await this.props.configuration.saveContext(); } public async diff(options: DiffOptions): Promise { @@ -384,7 +391,7 @@ export class CdkToolkit { } } - public async list(selectors: string[], options: { long?: boolean } = { }) { + public async list(selectors: string[], options: { long?: boolean, json?: boolean } = { }): Promise { const stacks = await this.selectStacksForList(selectors); // if we are in "long" mode, emit the array as-is (JSON/YAML) @@ -397,7 +404,8 @@ export class CdkToolkit { environment: stack.environment, }); } - return long; // will be YAML formatted output + data(serializeStructure(long, options.json ?? false)); + return 0; } // just print stack IDs @@ -417,13 +425,13 @@ export class CdkToolkit { * OUTPUT: If more than one stack ends up being selected, an output directory * should be supplied, where the templates will be written. */ - public async synth(stackNames: string[], exclusively: boolean, quiet: boolean, autoValidate?: boolean): Promise { + public async synth(stackNames: string[], exclusively: boolean, quiet: boolean, autoValidate?: boolean, json?: boolean): Promise { const stacks = await this.selectStacksForDiff(stackNames, exclusively, autoValidate); // if we have a single stack, print it to STDOUT if (stacks.stackCount === 1) { if (!quiet) { - return stacks.firstStack.template; + data(serializeStructure(stacks.firstStack.template, json ?? false)); } return undefined; } @@ -437,7 +445,7 @@ export class CdkToolkit { // behind an environment variable. const isIntegMode = process.env.CDK_INTEG_MODE === '1'; if (isIntegMode) { - return stacks.stackArtifacts.map(s => s.template); + data(serializeStructure(stacks.stackArtifacts.map(s => s.template), json ?? false)); } // not outputting template to stdout, let's explain things to the user a little bit... diff --git a/packages/aws-cdk/lib/cli.ts b/packages/aws-cdk/lib/cli.ts index b173ea12db43e..b8a0cd40c463b 100644 --- a/packages/aws-cdk/lib/cli.ts +++ b/packages/aws-cdk/lib/cli.ts @@ -19,8 +19,8 @@ import { realHandler as doctor } from '../lib/commands/doctor'; import { RequireApproval } from '../lib/diff'; import { availableInitLanguages, cliInit, printAvailableTemplates } from '../lib/init'; import { data, debug, error, print, setLogLevel } from '../lib/logging'; +import { displayNotices, refreshNotices } from '../lib/notices'; import { PluginHost } from '../lib/plugin'; -import { serializeStructure } from '../lib/serialize'; import { Command, Configuration, Settings } from '../lib/settings'; import * as version from '../lib/version'; @@ -71,6 +71,7 @@ async function parseCommandLineArguments() { .option('role-arn', { type: 'string', alias: 'r', desc: 'ARN of Role to use when invoking CloudFormation', default: undefined, requiresArg: true }) .option('staging', { type: 'boolean', desc: 'Copy assets to the output directory (use --no-staging to disable, needed for local debugging the source files with SAM CLI)', default: true }) .option('output', { type: 'string', alias: 'o', desc: 'Emits the synthesized cloud assembly into a directory (default: cdk.out)', requiresArg: true }) + .option('notices', { type: 'boolean', desc: 'Show relevant notices' }) .option('no-color', { type: 'boolean', desc: 'Removes colors and other style from console output', default: false }) .command(['list [STACKS..]', 'ls [STACKS..]'], 'Lists all stacks in the app', yargs => yargs .option('long', { type: 'boolean', default: false, alias: 'l', desc: 'Display environment information for each stack' }), @@ -193,6 +194,8 @@ async function parseCommandLineArguments() { .option('security-only', { type: 'boolean', desc: 'Only diff for broadened security changes', default: false }) .option('fail', { type: 'boolean', desc: 'Fail with exit code 1 in case of diff', default: false })) .command('metadata [STACK]', 'Returns all metadata associated with this stack') + .command(['acknowledge [ID]', 'ack [ID]'], 'Acknowledge a notice so that it does not show up anymore') + .command('notices', 'Returns a list of relevant notices') .command('init [TEMPLATE]', 'Create a new, empty CDK project from a template.', yargs => yargs .option('language', { type: 'string', alias: 'l', desc: 'The language to be used for the new project (default can be configured in ~/.cdk.json)', choices: initTemplateLanguages }) .option('list', { type: 'boolean', desc: 'List the available templates' }) @@ -227,6 +230,10 @@ if (!process.stdout.isTTY) { } async function initCommandLine() { + void refreshNotices() + .then(_ => debug('Notices refreshed')) + .catch(e => debug(`Notices refresh failed: ${e}`)); + const argv = await parseCommandLineArguments(); if (argv.verbose) { setLogLevel(argv.verbose); @@ -295,37 +302,32 @@ async function initCommandLine() { const commandOptions = { args: argv, configuration, aws: sdkProvider }; try { + return await main(cmd, argv); + } finally { + await version.displayVersionMessage(); - let returnValue = undefined; - - switch (cmd) { - case 'context': - returnValue = await context(commandOptions); - break; - case 'docs': - returnValue = await docs(commandOptions); - break; - case 'doctor': - returnValue = await doctor(commandOptions); - break; - } - - if (returnValue === undefined) { - returnValue = await main(cmd, argv); + if (shouldDisplayNotices()) { + if (cmd === 'notices') { + await displayNotices({ + outdir: configuration.settings.get(['output']) ?? 'cdk.out', + acknowledgedIssueNumbers: [], + ignoreCache: true, + }); + } else { + await displayNotices({ + outdir: configuration.settings.get(['output']) ?? 'cdk.out', + acknowledgedIssueNumbers: configuration.context.get('acknowledged-issue-numbers') ?? [], + ignoreCache: false, + }); + } } - if (typeof returnValue === 'object') { - return toJsonOrYaml(returnValue); - } else if (typeof returnValue === 'string') { - return returnValue; - } else { - return returnValue; + function shouldDisplayNotices(): boolean { + return configuration.settings.get(['notices']) ?? true; } - } finally { - await version.displayVersionMessage(); } - async function main(command: string, args: any): Promise { + async function main(command: string, args: any): Promise { const toolkitStackName: string = ToolkitInfo.determineName(configuration.settings.get(['toolkitStackName'])); debug(`Toolkit stack: ${chalk.bold(toolkitStackName)}`); @@ -352,9 +354,18 @@ async function initCommandLine() { }); switch (command) { + case 'context': + return context(commandOptions); + + case 'docs': + return docs(commandOptions); + + case 'doctor': + return doctor(commandOptions); + case 'ls': case 'list': - return cli.list(args.STACKS, { long: args.long }); + return cli.list(args.STACKS, { long: args.long, json: argv.json }); case 'diff': const enableDiffNoFail = isFeatureEnabled(configuration, cxapi.ENABLE_DIFF_NO_FAIL); @@ -458,14 +469,21 @@ async function initCommandLine() { case 'synthesize': case 'synth': if (args.exclusively) { - return cli.synth(args.STACKS, args.exclusively, args.quiet, args.validation); + return cli.synth(args.STACKS, args.exclusively, args.quiet, args.validation, argv.json); } else { - return cli.synth(args.STACKS, true, args.quiet, args.validation); + return cli.synth(args.STACKS, true, args.quiet, args.validation, argv.json); } + case 'notices': + // This is a valid command, but we're postponing its execution + return; case 'metadata': - return cli.metadata(args.STACK); + return cli.metadata(args.STACK, argv.json); + + case 'acknowledge': + case 'ack': + return cli.acknowledge(args.ID); case 'init': const language = configuration.settings.get(['language']); @@ -482,9 +500,6 @@ async function initCommandLine() { } } - function toJsonOrYaml(object: any): string { - return serializeStructure(object, argv.json); - } } /** @@ -558,11 +573,8 @@ function yargsNegativeAlias { - if (value == null) { return; } - if (typeof value === 'string') { - data(value); - } else if (typeof value === 'number') { + .then(async (value) => { + if (typeof value === 'number') { process.exitCode = value; } }) diff --git a/packages/aws-cdk/lib/notices.ts b/packages/aws-cdk/lib/notices.ts new file mode 100644 index 0000000000000..dee9eab05fa68 --- /dev/null +++ b/packages/aws-cdk/lib/notices.ts @@ -0,0 +1,264 @@ +import * as https from 'https'; +import * as path from 'path'; +import * as fs from 'fs-extra'; +import * as semver from 'semver'; +import { debug, print } from './logging'; +import { cdkCacheDir } from './util/directories'; +import { versionNumber } from './version'; + +const CACHE_FILE_PATH = path.join(cdkCacheDir(), 'notices.json'); + +export interface DisplayNoticesProps { + /** + * The cloud assembly directory. Usually 'cdk.out'. + */ + readonly outdir: string; + + /** + * Issue numbers of notices that have been acknowledged by a user + * of the current CDK repository. These notices will be skipped. + */ + readonly acknowledgedIssueNumbers: number[]; + + /** + * Whether cached notices should be ignored. Setting this property + * to true will force the CLI to download fresh data + * + * @default false + */ + readonly ignoreCache?: boolean; +} + +export async function refreshNotices() { + const dataSource = dataSourceReference(false); + return dataSource.fetch(); +} + +export async function displayNotices(props: DisplayNoticesProps) { + const dataSource = dataSourceReference(props.ignoreCache ?? false); + print(await generateMessage(dataSource, props)); + return 0; +} + +export async function generateMessage(dataSource: NoticeDataSource, props: DisplayNoticesProps) { + const data = await dataSource.fetch(); + const individualMessages = formatNotices(filterNotices(data, { + outdir: props.outdir, + acknowledgedIssueNumbers: new Set(props.acknowledgedIssueNumbers), + })); + + if (individualMessages.length > 0) { + return finalMessage(individualMessages, data[0].issueNumber); + } + return ''; +} + +function dataSourceReference(ignoreCache: boolean): NoticeDataSource { + return new CachedDataSource(CACHE_FILE_PATH, new WebsiteNoticeDataSource(), ignoreCache); +} + +function finalMessage(individualMessages: string[], exampleNumber: number): string { + return [ + '\nNOTICES', + ...individualMessages, + `If you don’t want to see a notice anymore, use "cdk acknowledge ". For example, "cdk acknowledge ${exampleNumber}".`, + ].join('\n\n'); +} + +export interface FilterNoticeOptions { + outdir?: string, + cliVersion?: string, + frameworkVersion?: string, + acknowledgedIssueNumbers?: Set, +} + +export function filterNotices(data: Notice[], options: FilterNoticeOptions): Notice[] { + const filter = new NoticeFilter({ + cliVersion: options.cliVersion ?? versionNumber(), + frameworkVersion: options.frameworkVersion ?? frameworkVersion(options.outdir ?? 'cdk.out'), + acknowledgedIssueNumbers: options.acknowledgedIssueNumbers ?? new Set(), + }); + return data.filter(notice => filter.apply(notice)); +} + +export function formatNotices(data: Notice[]): string[] { + return data.map(formatNotice); +} + +export interface Component { + name: string; + version: string; +} + +export interface Notice { + title: string; + issueNumber: number; + overview: string; + components: Component[]; + schemaVersion: string; +} + +export interface NoticeDataSource { + fetch(): Promise, +} + +export class WebsiteNoticeDataSource implements NoticeDataSource { + fetch(): Promise { + return new Promise((resolve) => { + https.get('https://cli.cdk.dev-tools.aws.dev/notices.json', res => { + if (res.statusCode === 200) { + res.setEncoding('utf8'); + let rawData = ''; + res.on('data', (chunk) => { + rawData += chunk; + }); + res.on('end', () => { + try { + const data = JSON.parse(rawData).notices as Notice[]; + resolve(data ?? []); + } catch (e) { + debug(`Failed to parse notices: ${e}`); + resolve([]); + } + }); + res.on('error', e => { + debug(`Failed to fetch notices: ${e}`); + resolve([]); + }); + } else { + debug(`Failed to fetch notices. Status code: ${res.statusCode}`); + resolve([]); + } + }); + }); + } +} + +interface CachedNotices { + expiration: number, + notices: Notice[], +} + +const TIME_TO_LIVE = 60 * 60 * 1000; // 1 hour + +export class CachedDataSource implements NoticeDataSource { + constructor( + private readonly fileName: string, + private readonly dataSource: NoticeDataSource, + private readonly skipCache?: boolean) { + } + + async fetch(): Promise { + const cachedData = await this.load(); + const data = cachedData.notices; + const expiration = cachedData.expiration ?? 0; + + if (Date.now() > expiration || this.skipCache) { + const freshData = { + expiration: Date.now() + TIME_TO_LIVE, + notices: await this.dataSource.fetch(), + }; + await this.save(freshData); + return freshData.notices; + } else { + return data; + } + } + + private async load(): Promise { + try { + return await fs.readJSON(this.fileName) as CachedNotices; + } catch (e) { + debug(`Failed to load notices from cache: ${e}`); + return { + expiration: 0, + notices: [], + }; + } + } + + private async save(cached: CachedNotices): Promise { + try { + await fs.writeJSON(this.fileName, cached); + } catch (e) { + debug(`Failed to store notices in the cache: ${e}`); + } + } +} + +export interface NoticeFilterProps { + cliVersion: string, + frameworkVersion: string | undefined, + acknowledgedIssueNumbers: Set, +} + +export class NoticeFilter { + private readonly acknowledgedIssueNumbers: Set; + + constructor(private readonly props: NoticeFilterProps) { + this.acknowledgedIssueNumbers = props.acknowledgedIssueNumbers; + } + + /** + * Returns true iff we should show this notice. + */ + apply(notice: Notice): boolean { + if (this.acknowledgedIssueNumbers.has(notice.issueNumber)) { + return false; + } + return this.applyVersion(notice, 'cli', this.props.cliVersion) || + this.applyVersion(notice, 'framework', this.props.frameworkVersion); + } + + /** + * Returns true iff we should show the notice. + */ + private applyVersion(notice: Notice, name: string, compareToVersion: string | undefined) { + if (compareToVersion === undefined) { return false; } + + const affectedComponent = notice.components.find(component => component.name === name); + const affectedRange = affectedComponent?.version; + return affectedRange != null && semver.satisfies(compareToVersion, affectedRange); + } +} + +function formatNotice(notice: Notice): string { + const componentsValue = notice.components.map(c => `${c.name}: ${c.version}`).join(', '); + return [ + `${notice.issueNumber}\t${notice.title}`, + formatOverview(notice.overview), + `\tAffected versions: ${componentsValue}`, + `\tMore information at: https://github.com/aws/aws-cdk/issues/${notice.issueNumber}`, + ].join('\n\n') + '\n'; +} + +function formatOverview(text: string) { + const wrap = (s: string) => s.replace(/(?![^\n]{1,60}$)([^\n]{1,60})\s/g, '$1\n'); + + const heading = 'Overview: '; + const separator = `\n\t${' '.repeat(heading.length)}`; + const content = wrap(text) + .split('\n') + .join(separator); + + return '\t' + heading + content; +} + +function frameworkVersion(outdir: string): string | undefined { + const tree = loadTree().tree; + + if (tree?.constructInfo?.fqn.startsWith('aws-cdk-lib') + || tree?.constructInfo?.fqn.startsWith('@aws-cdk/core')) { + return tree.constructInfo.version; + } + return undefined; + + function loadTree() { + try { + return fs.readJSONSync(path.join(outdir, 'tree.json')); + } catch (e) { + debug(`Failed to get tree.json file: ${e}`); + return {}; + } + } +} \ No newline at end of file diff --git a/packages/aws-cdk/lib/settings.ts b/packages/aws-cdk/lib/settings.ts index 38723bffc0bc3..ddb28be756292 100644 --- a/packages/aws-cdk/lib/settings.ts +++ b/packages/aws-cdk/lib/settings.ts @@ -287,6 +287,7 @@ export class Settings { bundlingStacks, lookups: argv.lookups, rollback: argv.rollback, + notices: argv.notices, }); } diff --git a/packages/aws-cdk/test/cdk-toolkit.test.ts b/packages/aws-cdk/test/cdk-toolkit.test.ts index c7ee3b87130fc..f87ab3e76499c 100644 --- a/packages/aws-cdk/test/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cdk-toolkit.test.ts @@ -47,6 +47,12 @@ const fakeChokidarWatch = { }, }; +const mockData = jest.fn(); +jest.mock('../lib/logging', () => ({ + ...jest.requireActual('../lib/logging'), + data: mockData, +})); + import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import * as cxapi from '@aws-cdk/cx-api'; import { Bootstrapper } from '../lib/api/bootstrap'; @@ -676,7 +682,8 @@ describe('synth', () => { const toolkit = defaultToolkitSetup(); // THEN - await expect(toolkit.synth(['Test-Stack-A'], false, true)).resolves.toBeUndefined(); + await toolkit.synth(['Test-Stack-A'], false, true); + expect(mockData.mock.calls.length).toEqual(0); }); afterEach(() => { @@ -707,7 +714,8 @@ describe('synth', () => { test('causes synth to succeed if autoValidate=false', async() => { const toolkit = defaultToolkitSetup(); const autoValidate = false; - await expect(toolkit.synth([], false, true, autoValidate)).resolves.toBeUndefined(); + await toolkit.synth([], false, true, autoValidate); + expect(mockData.mock.calls.length).toEqual(0); }); }); @@ -757,7 +765,10 @@ describe('synth', () => { const toolkit = defaultToolkitSetup(); - await expect(toolkit.synth([MockStack.MOCK_STACK_D.stackName], true, false)).resolves.toBeDefined(); + await toolkit.synth([MockStack.MOCK_STACK_D.stackName], true, false); + + expect(mockData.mock.calls.length).toEqual(1); + expect(mockData.mock.calls[0][0]).toBeDefined(); }); }); diff --git a/packages/aws-cdk/test/cloud-assembly-trees/built-with-1_144_0/tree.json b/packages/aws-cdk/test/cloud-assembly-trees/built-with-1_144_0/tree.json new file mode 100644 index 0000000000000..a7b0c9e29c2f3 --- /dev/null +++ b/packages/aws-cdk/test/cloud-assembly-trees/built-with-1_144_0/tree.json @@ -0,0 +1,21 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "Tree": { + "id": "Tree", + "path": "Tree", + "constructInfo": { + "fqn": "@aws-cdk/core.Construct", + "version": "1.144.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/core.App", + "version": "1.144.0" + } + } +} \ No newline at end of file diff --git a/packages/aws-cdk/test/cloud-assembly-trees/built-with-2_12_0/tree.json b/packages/aws-cdk/test/cloud-assembly-trees/built-with-2_12_0/tree.json new file mode 100644 index 0000000000000..41c11ceaf7868 --- /dev/null +++ b/packages/aws-cdk/test/cloud-assembly-trees/built-with-2_12_0/tree.json @@ -0,0 +1,21 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "Tree": { + "id": "Tree", + "path": "Tree", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.0.64" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.App", + "version": "2.12.0" + } + } +} \ No newline at end of file diff --git a/packages/aws-cdk/test/notices.test.ts b/packages/aws-cdk/test/notices.test.ts new file mode 100644 index 0000000000000..dff5ef8c8004a --- /dev/null +++ b/packages/aws-cdk/test/notices.test.ts @@ -0,0 +1,280 @@ +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs-extra'; +import * as nock from 'nock'; +import { + CachedDataSource, + filterNotices, + formatNotices, + generateMessage, + Notice, + WebsiteNoticeDataSource, +} from '../lib/notices'; + +const BASIC_NOTICE = { + title: 'Toggling off auto_delete_objects for Bucket empties the bucket', + issueNumber: 16603, + overview: 'If a stack is deployed with an S3 bucket with auto_delete_objects=True, and then re-deployed with auto_delete_objects=False, all the objects in the bucket will be deleted.', + components: [{ + name: 'cli', + version: '<=1.126.0', + }], + schemaVersion: '1', +}; + +const MULTIPLE_AFFECTED_VERSIONS_NOTICE = { + title: 'Error when building EKS cluster with monocdk import', + issueNumber: 17061, + overview: 'When using monocdk/aws-eks to build a stack containing an EKS cluster, error is thrown about missing lambda-layer-node-proxy-agent/layer/package.json.', + components: [{ + name: 'cli', + version: '<1.130.0 >=1.126.0', + }], + schemaVersion: '1', +}; + +const FRAMEWORK_2_1_0_AFFECTED_NOTICE = { + title: 'Regression on module foobar', + issueNumber: 1234, + overview: 'Some bug description', + components: [{ + name: 'framework', + version: '<= 2.1.0', + }], + schemaVersion: '1', +}; + +describe('cli notices', () => { + describe(formatNotices, () => { + test('correct format', () => { + const result = formatNotices([BASIC_NOTICE])[0]; + expect(result).toEqual(`16603 Toggling off auto_delete_objects for Bucket empties the bucket + + Overview: If a stack is deployed with an S3 bucket with + auto_delete_objects=True, and then re-deployed with + auto_delete_objects=False, all the objects in the bucket + will be deleted. + + Affected versions: cli: <=1.126.0 + + More information at: https://github.com/aws/aws-cdk/issues/16603 +`); + }); + + test('multiple affect versions', () => { + const result = formatNotices([MULTIPLE_AFFECTED_VERSIONS_NOTICE])[0]; + expect(result).toEqual(`17061 Error when building EKS cluster with monocdk import + + Overview: When using monocdk/aws-eks to build a stack containing an + EKS cluster, error is thrown about missing + lambda-layer-node-proxy-agent/layer/package.json. + + Affected versions: cli: <1.130.0 >=1.126.0 + + More information at: https://github.com/aws/aws-cdk/issues/17061 +`); + }); + }); + + describe(filterNotices, () => { + test('correctly filter notices on cli', () => { + const notices = [BASIC_NOTICE, MULTIPLE_AFFECTED_VERSIONS_NOTICE]; + expect(filterNotices(notices, { + cliVersion: '1.0.0', + })).toEqual([BASIC_NOTICE]); + + expect(filterNotices(notices, { + cliVersion: '1.129.0', + })).toEqual([MULTIPLE_AFFECTED_VERSIONS_NOTICE]); + + expect(filterNotices(notices, { + cliVersion: '1.126.0', + })).toEqual(notices); + + expect(filterNotices(notices, { + cliVersion: '1.130.0', + })).toEqual([]); + }); + + test('correctly filter notices on framework', () => { + const notices = [FRAMEWORK_2_1_0_AFFECTED_NOTICE]; + + expect(filterNotices(notices, { + frameworkVersion: '2.0.0', + })).toEqual([FRAMEWORK_2_1_0_AFFECTED_NOTICE]); + + expect(filterNotices(notices, { + frameworkVersion: '2.2.0', + })).toEqual([]); + + expect(filterNotices(notices, { + outdir: path.join(__dirname, 'cloud-assembly-trees/built-with-2_12_0'), + })).toEqual([]); + + expect(filterNotices(notices, { + outdir: path.join(__dirname, 'cloud-assembly-trees/built-with-1_144_0'), + })).toEqual([FRAMEWORK_2_1_0_AFFECTED_NOTICE]); + }); + }); + + describe(WebsiteNoticeDataSource, () => { + const dataSource = new WebsiteNoticeDataSource(); + + test('returns data when download succeeds', async () => { + const result = await mockCall(200, { + notices: [BASIC_NOTICE, MULTIPLE_AFFECTED_VERSIONS_NOTICE], + }); + + expect(result).toEqual([BASIC_NOTICE, MULTIPLE_AFFECTED_VERSIONS_NOTICE]); + }); + + test('returns empty array when the server returns an unexpected status code', async () => { + const result = await mockCall(500, { + notices: [BASIC_NOTICE, MULTIPLE_AFFECTED_VERSIONS_NOTICE], + }); + + expect(result).toEqual([]); + }); + + test('returns empty array when the server returns an unexpected structure', async () => { + const result = await mockCall(200, { + foo: [BASIC_NOTICE, MULTIPLE_AFFECTED_VERSIONS_NOTICE], + }); + + expect(result).toEqual([]); + }); + + test('returns empty array when the server returns invalid json', async () => { + const result = await mockCall(200, '-09aiskjkj838'); + + expect(result).toEqual([]); + }); + + function mockCall(statusCode: number, body: any): Promise { + nock('https://cli.cdk.dev-tools.aws.dev') + .get('/notices.json') + .reply(statusCode, body); + + return dataSource.fetch(); + } + }); + + describe(CachedDataSource, () => { + const fileName = path.join(os.tmpdir(), 'cache.json'); + const cachedData = [BASIC_NOTICE]; + const freshData = [MULTIPLE_AFFECTED_VERSIONS_NOTICE]; + + beforeEach(() => { + fs.writeFileSync(fileName, ''); + }); + + test('retrieves data from the delegate cache when the file is empty', async () => { + const dataSource = dataSourceWithDelegateReturning(freshData); + + const notices = await dataSource.fetch(); + + expect(notices).toEqual(freshData); + }); + + test('retrieves data from the file when the data is still valid', async () => { + fs.writeJsonSync(fileName, { + notices: cachedData, + expiration: Date.now() + 10000, + }); + const dataSource = dataSourceWithDelegateReturning(freshData); + + const notices = await dataSource.fetch(); + + expect(notices).toEqual(cachedData); + }); + + test('retrieves data from the delegate when the data is expired', async () => { + fs.writeJsonSync(fileName, { + notices: cachedData, + expiration: 0, + }); + const dataSource = dataSourceWithDelegateReturning(freshData); + + const notices = await dataSource.fetch(); + + expect(notices).toEqual(freshData); + }); + + test('retrieves data from the delegate when the file cannot be read', async () => { + const nonExistingFile = path.join(os.tmpdir(), 'cache.json'); + const dataSource = dataSourceWithDelegateReturning(freshData, nonExistingFile); + + const notices = await dataSource.fetch(); + + expect(notices).toEqual(freshData); + }); + + test('retrieved data from the delegate when it is configured to ignore the cache', async () => { + fs.writeJsonSync(fileName, { + notices: cachedData, + expiration: Date.now() + 10000, + }); + const dataSource = dataSourceWithDelegateReturning(freshData, fileName, true); + + const notices = await dataSource.fetch(); + + expect(notices).toEqual(freshData); + }); + + function dataSourceWithDelegateReturning(notices: Notice[], file: string = fileName, ignoreCache: boolean = false) { + const delegate = { + fetch: jest.fn(), + }; + + delegate.fetch.mockResolvedValue(notices); + return new CachedDataSource(file, delegate, ignoreCache); + } + }); + + describe(generateMessage, () => { + test('does not show anything when there are no notices', async () => { + const dataSource = createDataSource(); + dataSource.fetch.mockResolvedValue([]); + + const result = await generateMessage(dataSource, { + acknowledgedIssueNumbers: [], + outdir: '/tmp', + }); + + expect(result).toEqual(''); + }); + + test('shows notices that pass the filter', async () => { + const dataSource = createDataSource(); + dataSource.fetch.mockResolvedValue([BASIC_NOTICE, MULTIPLE_AFFECTED_VERSIONS_NOTICE]); + + const result = await generateMessage(dataSource, { + acknowledgedIssueNumbers: [17061], + outdir: '/tmp', + }); + + expect(result).toEqual(` +NOTICES + +16603 Toggling off auto_delete_objects for Bucket empties the bucket + + Overview: If a stack is deployed with an S3 bucket with + auto_delete_objects=True, and then re-deployed with + auto_delete_objects=False, all the objects in the bucket + will be deleted. + + Affected versions: cli: <=1.126.0 + + More information at: https://github.com/aws/aws-cdk/issues/16603 + + +If you don’t want to see a notice anymore, use "cdk acknowledge ". For example, "cdk acknowledge 16603".`); + }); + + function createDataSource() { + return { + fetch: jest.fn(), + }; + } + }); +});