Skip to content

Commit 9fcb95a

Browse files
committed
fix(cli): write notices to stderr or don't write them at all
On CI systems, the CDK CLI tries to avoid writing to `stderr` because there are a couple of CI systems that are commonly configured to fail if any output is written to `stderr`. That means all output, like notices, must go to `stdout`. Some commands (like `cdk synth` or `cdk bootstrap --show-template`) produce usable output on `stdout`, and these are commonly scripted, like piping their output to a file. However, because notices must go to `stdout`, these now interfere with the output of these commands. This needs a more thorough reworking of the CLI output streams, but there is a risk of affecting users who are currently relying on the fact that all output goes to `stdout`. In this PR, we are doing the first steps to solving this situation: - Notices will always go to `stderr`, so that they will never interfere with `stdout` anymore. - We try to detect what CI system we are running on, and we will completely suppress notices *unless* we determine that we are running on a CI system where it is "safe" to write to `sterr` (fail closed). "Safe" in this case means that the CI system doesn't come with an easy to toggle checkbox that makes commands fail based on what they print, instead of their exit codes. The only systems I'm aware of that have this checkbox are "Azure DevOps", and "TeamCity running PowerShell scripts". Even though we know the systems that are "unsafe", we will only show notices on systems known to be "safe". Fixes aws/aws-cdk#33589.
1 parent faa2de0 commit 9fcb95a

File tree

5 files changed

+194
-34
lines changed

5 files changed

+194
-34
lines changed

packages/@aws-cdk/toolkit-lib/lib/api/io/private/codes.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,28 @@ export const CODES = {
206206
level: 'error',
207207
}),
208208

209+
// Notices
210+
CDK_TOOLKIT_I0100: codeInfo({
211+
code: 'CDK_TOOLKIT_I0100',
212+
description: 'Notices decoration (the header or footer of a list of notices)',
213+
level: 'info',
214+
}),
215+
CDK_TOOLKIT_W0101: codeInfo({
216+
code: 'CDK_TOOLKIT_W0101',
217+
description: 'A notice that is marked as a warning',
218+
level: 'warn',
219+
}),
220+
CDK_TOOLKIT_E0101: codeInfo({
221+
code: 'CDK_TOOLKIT_E0101',
222+
description: 'A notice that is marked as an error',
223+
level: 'error',
224+
}),
225+
CDK_TOOLKIT_I0101: codeInfo({
226+
code: 'CDK_TOOLKIT_I0101',
227+
description: 'A notice that is marked as informational',
228+
level: 'info',
229+
}),
230+
209231
// Assembly codes
210232
CDK_ASSEMBLY_I0042: codeInfo({
211233
code: 'CDK_ASSEMBLY_I0042',
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
2+
interface CiSystem {
3+
/**
4+
* What's the name?
5+
*/
6+
readonly name: string;
7+
8+
/**
9+
* What environment variable indicates that we are running on this system?
10+
*/
11+
readonly detectEnvVar: string;
12+
13+
/**
14+
* Whether or not this CI system can be configured to fail on messages written to stderr
15+
*
16+
* With "can be configured", what we mean is that a checkbox or configuration
17+
* flag to enable this behavior comes out of the box with the CI system and (judgement
18+
* call), this flag is "commonly" used.
19+
*
20+
* Of course every CI system can be scripted to have this behavior, but that's
21+
* not what we mean.
22+
*/
23+
readonly canBeConfiguredToFailOnStdErr: boolean;
24+
}
25+
26+
const CI_SYSTEMS: CiSystem[] = [
27+
{
28+
name: 'Azure DevOps',
29+
// https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml
30+
detectEnvVar: 'TF_BUILD',
31+
canBeConfiguredToFailOnStdErr: true,
32+
},
33+
{
34+
name: 'TeamCity',
35+
// https://www.jetbrains.com/help/teamcity/predefined-build-parameters.html
36+
detectEnvVar: 'TEAMCITY_VERSION',
37+
// Can be configured to fail on stderr, when using a PowerShell task
38+
canBeConfiguredToFailOnStdErr: true,
39+
},
40+
{
41+
name: 'GitHub Actions',
42+
// https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables
43+
detectEnvVar: 'GITHUB_ACTION',
44+
canBeConfiguredToFailOnStdErr: false,
45+
},
46+
{
47+
name: 'CodeBuild',
48+
// https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-env-vars.html
49+
detectEnvVar: 'CODEBUILD_BUILD_ID',
50+
canBeConfiguredToFailOnStdErr: false,
51+
},
52+
{
53+
name: 'CircleCI',
54+
// https://circleci.com/docs/variables/#built-in-environment-variables
55+
detectEnvVar: 'CIRCLECI',
56+
canBeConfiguredToFailOnStdErr: false,
57+
},
58+
{
59+
name: 'Jenkins',
60+
// https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables
61+
detectEnvVar: 'EXECUTOR_NUMBER',
62+
canBeConfiguredToFailOnStdErr: false,
63+
},
64+
];
65+
66+
export function detectCiSystem(): CiSystem | undefined {
67+
for (const ciSystem of CI_SYSTEMS) {
68+
if (process.env[ciSystem.detectEnvVar]) {
69+
return ciSystem;
70+
}
71+
}
72+
return undefined;
73+
}
74+
75+
/**
76+
* Return whether the CI system we're detecting is safe to write to stderr on
77+
*
78+
* Returns `undefined` if the current CI system cannot be recognized.
79+
*/
80+
export function ciSystemIsStdErrSafe(): boolean | undefined {
81+
const x = detectCiSystem()?.canBeConfiguredToFailOnStdErr;
82+
return x === undefined ? undefined : !x;
83+
}

packages/aws-cdk/lib/cli/cli.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as cxapi from '@aws-cdk/cx-api';
22
import '@jsii/check-node/run';
33
import * as chalk from 'chalk';
44
import { CdkToolkit, AssetBuildTime } from './cdk-toolkit';
5+
import { ciSystemIsStdErrSafe } from './ci-systems';
56
import { parseCommandLineArguments } from './parse-command-line-arguments';
67
import { checkForPlatformWarnings } from './platform-warnings';
78

@@ -90,10 +91,23 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
9091
});
9192
await configuration.load();
9293

94+
const shouldDisplayNotices = configuration.settings.get(['notices']);
95+
if (shouldDisplayNotices !== undefined) {
96+
// Notices either go to stderr, or nowhere
97+
ioHost.noticesTarget = shouldDisplayNotices ? 'stderr' : 'drop';
98+
} else {
99+
// If the user didn't supply either `--notices` or `--no-notices`, we do
100+
// autodetection. The autodetection currently is: do write notices if we are
101+
// not on CI, or are on a CI system where we know that writing to stderr is
102+
// safe. We fail "closed"; that is, we decide to NOT print for unknown CI
103+
// systems, even though technically we maybe could.
104+
const safeToWriteToStdErr = !argv.ci || Boolean(ciSystemIsStdErrSafe());
105+
ioHost.noticesTarget = safeToWriteToStdErr ? 'stderr' : 'drop';
106+
}
107+
93108
const notices = Notices.create({
94109
context: configuration.context,
95110
output: configuration.settings.get(['outdir']),
96-
shouldDisplay: configuration.settings.get(['notices']),
97111
includeAcknowledged: cmd === 'notices' ? !argv.unacknowledged : false,
98112
httpOptions: {
99113
proxyAddress: configuration.settings.get(['proxy']),
@@ -458,7 +472,12 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
458472

459473
case 'notices':
460474
ioHost.currentAction = 'notices';
461-
// This is a valid command, but we're postponing its execution
475+
// If the user explicitly asks for notices, they are now the primary output
476+
// of the command and they should go to stdout.
477+
ioHost.noticesTarget = 'stdout';
478+
479+
// This is a valid command, but we're postponing its execution because displaying
480+
// notices automatically happens after every command.
462481
return;
463482

464483
case 'metadata':

packages/aws-cdk/lib/notices.ts

Lines changed: 21 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,6 @@ export interface NoticesProps {
3636
*/
3737
readonly output?: string;
3838

39-
/**
40-
* Global CLI option for whether we show notices
41-
*
42-
* @default true
43-
*/
44-
readonly shouldDisplay?: boolean;
45-
4639
/**
4740
* Options for the HTTP request
4841
*/
@@ -298,7 +291,6 @@ export class Notices {
298291

299292
private readonly context: Context;
300293
private readonly output: string;
301-
private readonly shouldDisplay: boolean;
302294
private readonly acknowledgedIssueNumbers: Set<Number>;
303295
private readonly includeAcknowlegded: boolean;
304296
private readonly httpOptions: SdkHttpOptions;
@@ -313,7 +305,6 @@ export class Notices {
313305
this.acknowledgedIssueNumbers = new Set(this.context.get('acknowledged-issue-numbers') ?? []);
314306
this.includeAcknowlegded = props.includeAcknowledged ?? false;
315307
this.output = props.output ?? 'cdk.out';
316-
this.shouldDisplay = props.shouldDisplay ?? true;
317308
this.httpOptions = props.httpOptions ?? {};
318309
}
319310

@@ -339,10 +330,6 @@ export class Notices {
339330
* If context is configured to not display notices, this will no-op.
340331
*/
341332
public async refresh(options: NoticesRefreshOptions = {}) {
342-
if (!this.shouldDisplay) {
343-
return;
344-
}
345-
346333
try {
347334
const underlyingDataSource = options.dataSource ?? new WebsiteNoticeDataSource(this.httpOptions);
348335
const dataSource = new CachedDataSource(CACHE_FILE_PATH, underlyingDataSource, options.force ?? false);
@@ -357,10 +344,6 @@ export class Notices {
357344
* Display the relevant notices (unless context dictates we shouldn't).
358345
*/
359346
public display(options: NoticesPrintOptions = {}) {
360-
if (!this.shouldDisplay) {
361-
return;
362-
}
363-
364347
const filteredNotices = NoticesFilter.filter({
365348
data: Array.from(this.data),
366349
cliVersion: versionNumber(),
@@ -369,29 +352,39 @@ export class Notices {
369352
});
370353

371354
if (filteredNotices.length > 0) {
372-
info('');
373-
info('NOTICES (What\'s this? https://github.com/aws/aws-cdk/wiki/CLI-Notices)');
374-
info('');
355+
info({
356+
code: 'CDK_TOOLKIT_I0100',
357+
message: [
358+
'',
359+
'NOTICES (What\'s this? https://github.com/aws/aws-cdk/wiki/CLI-Notices)',
360+
'',
361+
].join('\n'),
362+
});
375363
for (const filtered of filteredNotices) {
376-
const formatted = filtered.format();
364+
const formatted = filtered.format() + '\n';
377365
switch (filtered.notice.severity) {
378366
case 'warning':
379-
warning(formatted);
367+
warning({ code: 'CDK_TOOLKIT_W0101', message: formatted });
380368
break;
381369
case 'error':
382-
error(formatted);
370+
error({ code: 'CDK_TOOLKIT_E0101', message: formatted });
383371
break;
384372
default:
385-
info(formatted);
373+
info({ code: 'CDK_TOOLKIT_I0101', message: formatted });
374+
break;
386375
}
387-
info('');
388376
}
389-
info(`If you don’t want to see a notice anymore, use "cdk acknowledge <id>". For example, "cdk acknowledge ${filteredNotices[0].notice.issueNumber}".`);
377+
info({
378+
code: 'CDK_TOOLKIT_I0100',
379+
message: `If you don’t want to see a notice anymore, use "cdk acknowledge <id>". For example, "cdk acknowledge ${filteredNotices[0].notice.issueNumber}".`
380+
});
390381
}
391382

392383
if (options.showTotal ?? false) {
393-
info('');
394-
info(`There are ${filteredNotices.length} unacknowledged notice(s).`);
384+
info({
385+
code: 'CDK_TOOLKIT_I0100',
386+
message: `\nThere are ${filteredNotices.length} unacknowledged notice(s).`,
387+
});
395388
}
396389
}
397390
}

packages/aws-cdk/lib/toolkit/cli-io-host.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,11 @@ export interface CliIoHostProps {
170170
readonly stackProgress?: StackActivityProgress;
171171
}
172172

173+
/**
174+
* A type for configuring a target stream
175+
*/
176+
export type TargetStream = 'stdout' | 'stderr' | 'drop';
177+
173178
/**
174179
* A simple IO host for the CLI that writes messages to the console.
175180
*/
@@ -189,6 +194,14 @@ export class CliIoHost implements IIoHost {
189194
*/
190195
private static _instance: CliIoHost | undefined;
191196

197+
/**
198+
* Configure the target stream for notices
199+
*
200+
* (Not a setter because there's no need for additional logic when this value
201+
* is changed yet)
202+
*/
203+
public noticesTarget: TargetStream = 'stderr';
204+
192205
// internal state for getters/setter
193206
private _currentAction: ToolkitAction;
194207
private _isCI: boolean;
@@ -379,8 +392,8 @@ export class CliIoHost implements IIoHost {
379392
}
380393

381394
const output = this.formatMessage(msg);
382-
const stream = this.selectStream(msg.level);
383-
stream.write(output);
395+
const stream = this.selectStream(msg);
396+
stream?.write(output);
384397
}
385398

386399
/**
@@ -394,10 +407,21 @@ export class CliIoHost implements IIoHost {
394407
].includes(msg.code);
395408
}
396409

410+
/**
411+
* Determines the output stream, based on message and configuration.
412+
*/
413+
private selectStream(msg: IoMessage<any>): NodeJS.WriteStream | undefined {
414+
if (isNoticesMessage(msg)) {
415+
return targetStreamObject(this.noticesTarget);
416+
}
417+
418+
return this.selectStreamFromLevel(msg.level);
419+
}
420+
397421
/**
398422
* Determines the output stream, based on message level and configuration.
399423
*/
400-
private selectStream(level: IoMessageLevel) {
424+
private selectStreamFromLevel(level: IoMessageLevel): NodeJS.WriteStream {
401425
// The stream selection policy for the CLI is the following:
402426
//
403427
// (1) Messages of level `result` always go to `stdout`
@@ -506,7 +530,7 @@ export class CliIoHost implements IIoHost {
506530
*/
507531
private makeActivityPrinter() {
508532
const props: ActivityPrinterProps = {
509-
stream: this.selectStream('info'),
533+
stream: this.selectStreamFromLevel('info'),
510534
};
511535

512536
switch (this.stackProgress) {
@@ -568,3 +592,22 @@ export function isCI(): boolean {
568592
return process.env.CI !== undefined && process.env.CI !== 'false' && process.env.CI !== '0';
569593
}
570594

595+
function targetStreamObject(x: TargetStream): NodeJS.WriteStream | undefined {
596+
switch (x) {
597+
case 'stderr':
598+
return process.stderr;
599+
case 'stdout':
600+
return process.stdout;
601+
case 'drop':
602+
return undefined;
603+
}
604+
}
605+
606+
function isNoticesMessage(msg: IoMessage<any>) {
607+
return [
608+
'CDK_TOOLKIT_I0100',
609+
'CDK_TOOLKIT_W0101',
610+
'CDK_TOOLKIT_E0101',
611+
'CDK_TOOLKIT_I0101',
612+
].includes(msg.code);
613+
}

0 commit comments

Comments
 (0)