Skip to content

Commit 6b16a21

Browse files
authored
fix(cli): cdk notices ignores failures and calling cli with --no-notices makes an unexpected network call (#399)
Fixes #370 Fixes #440 Previously when the `--no-notices` option was passed, the CLI would still attempt to refresh notices in the background. This is unnecessary and an unexpected behavior to users, especially when running the CLI in environments with no internet access. On the other hand, running the explicit `cdk notices` command did previously not fail if the command failed to refresh notices. Instead the command would pretend no notices are found and complete successfully. This PR updates the code to not refresh notices when notices are disabled. Unless the explicit `cdk notices` command is called, which will always refresh and now error if fetching notices fails. It also removes a double refresh of the notices (which didn't have an effect because they were cached, but still). `cdk notices` with an error now looks like this: <img width="775" alt="image" src="https://github.com/user-attachments/assets/5c09263c-a775-49c9-8dcf-e4feeefb10ac" /> Before the command would incorrectly and silently succeed and not show any notices: <img width="118" alt="image" src="https://github.com/user-attachments/assets/dd93c1bf-4b6a-4fa9-8c9a-1fbacccc0156" /> Only when run with the `-v` flag, it would hint at the failure: ```console $ cdk notices -v ... [12:38:29] Could not refresh notices: Error: getaddrinfo ENOTFOUND cli.cdk.dev-tools ``` --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent bd4ad74 commit 6b16a21

File tree

13 files changed

+273
-95
lines changed

13 files changed

+273
-95
lines changed

.projenrc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,7 @@ const cli = configureProject(
10101010
'jest-environment-node',
10111011
'jest-mock',
10121012
'madge',
1013+
'nock@13',
10131014
'sinon',
10141015
'ts-mock-imports',
10151016
'xml-js',

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

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as fs from 'fs-extra';
22
import type { Notice, NoticeDataSource } from './types';
33
import type { IoDefaultMessages } from '../io/private';
4+
import { ToolkitError } from '../toolkit-error';
45

56
interface CachedNotices {
67
expiration: number;
@@ -15,7 +16,8 @@ export class CachedDataSource implements NoticeDataSource {
1516
private readonly ioMessages: IoDefaultMessages,
1617
private readonly fileName: string,
1718
private readonly dataSource: NoticeDataSource,
18-
private readonly skipCache?: boolean) {
19+
private readonly skipCache?: boolean,
20+
) {
1921
}
2022

2123
async fetch(): Promise<Notice[]> {
@@ -24,28 +26,32 @@ export class CachedDataSource implements NoticeDataSource {
2426
const expiration = cachedData.expiration ?? 0;
2527

2628
if (Date.now() > expiration || this.skipCache) {
27-
const freshData = await this.fetchInner();
28-
await this.save(freshData);
29-
return freshData.notices;
29+
let updatedData: CachedNotices = cachedData;
30+
31+
try {
32+
updatedData = await this.fetchInner();
33+
} catch (e) {
34+
this.ioMessages.debug(`Could not refresh notices: ${e}`);
35+
updatedData = {
36+
expiration: Date.now() + TIME_TO_LIVE_ERROR,
37+
notices: [],
38+
};
39+
throw ToolkitError.withCause('Failed to load CDK notices. Please try again later.', e);
40+
} finally {
41+
await this.save(updatedData);
42+
}
43+
return updatedData.notices;
3044
} else {
3145
this.ioMessages.debug(`Reading cached notices from ${this.fileName}`);
3246
return data;
3347
}
3448
}
3549

3650
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-
}
51+
return {
52+
expiration: Date.now() + TIME_TO_LIVE_SUCCESS,
53+
notices: await this.dataSource.fetch(),
54+
};
4955
}
5056

5157
private async load(): Promise<CachedNotices> {

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

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { SdkHttpOptions } from '../aws-auth';
44
import type { Context } from '../context';
55
import type { IIoHost } from '../io';
66
import { CachedDataSource } from './cached-data-source';
7+
import type { FilteredNotice } from './filter';
78
import { NoticesFilter } from './filter';
89
import type { BootstrappedEnvironment, Notice, NoticeDataSource } from './types';
910
import { WebsiteNoticeDataSource } from './web-data-source';
@@ -18,13 +19,6 @@ export interface NoticesProps {
1819
*/
1920
readonly context: Context;
2021

21-
/**
22-
* Include notices that have already been acknowledged.
23-
*
24-
* @default false
25-
*/
26-
readonly includeAcknowledged?: boolean;
27-
2822
/**
2923
* Global CLI option for output directory for synthesized cloud assembly
3024
*
@@ -48,8 +42,16 @@ export interface NoticesProps {
4842
readonly cliVersion: string;
4943
}
5044

51-
export interface NoticesPrintOptions {
45+
export interface NoticesFilterOptions {
46+
/**
47+
* Include notices that have already been acknowledged.
48+
*
49+
* @default false
50+
*/
51+
readonly includeAcknowledged?: boolean;
52+
}
5253

54+
export interface NoticesDisplayOptions extends NoticesFilterOptions {
5355
/**
5456
* Whether to append the total number of unacknowledged notices to the display.
5557
*
@@ -98,7 +100,6 @@ export class Notices {
98100
private readonly context: Context;
99101
private readonly output: string;
100102
private readonly acknowledgedIssueNumbers: Set<Number>;
101-
private readonly includeAcknowlegded: boolean;
102103
private readonly httpOptions: SdkHttpOptions;
103104
private readonly ioHelper: IoHelper;
104105
private readonly ioMessages: IoDefaultMessages;
@@ -112,7 +113,6 @@ export class Notices {
112113
private constructor(props: NoticesProps) {
113114
this.context = props.context;
114115
this.acknowledgedIssueNumbers = new Set(this.context.get('acknowledged-issue-numbers') ?? []);
115-
this.includeAcknowlegded = props.includeAcknowledged ?? false;
116116
this.output = props.output ?? 'cdk.out';
117117
this.httpOptions = props.httpOptions ?? {};
118118
this.ioHelper = asIoHelper(props.ioHost, 'notices' as any /* forcing a CliAction to a ToolkitAction */);
@@ -136,35 +136,39 @@ export class Notices {
136136

137137
/**
138138
* Refresh the list of notices this instance is aware of.
139-
* To make sure this never crashes the CLI process, all failures are caught and
140-
* silently logged.
141139
*
142-
* If context is configured to not display notices, this will no-op.
140+
* This method throws an error if the data source fails to fetch notices.
141+
* When using, consider if execution should halt immdiately or if catching the rror and continuing is more appropriate
142+
*
143+
* @throws on failure to refresh the data source
143144
*/
144145
public async refresh(options: NoticesRefreshOptions = {}) {
145-
try {
146-
const underlyingDataSource = options.dataSource ?? new WebsiteNoticeDataSource(this.ioHelper, this.httpOptions);
147-
const dataSource = new CachedDataSource(this.ioMessages, CACHE_FILE_PATH, underlyingDataSource, options.force ?? false);
148-
const notices = await dataSource.fetch();
149-
this.data = new Set(this.includeAcknowlegded ? notices : notices.filter(n => !this.acknowledgedIssueNumbers.has(n.issueNumber)));
150-
} catch (e: any) {
151-
this.ioMessages.debug(`Could not refresh notices: ${e}`);
152-
}
146+
const innerDataSource = options.dataSource ?? new WebsiteNoticeDataSource(this.ioHelper, this.httpOptions);
147+
const dataSource = new CachedDataSource(this.ioMessages, CACHE_FILE_PATH, innerDataSource, options.force ?? false);
148+
const notices = await dataSource.fetch();
149+
this.data = new Set(notices);
153150
}
154151

155152
/**
156-
* Display the relevant notices (unless context dictates we shouldn't).
153+
* Filter the data sourece for relevant notices
157154
*/
158-
public display(options: NoticesPrintOptions = {}) {
159-
const filteredNotices = new NoticesFilter(this.ioMessages).filter({
160-
data: Array.from(this.data),
155+
public filter(options: NoticesDisplayOptions = {}): FilteredNotice[] {
156+
return new NoticesFilter(this.ioMessages).filter({
157+
data: this.noticesFromData(options.includeAcknowledged ?? false),
161158
cliVersion: this.cliVersion,
162159
outDir: this.output,
163160
bootstrappedEnvironments: Array.from(this.bootstrappedEnvironments.values()),
164161
});
162+
}
163+
164+
/**
165+
* Display the relevant notices (unless context dictates we shouldn't).
166+
*/
167+
public async display(options: NoticesDisplayOptions = {}): Promise<void> {
168+
const filteredNotices = this.filter(options);
165169

166170
if (filteredNotices.length > 0) {
167-
void this.ioMessages.notify(IO.CDK_TOOLKIT_I0100.msg([
171+
await this.ioHelper.notify(IO.CDK_TOOLKIT_I0100.msg([
168172
'',
169173
'NOTICES (What\'s this? https://github.com/aws/aws-cdk/wiki/CLI-Notices)',
170174
'',
@@ -173,25 +177,41 @@ export class Notices {
173177
const formatted = filtered.format() + '\n';
174178
switch (filtered.notice.severity) {
175179
case 'warning':
176-
void this.ioMessages.notify(IO.CDK_TOOLKIT_W0101.msg(formatted));
180+
await this.ioHelper.notify(IO.CDK_TOOLKIT_W0101.msg(formatted));
177181
break;
178182
case 'error':
179-
void this.ioMessages.notify(IO.CDK_TOOLKIT_E0101.msg(formatted));
183+
await this.ioHelper.notify(IO.CDK_TOOLKIT_E0101.msg(formatted));
180184
break;
181185
default:
182-
void this.ioMessages.notify(IO.CDK_TOOLKIT_I0101.msg(formatted));
186+
await this.ioHelper.notify(IO.CDK_TOOLKIT_I0101.msg(formatted));
183187
break;
184188
}
185189
}
186-
void this.ioMessages.notify(IO.CDK_TOOLKIT_I0100.msg(
190+
await this.ioHelper.notify(IO.CDK_TOOLKIT_I0100.msg(
187191
`If you don’t want to see a notice anymore, use "cdk acknowledge <id>". For example, "cdk acknowledge ${filteredNotices[0].notice.issueNumber}".`,
188192
));
189193
}
190194

191195
if (options.showTotal ?? false) {
192-
void this.ioMessages.notify(IO.CDK_TOOLKIT_I0100.msg(
196+
await this.ioHelper.notify(IO.CDK_TOOLKIT_I0100.msg(
193197
`\nThere are ${filteredNotices.length} unacknowledged notice(s).`,
194198
));
195199
}
196200
}
201+
202+
/**
203+
* List all notices available in the data source.
204+
*
205+
* @param includeAcknowlegded Whether to include acknowledged notices.
206+
*/
207+
private noticesFromData(includeAcknowlegded = false): Notice[] {
208+
const data = Array.from(this.data);
209+
210+
if (includeAcknowlegded) {
211+
return data;
212+
}
213+
214+
return data.filter(n => !this.acknowledgedIssueNumbers.has(n.issueNumber));
215+
}
197216
}
217+

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import type { ClientRequest } from 'http';
22
import type { RequestOptions } from 'https';
33
import * as https from 'node:https';
4-
import { formatErrorMessage } from '../../util';
54
import type { SdkHttpOptions } from '../aws-auth';
65
import { ProxyAgentProvider } from '../aws-auth/private';
76
import type { IoHelper } from '../io/private';
87
import { IO } from '../io/private';
98
import { ToolkitError } from '../toolkit-error';
109
import type { Notice, NoticeDataSource } from './types';
10+
import { formatErrorMessage, humanHttpStatusError, humanNetworkError } from '../../util';
1111

1212
export class WebsiteNoticeDataSource implements NoticeDataSource {
1313
private readonly options: SdkHttpOptions;
@@ -48,23 +48,25 @@ export class WebsiteNoticeDataSource implements NoticeDataSource {
4848
try {
4949
const data = JSON.parse(rawData).notices as Notice[];
5050
if (!data) {
51-
throw new ToolkitError("'notices' key is missing");
51+
throw new ToolkitError("'notices' key is missing from received data");
5252
}
5353
resolve(data ?? []);
5454
} catch (e: any) {
55-
reject(new ToolkitError(`Failed to parse notices: ${formatErrorMessage(e)}`));
55+
reject(ToolkitError.withCause(`Parse error: ${formatErrorMessage(e)}`, e));
5656
}
5757
});
5858
res.on('error', e => {
59-
reject(new ToolkitError(`Failed to fetch notices: ${formatErrorMessage(e)}`));
59+
reject(ToolkitError.withCause(formatErrorMessage(e), e));
6060
});
6161
} else {
62-
reject(new ToolkitError(`Failed to fetch notices. Status code: ${res.statusCode}`));
62+
reject(new ToolkitError(`${humanHttpStatusError(res.statusCode!)} (Status code: ${res.statusCode})`));
6363
}
6464
});
65-
req.on('error', reject);
65+
req.on('error', e => {
66+
reject(ToolkitError.withCause(humanNetworkError(e), e));
67+
});
6668
} catch (e: any) {
67-
reject(new ToolkitError(`HTTPS 'get' call threw an error: ${formatErrorMessage(e)}`));
69+
reject(ToolkitError.withCause(formatErrorMessage(e), e));
6870
}
6971
});
7072

packages/@aws-cdk/toolkit-lib/lib/util/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './content-hash';
77
export * from './directories';
88
export * from './format-error';
99
export * from './json';
10+
export * from './net';
1011
export * from './objects';
1112
export * from './parallel';
1213
export * from './package-info';
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Get a human-readable error message for a network error
3+
* @param error The network error object
4+
*/
5+
export function humanNetworkError(error: NodeJS.ErrnoException): string {
6+
switch (error.code) {
7+
case 'ENOTFOUND':
8+
return `Cannot reach the server. Please check your internet connection or the URL (${(error as any).hostname}).`;
9+
case 'ECONNREFUSED':
10+
return `Connection refused. The server at ${(error as any).address}:${(error as any).port} is not accepting connections.`;
11+
case 'ECONNRESET':
12+
return 'Connection was suddenly closed by the server. Please try again later.';
13+
case 'ETIMEDOUT':
14+
return 'Connection timed out. The server took too long to respond.';
15+
case 'CERT_HAS_EXPIRED':
16+
return 'The SSL certificate of the server has expired. This could be a security risk.';
17+
case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
18+
case 'CERT_SIGNATURE_FAILURE':
19+
case 'ERR_TLS_CERT_ALTNAME_INVALID':
20+
return 'SSL certificate validation failed. This could indicate a security issue or a misconfigured server.';
21+
default:
22+
return `Network error: ${error.message || error.code || 'Unknown error'}`;
23+
}
24+
}
25+
26+
/**
27+
* Get a human-readable error message for a HTTP status code
28+
*/
29+
export function humanHttpStatusError(statusCode: number): string {
30+
switch (statusCode) {
31+
case 400:
32+
return 'Bad request - the server could not understand the request';
33+
case 401:
34+
return 'Unauthorized - authentication is required';
35+
case 403:
36+
return 'Forbidden - you do not have permission to access this resource';
37+
case 404:
38+
return 'Not found - the requested resource does not exist';
39+
case 408:
40+
return 'Request timeout - the server timed out waiting for the request';
41+
case 429:
42+
return 'Too many requests - you have sent too many requests in a given amount of time';
43+
case 500:
44+
return 'Internal server error - something went wrong on the server';
45+
case 502:
46+
return 'Bad gateway - the server received an invalid response from an upstream server';
47+
case 503:
48+
return 'Service unavailable - the server is temporarily unable to handle the request';
49+
case 504:
50+
return 'Gateway timeout - the server did not receive a timely response from an upstream server';
51+
default:
52+
return statusCode >= 500
53+
? 'Server error - something went wrong on the server'
54+
: 'Client error - something went wrong with the request';
55+
}
56+
}

packages/@aws-cdk/toolkit-lib/test/api/environment/environment-resources.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ describe('validateversion without bootstrap stack', () => {
109109
await expect(envResources().validateVersion(8, '/abc')).resolves.toBeUndefined();
110110

111111
const filter = jest.spyOn(NoticesFilter.prototype, 'filter');
112-
notices.display();
112+
await notices.display();
113113

114114
expect(filter).toHaveBeenCalledTimes(1);
115115
expect(filter).toHaveBeenCalledWith({

0 commit comments

Comments
 (0)