diff --git a/packages/aws-cdk-lib/aws-ecr-assets/README.md b/packages/aws-cdk-lib/aws-ecr-assets/README.md index b9d43f2aedbde..8b9bedc1d8211 100644 --- a/packages/aws-cdk-lib/aws-ecr-assets/README.md +++ b/packages/aws-cdk-lib/aws-ecr-assets/README.md @@ -51,6 +51,11 @@ asset hash. Additionally, you can supply `buildSecrets`. Your system must have Buildkit enabled, see https://docs.docker.com/build/buildkit/. +Also, similarly to `@aws-cdk/aws-s3-assets`, you can set the CDK_DOCKER environment +variable in order to provide a custom Docker executable command or path. This may sometimes +be needed when building in environments where the standard docker cannot be executed +(see https://github.com/aws/aws-cdk/issues/8460 for details). + SSH agent sockets or keys may be passed to docker build via `buildSsh`. ```ts @@ -209,5 +214,5 @@ pull images from this repository. If the pulling principal is not in the same account or is an AWS service that doesn't assume a role in your account (e.g. AWS CodeBuild), you must either copy the image to a new repository, or grant pull permissions on the resource policy of the repository. Since the repository is managed by the CDK bootstrap stack, -the following permissions must be granted there, or granted manually on the repository: "ecr:GetDownloadUrlForLayer", +the following permissions must be granted there, or granted manually on the repository: "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage" and "ecr:BatchCheckLayerAvailability". diff --git a/packages/aws-cdk/lib/toolkit/cli-io-host.ts b/packages/aws-cdk/lib/toolkit/cli-io-host.ts new file mode 100644 index 0000000000000..9955c6b6d76c7 --- /dev/null +++ b/packages/aws-cdk/lib/toolkit/cli-io-host.ts @@ -0,0 +1,142 @@ +import * as chalk from 'chalk'; + +/** + * Basic message structure for toolkit notifications. + * Messages are emitted by the toolkit and handled by the IoHost. + */ +interface IoMessage { + /** + * The time the message was emitted. + */ + readonly time: Date; + + /** + * The log level of the message. + */ + readonly level: IoMessageLevel; + + /** + * The action that triggered the message. + */ + readonly action: IoAction; + + /** + * A short code uniquely identifying message type. + */ + readonly code: string; + + /** + * The message text. + */ + readonly message: string; + + /** + * If true, the message will be written to stdout + * regardless of any other parameters. + * + * @default false + */ + readonly forceStdout?: boolean; +} + +export type IoMessageLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'; + +export type IoAction = 'synth' | 'list' | 'deploy' | 'destroy'; + +/** + * Options for the CLI IO host. + */ +interface CliIoHostOptions { + /** + * If true, the host will use TTY features like color. + */ + useTTY?: boolean; + + /** + * Flag representing whether the current process is running in a CI environment. + * If true, the host will write all messages to stdout, unless log level is 'error'. + * + * @default false + */ + ci?: boolean; +} + +/** + * A simple IO host for the CLI that writes messages to the console. + */ +export class CliIoHost { + private readonly pretty_messages: boolean; + private readonly ci: boolean; + + constructor(options: CliIoHostOptions) { + this.pretty_messages = options.useTTY ?? process.stdout.isTTY ?? false; + this.ci = options.ci ?? false; + } + + /** + * Notifies the host of a message. + * The caller waits until the notification completes. + */ + async notify(msg: IoMessage): Promise { + const output = this.formatMessage(msg); + + const stream = this.getStream(msg.level, msg.forceStdout ?? false); + + return new Promise((resolve, reject) => { + stream.write(output, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Determines which output stream to use based on log level and configuration. + */ + private getStream(level: IoMessageLevel, forceStdout: boolean) { + // For legacy purposes all log streams are written to stderr by default, unless + // specified otherwise, by passing `forceStdout`, which is used by the `data()` logging function, or + // if the CDK is running in a CI environment. This is because some CI environments will immediately + // fail if stderr is written to. In these cases, we detect if we are in a CI environment and + // write all messages to stdout instead. + if (forceStdout) { + return process.stdout; + } + if (level == 'error') return process.stderr; + return this.ci ? process.stdout : process.stderr; + } + + /** + * Formats a message for console output with optional color support + */ + private formatMessage(msg: IoMessage): string { + // apply provided style or a default style if we're in TTY mode + let message_text = this.pretty_messages + ? styleMap[msg.level](msg.message) + : msg.message; + + // prepend timestamp if IoMessageLevel is DEBUG or TRACE. Postpend a newline. + return ((msg.level === 'debug' || msg.level === 'trace') + ? `[${this.formatTime(msg.time)}] ${message_text}` + : message_text) + '\n'; + } + + /** + * Formats date to HH:MM:SS + */ + private formatTime(d: Date): string { + const pad = (n: number): string => n.toString().padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + } +} + +export const styleMap: Record string> = { + error: chalk.red, + warn: chalk.yellow, + info: chalk.white, + debug: chalk.gray, + trace: chalk.gray, +}; diff --git a/packages/aws-cdk/test/toolkit/cli-io-host.test.ts b/packages/aws-cdk/test/toolkit/cli-io-host.test.ts new file mode 100644 index 0000000000000..983294583736a --- /dev/null +++ b/packages/aws-cdk/test/toolkit/cli-io-host.test.ts @@ -0,0 +1,351 @@ +import * as chalk from 'chalk'; +import { CliIoHost, IoAction, styleMap } from '../../lib/toolkit/cli-io-host'; + +describe('CliIoHost', () => { + let mockStdout: jest.Mock; + let mockStderr: jest.Mock; + + beforeEach(() => { + mockStdout = jest.fn(); + mockStderr = jest.fn(); + + // Mock the write methods of STD out and STD err + jest.spyOn(process.stdout, 'write').mockImplementation((str: any, encoding?: any, cb?: any) => { + mockStdout(str.toString()); + // Handle callback + const callback = typeof encoding === 'function' ? encoding : cb; + if (callback) callback(); + return true; + }); + + jest.spyOn(process.stderr, 'write').mockImplementation((str: any, encoding?: any, cb?: any) => { + mockStderr(str.toString()); + // Handle callback + const callback = typeof encoding === 'function' ? encoding : cb; + if (callback) callback(); + return true; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('stream selection', () => { + test('writes to stderr by default for non-error messages in non-CI mode', async () => { + const host = new CliIoHost({ useTTY: true }); + await host.notify({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'TEST', + message: 'test message', + }); + + expect(mockStderr).toHaveBeenCalledWith(chalk.white('test message') + '\n'); + expect(mockStdout).not.toHaveBeenCalled(); + }); + + test('writes to stderr for error level with red color', async () => { + const host = new CliIoHost({ useTTY: true }); + await host.notify({ + time: new Date(), + level: 'error', + action: 'synth', + code: 'TEST', + message: 'error message', + }); + + expect(mockStderr).toHaveBeenCalledWith(chalk.red('error message') + '\n'); + expect(mockStdout).not.toHaveBeenCalled(); + }); + + test('writes to stdout when forceStdout is true', async () => { + const host = new CliIoHost({ useTTY: true }); + await host.notify({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'TEST', + message: 'forced message', + forceStdout: true, + }); + + expect(mockStdout).toHaveBeenCalledWith(chalk.white('forced message') + '\n'); + expect(mockStderr).not.toHaveBeenCalled(); + }); + }); + + describe('TTY formatting', () => { + test('accepts inlined chalk styles', async () => { + const host = new CliIoHost({ useTTY: true }); + await host.notify({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'TEST', + message: chalk.green('[green prefix message]') + ' regular info level text', + forceStdout: true, + }); + + const expected_text = styleMap.info(chalk.green('[green prefix message]') + ' regular info level text'); + expect(mockStdout).toHaveBeenCalledWith(expected_text + '\n'); + }); + + test('applies custom style in TTY mode', async () => { + const host = new CliIoHost({ useTTY: true }); + const customStyle = (str: string) => `\x1b[35m${str}\x1b[0m`; // Custom purple color + + await host.notify({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'TEST', + message: customStyle('styled message'), + forceStdout: true, + }); + + const expected_text = styleMap.info(customStyle('styled message')); + expect(mockStdout).toHaveBeenCalledWith(expected_text + '\n'); + }); + + test('applies default style by message level in TTY mode', async () => { + const host = new CliIoHost({ useTTY: true }); + await host.notify({ + time: new Date(), + level: 'warn', + action: 'synth', + code: 'TEST', + message: 'warning message', + forceStdout: true, + }); + + expect(mockStdout).toHaveBeenCalledWith(chalk.yellow('warning message') + '\n'); + }); + + test('does not apply styles in non-TTY mode', async () => { + const host = new CliIoHost({ useTTY: false }); + await host.notify({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'TEST', + message: 'unstyled message', + forceStdout: true, + }); + + expect(mockStdout).toHaveBeenCalledWith('unstyled message\n'); + }); + }); + + describe('timestamp handling', () => { + test('includes timestamp for DEBUG level with gray color', async () => { + const host = new CliIoHost({ useTTY: true }); + const testDate = new Date('2024-01-01T12:34:56'); + + await host.notify({ + time: testDate, + level: 'debug', + action: 'synth', + code: 'TEST', + message: 'debug message', + forceStdout: true, + }); + + expect(mockStdout).toHaveBeenCalledWith(`[12:34:56] ${chalk.gray('debug message')}\n`); + }); + + test('includes timestamp for TRACE level with gray color', async () => { + const host = new CliIoHost({ useTTY: true }); + const testDate = new Date('2024-01-01T12:34:56'); + + await host.notify({ + time: testDate, + level: 'trace', + action: 'synth', + code: 'TEST', + message: 'trace message', + forceStdout: true, + }); + + expect(mockStdout).toHaveBeenCalledWith(`[12:34:56] ${chalk.gray('trace message')}\n`); + }); + + test('excludes timestamp for other levels but includes color', async () => { + const host = new CliIoHost({ useTTY: true }); + const testDate = new Date('2024-01-01T12:34:56'); + + await host.notify({ + time: testDate, + level: 'info', + action: 'synth', + code: 'TEST', + message: 'info message', + forceStdout: true, + }); + + expect(mockStdout).toHaveBeenCalledWith(chalk.white('info message') + '\n'); + }); + }); + + describe('CI mode behavior', () => { + test('writes to stdout in CI mode when level is not error', async () => { + const host = new CliIoHost({ useTTY: true, ci: true }); + await host.notify({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'TEST', + message: 'ci message', + }); + + expect(mockStdout).toHaveBeenCalledWith(chalk.white('ci message') + '\n'); + expect(mockStderr).not.toHaveBeenCalled(); + }); + + test('writes to stdout in CI mode with forceStdout', async () => { + const host = new CliIoHost({ useTTY: true, ci: true }); + await host.notify({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'TEST', + message: 'ci message', + forceStdout: true, + }); + + expect(mockStdout).toHaveBeenCalledWith(chalk.white('ci message') + '\n'); + expect(mockStderr).not.toHaveBeenCalled(); + }); + + test('writes to stderr for error level in CI mode', async () => { + const host = new CliIoHost({ useTTY: true, ci: true }); + await host.notify({ + time: new Date(), + level: 'error', + action: 'synth', + code: 'TEST', + message: 'ci error message', + }); + + expect(mockStderr).toHaveBeenCalledWith(chalk.red('ci error message') + '\n'); + expect(mockStdout).not.toHaveBeenCalled(); + }); + + test('writes to stdout for error level in CI mode with forceStdOut', async () => { + const host = new CliIoHost({ useTTY: true, ci: true }); + await host.notify({ + time: new Date(), + level: 'error', + action: 'synth', + code: 'TEST', + message: 'ci error message', + forceStdout: true, + }); + + expect(mockStdout).toHaveBeenCalledWith(chalk.red('ci error message') + '\n'); + expect(mockStderr).not.toHaveBeenCalled(); + }); + }); + + describe('special characters handling', () => { + test('handles messages with ANSI escape sequences', async () => { + const host = new CliIoHost({ useTTY: true }); + await host.notify({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'TEST', + message: '\u001b[31mred text\u001b[0m', + forceStdout: true, + }); + + expect(mockStdout).toHaveBeenCalledWith(chalk.white('\u001b[31mred text\u001b[0m') + '\n'); + }); + + test('handles messages with newlines', async () => { + const host = new CliIoHost({ useTTY: true }); + await host.notify({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'TEST', + message: 'line1\nline2\nline3', + forceStdout: true, + }); + + expect(mockStdout).toHaveBeenCalledWith(chalk.white('line1\nline2\nline3') + '\n'); + }); + + test('handles empty messages', async () => { + const host = new CliIoHost({ useTTY: true }); + await host.notify({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'TEST', + message: '', + forceStdout: true, + }); + + expect(mockStdout).toHaveBeenCalledWith(chalk.white('') + '\n'); + }); + }); + + describe('action and code behavior', () => { + test('handles all possible actions', async () => { + const host = new CliIoHost({ useTTY: true }); + const actions: IoAction[] = ['synth', 'list', 'deploy', 'destroy']; + + for (const action of actions) { + await host.notify({ + time: new Date(), + level: 'info', + action, + code: 'TEST', + message: `${action} message`, + forceStdout: true, + }); + + expect(mockStdout).toHaveBeenCalledWith(chalk.white(`${action} message`) + '\n'); + } + }); + + test('handles various code values', async () => { + const host = new CliIoHost({ useTTY: true }); + const testCases = ['ERROR_1', 'SUCCESS', 'WARN_XYZ', '123']; + + for (const code of testCases) { + await host.notify({ + time: new Date(), + level: 'info', + action: 'synth', + code, + message: `message with code ${code}`, + forceStdout: true, + }); + + expect(mockStdout).toHaveBeenCalledWith(chalk.white(`message with code ${code}`) + '\n'); + } + }); + }); + + describe('error handling', () => { + test('rejects on write error', async () => { + jest.spyOn(process.stdout, 'write').mockImplementation((_: any, callback: any) => { + if (callback) callback(new Error('Write failed')); + return true; + }); + + const host = new CliIoHost({ useTTY: true }); + await expect(host.notify({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'TEST', + message: 'test message', + forceStdout: true, + })).rejects.toThrow('Write failed'); + }); + }); +}); diff --git a/tools/@aws-cdk/cli-args-gen/package.json b/tools/@aws-cdk/cli-args-gen/package.json index b290bfc3b7fcd..29a15729acc0c 100644 --- a/tools/@aws-cdk/cli-args-gen/package.json +++ b/tools/@aws-cdk/cli-args-gen/package.json @@ -28,7 +28,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@cdklabs/typewriter": "^0.0.4", + "@cdklabs/typewriter": "^0.0.5", "lodash.clonedeep": "^4.5.0", "prettier": "^2.8.8" }, diff --git a/tools/@aws-cdk/cli-args-gen/test/cli-type-gen.test.ts b/tools/@aws-cdk/cli-args-gen/test/cli-type-gen.test.ts index 74d21f47ecb43..621b8e060b088 100644 --- a/tools/@aws-cdk/cli-args-gen/test/cli-type-gen.test.ts +++ b/tools/@aws-cdk/cli-args-gen/test/cli-type-gen.test.ts @@ -43,7 +43,7 @@ describe('render', () => { // GENERATED FROM packages/aws-cdk/lib/config.ts. // Do not edit by hand; all changes will be overwritten at build time from the config file. // ------------------------------------------------------------------------------------------- - /* eslint-disable max-len */ + /* eslint-disable @stylistic/max-len */ import { Command } from './settings'; /** @@ -145,7 +145,7 @@ describe('render', () => { // GENERATED FROM packages/aws-cdk/lib/config.ts. // Do not edit by hand; all changes will be overwritten at build time from the config file. // ------------------------------------------------------------------------------------------- - /* eslint-disable max-len */ + /* eslint-disable @stylistic/max-len */ import { Command } from './settings'; /** diff --git a/tools/@aws-cdk/cli-args-gen/test/yargs-gen.test.ts b/tools/@aws-cdk/cli-args-gen/test/yargs-gen.test.ts index 722d7d7807225..75540361fcc3a 100644 --- a/tools/@aws-cdk/cli-args-gen/test/yargs-gen.test.ts +++ b/tools/@aws-cdk/cli-args-gen/test/yargs-gen.test.ts @@ -28,7 +28,7 @@ describe('render', () => { // GENERATED FROM packages/aws-cdk/lib/config.ts. // Do not edit by hand; all changes will be overwritten at build time from the config file. // ------------------------------------------------------------------------------------------- - /* eslint-disable max-len */ + /* eslint-disable @stylistic/max-len */ import { Argv } from 'yargs'; import * as helpers from './util/yargs-helpers'; @@ -95,7 +95,7 @@ describe('render', () => { // GENERATED FROM packages/aws-cdk/lib/config.ts. // Do not edit by hand; all changes will be overwritten at build time from the config file. // ------------------------------------------------------------------------------------------- - /* eslint-disable max-len */ + /* eslint-disable @stylistic/max-len */ import { Argv } from 'yargs'; import * as helpers from './util/yargs-helpers'; @@ -180,7 +180,7 @@ describe('render', () => { // GENERATED FROM packages/aws-cdk/lib/config.ts. // Do not edit by hand; all changes will be overwritten at build time from the config file. // ------------------------------------------------------------------------------------------- - /* eslint-disable max-len */ + /* eslint-disable @stylistic/max-len */ import { Argv } from 'yargs'; import * as helpers from './util/yargs-helpers'; diff --git a/tools/@aws-cdk/prlint/lint.ts b/tools/@aws-cdk/prlint/lint.ts index 0cb1b89621a24..48c5a23819459 100644 --- a/tools/@aws-cdk/prlint/lint.ts +++ b/tools/@aws-cdk/prlint/lint.ts @@ -265,8 +265,8 @@ export class PullRequestLinter { }); } - const comments = await this.client.issues.listComments(this.issueParams); - if (comments.data.find(comment => comment.body?.toLowerCase().includes("exemption request"))) { + const comments = await this.client.paginate(this.client.issues.listComments, this.issueParams); + if (comments.find(comment => comment.body?.toLowerCase().includes("exemption request"))) { body += '\n\n✅ A exemption request has been requested. Please wait for a maintainer\'s review.'; } await this.client.issues.createComment({ @@ -303,8 +303,8 @@ export class PullRequestLinter { * @returns Existing review, if present */ private async findExistingPRLinterReview(): Promise { - const reviews = await this.client.pulls.listReviews(this.prParams); - return reviews.data.find((review) => review.user?.login === 'aws-cdk-automation' && review.state !== 'DISMISSED') as Review; + const reviews = await this.client.paginate(this.client.pulls.listReviews, this.prParams); + return reviews.find((review) => review.user?.login === 'aws-cdk-automation' && review.state !== 'DISMISSED') as Review; } /** @@ -312,8 +312,8 @@ export class PullRequestLinter { * @returns Existing comment, if present */ private async findExistingPRLinterComment(): Promise { - const comments = await this.client.issues.listComments(this.issueParams); - return comments.data.find((comment) => comment.user?.login === 'aws-cdk-automation' && comment.body?.startsWith('The pull request linter fails with the following errors:')) as Comment; + const comments = await this.client.paginate(this.client.issues.listComments, this.issueParams); + return comments.find((comment) => comment.user?.login === 'aws-cdk-automation' && comment.body?.startsWith('The pull request linter fails with the following errors:')) as Comment; } /** diff --git a/yarn.lock b/yarn.lock index 087c8e04423ac..679dd0d5e7354 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5111,10 +5111,10 @@ resolved "https://registry.npmjs.org/@cdklabs/typewriter/-/typewriter-0.0.3.tgz#37143d4cf004085bce7d1bbc9139bbf4bf4403a8" integrity sha512-dymXkqVKZLLQJGxZGvmCn9ZIDCiPM5hC1P7dABob8C0m5P0bf91W7HsPUu3yHomdFxoHAWFaXAZ9i3Q+uVeJ5g== -"@cdklabs/typewriter@^0.0.4": - version "0.0.4" - resolved "https://registry.npmjs.org/@cdklabs/typewriter/-/typewriter-0.0.4.tgz#4c2ae97c05eec921131549de08e37e5ecda80e43" - integrity sha512-FAcF8k0nNo3VmlGP3UHi4h2K5sohY/7Gcv4p7epMGwT4U3PbAsc3xWL42IAD1a/1g/rvrtIaRHbuGUp1O1VNvw== +"@cdklabs/typewriter@^0.0.5": + version "0.0.5" + resolved "https://registry.npmjs.org/@cdklabs/typewriter/-/typewriter-0.0.5.tgz#edbec5c2e6dd45c803154d7e521ca38746a08d89" + integrity sha512-gLp7s9bhHOIN9SN6jhdVi3cLp0YisMkvn4Ct3KeqySR7H1Q5nytKvV0NWUC1FrdNsPoKvulUFIGtqbwCFZt9NQ== "@colors/colors@1.5.0": version "1.5.0"