Skip to content

Commit 70eab93

Browse files
committed
fix(cli): calling cli with --no-notices makes an unexpected network call to refresh notices
1 parent 27aad24 commit 70eab93

File tree

13 files changed

+259
-95
lines changed

13 files changed

+259
-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)