diff --git a/.projenrc.ts b/.projenrc.ts index ee4f3bd38..b154d9750 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -681,11 +681,14 @@ const tmpToolkitHelpers = configureProject( deps: [ cloudAssemblySchema.name, cloudFormationDiff, + cxApi, + `@aws-sdk/client-cloudformation@${CLI_SDK_V3_RANGE}`, 'archiver', 'chalk@4', 'glob', 'semver', 'uuid', + 'wrap-ansi@^7', // Last non-ESM version 'yaml@^1', ], tsconfig: { diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json index d638a847a..4af6f4ece 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json +++ b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/deps.json @@ -109,6 +109,15 @@ "name": "@aws-cdk/cloudformation-diff", "type": "runtime" }, + { + "name": "@aws-cdk/cx-api", + "type": "runtime" + }, + { + "name": "@aws-sdk/client-cloudformation", + "version": "^3", + "type": "runtime" + }, { "name": "archiver", "type": "runtime" @@ -130,6 +139,11 @@ "name": "uuid", "type": "runtime" }, + { + "name": "wrap-ansi", + "version": "^7", + "type": "runtime" + }, { "name": "yaml", "version": "^1", diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json index 4ef457ac1..328be7de6 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json +++ b/packages/@aws-cdk/tmp-toolkit-helpers/.projen/tasks.json @@ -37,7 +37,7 @@ }, "steps": [ { - "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@cdklabs/eslint-plugin,@types/archiver,@types/jest,@types/semver,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,fast-check,jest,projen,ts-jest,@aws-cdk/cloud-assembly-schema,archiver,glob,semver,uuid" + "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@cdklabs/eslint-plugin,@types/archiver,@types/jest,@types/semver,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,fast-check,jest,projen,ts-jest,@aws-cdk/cloud-assembly-schema,@aws-cdk/cx-api,archiver,glob,semver,uuid" } ] }, diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/package.json b/packages/@aws-cdk/tmp-toolkit-helpers/package.json index 5a3325f14..50c08a696 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/package.json +++ b/packages/@aws-cdk/tmp-toolkit-helpers/package.json @@ -57,11 +57,14 @@ "dependencies": { "@aws-cdk/cloud-assembly-schema": "^0.0.0", "@aws-cdk/cloudformation-diff": "^0.0.0", + "@aws-cdk/cx-api": "^2.186.0", + "@aws-sdk/client-cloudformation": "^3", "archiver": "^7.0.1", "chalk": "4", "glob": "^11.0.1", "semver": "^7.7.1", "uuid": "^11.1.0", + "wrap-ansi": "^7", "yaml": "^1" }, "keywords": [ diff --git a/packages/aws-cdk/lib/cli/activity-printer/base.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/base.ts similarity index 94% rename from packages/aws-cdk/lib/cli/activity-printer/base.ts rename to packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/base.ts index ce9e9b432..9d89e82e1 100644 --- a/packages/aws-cdk/lib/cli/activity-printer/base.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/base.ts @@ -1,8 +1,8 @@ import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; -import { type StackActivity, type StackProgress } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; -import { IO } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private'; +import type { IoMessage } from '../../api/io'; +import { type StackActivity, type StackProgress } from '../../api/io/payloads'; +import { IO } from '../../api/io/private'; import { maxResourceTypeLength, stackEventHasErrorMessage } from '../../util'; -import type { IoMessage } from '../io-host/cli-io-host'; export interface IActivityPrinter { notify(msg: IoMessage): void; diff --git a/packages/aws-cdk/lib/cli/activity-printer/current.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/current.ts similarity index 98% rename from packages/aws-cdk/lib/cli/activity-printer/current.ts rename to packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/current.ts index b2f1e38e4..119ebbd6f 100644 --- a/packages/aws-cdk/lib/cli/activity-printer/current.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/current.ts @@ -1,9 +1,9 @@ import * as util from 'util'; -import type { StackActivity } from '@aws-cdk/tmp-toolkit-helpers'; import * as chalk from 'chalk'; import type { ActivityPrinterProps } from './base'; import { ActivityPrinterBase } from './base'; import { RewritableBlock } from './display'; +import type { StackActivity } from '../../api/io/payloads'; import { padLeft, padRight, stackEventHasErrorMessage } from '../../util'; /** diff --git a/packages/aws-cdk/lib/cli/activity-printer/display.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/display.ts similarity index 100% rename from packages/aws-cdk/lib/cli/activity-printer/display.ts rename to packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/display.ts diff --git a/packages/aws-cdk/lib/cli/activity-printer/history.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/history.ts similarity index 98% rename from packages/aws-cdk/lib/cli/activity-printer/history.ts rename to packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/history.ts index 4a082d7ce..8a2ff7495 100644 --- a/packages/aws-cdk/lib/cli/activity-printer/history.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/history.ts @@ -1,8 +1,8 @@ import * as util from 'util'; -import type { StackActivity } from '@aws-cdk/tmp-toolkit-helpers'; import * as chalk from 'chalk'; import type { ActivityPrinterProps } from './base'; import { ActivityPrinterBase } from './base'; +import type { StackActivity } from '../../api/io/payloads'; import { padRight } from '../../util'; /** diff --git a/packages/aws-cdk/lib/cli/activity-printer/index.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/index.ts similarity index 100% rename from packages/aws-cdk/lib/cli/activity-printer/index.ts rename to packages/@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer/index.ts diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/private/index.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/index.ts new file mode 100644 index 000000000..ab21a80b6 --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/private/index.ts @@ -0,0 +1 @@ +export * from './activity-printer'; diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/test/_helpers/assembly.ts b/packages/@aws-cdk/tmp-toolkit-helpers/test/_helpers/assembly.ts new file mode 100644 index 000000000..b6d807db9 --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/test/_helpers/assembly.ts @@ -0,0 +1,165 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ArtifactMetadataEntryType, ArtifactType, type AssetManifest, type AssetMetadataEntry, type AwsCloudFormationStackProperties, type MetadataEntry, type MissingContext } from '@aws-cdk/cloud-assembly-schema'; +import { CloudAssembly, CloudAssemblyBuilder, type CloudFormationStackArtifact, type StackMetadata } from '@aws-cdk/cx-api'; + +export const DEFAULT_FAKE_TEMPLATE = { No: 'Resources' }; + +const SOME_RECENT_SCHEMA_VERSION = '30.0.0'; + +export interface TestStackArtifact { + stackName: string; + template?: any; + env?: string; + depends?: string[]; + metadata?: StackMetadata; + notificationArns?: string[]; + + /** Old-style assets */ + assets?: AssetMetadataEntry[]; + properties?: Partial; + terminationProtection?: boolean; + displayName?: string; + + /** New-style assets */ + assetManifest?: AssetManifest; +} + +export interface TestAssembly { + stacks: TestStackArtifact[]; + missing?: MissingContext[]; + nestedAssemblies?: TestAssembly[]; + schemaVersion?: string; +} + +function clone(obj: any) { + return JSON.parse(JSON.stringify(obj)); +} + +function addAttributes(assembly: TestAssembly, builder: CloudAssemblyBuilder) { + for (const stack of assembly.stacks) { + const templateFile = `${stack.stackName}.template.json`; + const template = stack.template ?? DEFAULT_FAKE_TEMPLATE; + fs.writeFileSync(path.join(builder.outdir, templateFile), JSON.stringify(template, undefined, 2)); + addNestedStacks(templateFile, builder.outdir, template); + + // we call patchStackTags here to simulate the tags formatter + // that is used when building real manifest files. + const metadata: { [path: string]: MetadataEntry[] } = patchStackTags({ ...stack.metadata }); + for (const asset of stack.assets || []) { + metadata[asset.id] = [{ type: ArtifactMetadataEntryType.ASSET, data: asset }]; + } + + for (const missing of assembly.missing || []) { + builder.addMissing(missing); + } + + const dependencies = [...(stack.depends ?? [])]; + + if (stack.assetManifest) { + const manifestFile = `${stack.stackName}.assets.json`; + fs.writeFileSync(path.join(builder.outdir, manifestFile), JSON.stringify(stack.assetManifest, undefined, 2)); + dependencies.push(`${stack.stackName}.assets`); + builder.addArtifact(`${stack.stackName}.assets`, { + type: ArtifactType.ASSET_MANIFEST, + environment: stack.env || 'aws://123456789012/here', + properties: { + file: manifestFile, + }, + }); + } + + builder.addArtifact(stack.stackName, { + type: ArtifactType.AWS_CLOUDFORMATION_STACK, + environment: stack.env || 'aws://123456789012/here', + + dependencies, + metadata, + properties: { + ...stack.properties, + templateFile, + terminationProtection: stack.terminationProtection, + notificationArns: stack.notificationArns, + }, + displayName: stack.displayName, + }); + } +} + +function addNestedStacks(templatePath: string, outdir: string, rootStackTemplate?: any) { + let template = rootStackTemplate; + + if (!template) { + const templatePathWithDir = path.join('nested-stack-templates', templatePath); + template = JSON.parse(fs.readFileSync(path.join(__dirname, '..', templatePathWithDir)).toString()); + fs.writeFileSync(path.join(outdir, templatePath), JSON.stringify(template, undefined, 2)); + } + + for (const logicalId of Object.keys(template.Resources ?? {})) { + if (template.Resources[logicalId].Type === 'AWS::CloudFormation::Stack') { + if (template.Resources[logicalId].Metadata && template.Resources[logicalId].Metadata['aws:asset:path']) { + const nestedTemplatePath = template.Resources[logicalId].Metadata['aws:asset:path']; + addNestedStacks(nestedTemplatePath, outdir); + } + } + } +} + +function rewriteManifestVersion(directory: string, version: string) { + const manifestFile = `${directory}/manifest.json`; + const contents = JSON.parse(fs.readFileSync(`${directory}/manifest.json`, 'utf-8')); + contents.version = version; + fs.writeFileSync(manifestFile, JSON.stringify(contents, undefined, 2)); +} + +function cxapiAssemblyWithForcedVersion(asm: CloudAssembly, version: string) { + rewriteManifestVersion(asm.directory, version); + return new CloudAssembly(asm.directory, { skipVersionCheck: true }); +} + +export function testAssembly(assembly: TestAssembly): CloudAssembly { + const builder = new CloudAssemblyBuilder(); + addAttributes(assembly, builder); + + if (assembly.nestedAssemblies != null && assembly.nestedAssemblies.length > 0) { + assembly.nestedAssemblies?.forEach((nestedAssembly: TestAssembly, i: number) => { + const nestedAssemblyBuilder = builder.createNestedAssembly(`nested${i}`, `nested${i}`); + addAttributes(nestedAssembly, nestedAssemblyBuilder); + nestedAssemblyBuilder.buildAssembly(); + }); + } + + const asm = builder.buildAssembly(); + return cxapiAssemblyWithForcedVersion(asm, assembly.schemaVersion ?? SOME_RECENT_SCHEMA_VERSION); +} + +/** + * Transform stack tags from how they are decalred in source code (lower cased) + * to how they are stored on disk (upper cased). In real synthesis this is done + * by a special tags formatter. + * + * @see aws-cdk-lib/lib/stack.ts + */ +function patchStackTags(metadata: { [path: string]: MetadataEntry[] }): { + [path: string]: MetadataEntry[]; +} { + const cloned = clone(metadata) as { [path: string]: MetadataEntry[] }; + + for (const metadataEntries of Object.values(cloned)) { + for (const metadataEntry of metadataEntries) { + if (metadataEntry.type === ArtifactMetadataEntryType.STACK_TAGS && metadataEntry.data) { + const metadataAny = metadataEntry as any; + + metadataAny.data = metadataAny.data.map((t: any) => { + return { Key: t.key, Value: t.value }; + }); + } + } + } + return cloned; +} + +export function testStack(stack: TestStackArtifact): CloudFormationStackArtifact { + const assembly = testAssembly({ stacks: [stack] }); + return assembly.getStackByName(stack.stackName); +} diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/test/_helpers/console-listener.ts b/packages/@aws-cdk/tmp-toolkit-helpers/test/_helpers/console-listener.ts new file mode 100644 index 000000000..52d63355a --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/test/_helpers/console-listener.ts @@ -0,0 +1,65 @@ +import { EventEmitter } from 'events'; + +export type Output = ReadonlyArray; + +export interface Options { + isTTY?: boolean; +} + +export interface Inspector { + output: Output; + restore: () => void; +} + +class ConsoleListener { + private _stream: NodeJS.WriteStream; + private _options?: Options; + + constructor(stream: NodeJS.WriteStream, options?: Options) { + this._stream = stream; + this._options = options; + } + + inspect(): Inspector { + let isTTY; + if (this._options && this._options.isTTY !== undefined) { + isTTY = this._options.isTTY; + } + + const output: string[] = []; + const stream = this._stream; + const res: EventEmitter & Partial = new EventEmitter(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + const originalWrite = stream.write; + stream.write = (string: string) => { + output.push(string); + return res.emit('data', string); + }; + + const originalIsTTY = stream.isTTY; + if (isTTY === true) { + stream.isTTY = isTTY; + } + + res.output = output; + res.restore = () => { + stream.write = originalWrite; + stream.isTTY = originalIsTTY; + }; + return (res as Inspector); + } + + inspectSync(fn: (output: Output) => void): Output { + const inspect = this.inspect(); + try { + fn(inspect.output); + } finally { + inspect.restore(); + } + return inspect.output; + } +} + +export const stdout = new ConsoleListener(process.stdout); +export const stderr = new ConsoleListener(process.stderr); diff --git a/packages/aws-cdk/test/cli/activity-monitor/display.test.ts b/packages/@aws-cdk/tmp-toolkit-helpers/test/activity-monitor/display.test.ts similarity index 94% rename from packages/aws-cdk/test/cli/activity-monitor/display.test.ts rename to packages/@aws-cdk/tmp-toolkit-helpers/test/activity-monitor/display.test.ts index 7dd67f49a..a3d64dc64 100644 --- a/packages/aws-cdk/test/cli/activity-monitor/display.test.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/test/activity-monitor/display.test.ts @@ -1,5 +1,4 @@ -/* eslint-disable import/order */ -import { RewritableBlock } from '../../../lib/cli/activity-printer/display'; +import { RewritableBlock } from '../../src/private/activity-printer/display'; import { stderr } from '../_helpers/console-listener'; describe('Rewritable Block Tests', () => { diff --git a/packages/aws-cdk/test/cli/activity-monitor/history.test.ts b/packages/@aws-cdk/tmp-toolkit-helpers/test/activity-monitor/history.test.ts similarity index 89% rename from packages/aws-cdk/test/cli/activity-monitor/history.test.ts rename to packages/@aws-cdk/tmp-toolkit-helpers/test/activity-monitor/history.test.ts index 4bc85f0c7..ad818e92f 100644 --- a/packages/aws-cdk/test/cli/activity-monitor/history.test.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/test/activity-monitor/history.test.ts @@ -1,9 +1,8 @@ -import { ResourceStatus } from "@aws-sdk/client-cloudformation"; -import { HistoryActivityPrinter } from "../../../lib/cli/activity-printer"; -import { CliIoHost } from "../../../lib/cli/io-host"; -import { testStack } from "../../_helpers"; -import { stderr } from "../_helpers/console-listener"; +import { ResourceStatus } from '@aws-sdk/client-cloudformation'; import * as chalk from 'chalk'; +import { HistoryActivityPrinter } from '../../src/private/activity-printer'; +import { testStack } from '../_helpers/assembly'; +import { stderr } from '../_helpers/console-listener'; let TIMESTAMP: number; let HUMAN_TIME: string; @@ -11,7 +10,6 @@ let HUMAN_TIME: string; beforeAll(() => { TIMESTAMP = new Date().getTime(); HUMAN_TIME = new Date(TIMESTAMP).toLocaleTimeString(); - CliIoHost.instance().isCI = false; }); test('prints "IN_PROGRESS" ResourceStatus', () => { @@ -31,12 +29,12 @@ test('prints "IN_PROGRESS" ResourceStatus', () => { EventId: '', StackName: 'stack-name', }, - deployment: "test", + deployment: 'test', progress: { completed: 0, total: 2, - formatted: "0/4" - } + formatted: '0/4', + }, }); historyActivityPrinter.stop(); }); @@ -46,7 +44,6 @@ test('prints "IN_PROGRESS" ResourceStatus', () => { ); }); - test('prints "Failed Resources:" list, when at least one deployment fails', () => { const historyActivityPrinter = new HistoryActivityPrinter({ stream: process.stderr, @@ -64,12 +61,12 @@ test('prints "Failed Resources:" list, when at least one deployment fails', () = EventId: '', StackName: 'stack-name', }, - deployment: "test", + deployment: 'test', progress: { completed: 0, total: 2, - formatted: "0/2" - } + formatted: '0/2', + }, }); historyActivityPrinter.activity({ event: { @@ -81,12 +78,12 @@ test('prints "Failed Resources:" list, when at least one deployment fails', () = EventId: '', StackName: 'stack-name', }, - deployment: "test", + deployment: 'test', progress: { completed: 0, total: 2, - formatted: "0/2" - } + formatted: '0/2', + }, }); historyActivityPrinter.stop(); }); @@ -124,12 +121,12 @@ test('print failed resources because of hook failures', () => { HookType: 'hook1', HookStatusReason: 'stack1 must obey certain rules', }, - deployment: "test", + deployment: 'test', progress: { completed: 0, total: 2, - formatted: "0/2" - } + formatted: '0/2', + }, }); historyActivityPrinter.activity({ event: { @@ -142,12 +139,12 @@ test('print failed resources because of hook failures', () => { StackName: 'stack-name', ResourceStatusReason: 'The following hook(s) failed: hook1', }, - deployment: "test", + deployment: 'test', progress: { completed: 0, total: 2, - formatted: "0/2" - } + formatted: '0/2', + }, }); historyActivityPrinter.stop(); }); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts b/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts index f40a3df12..204d8544d 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts @@ -17,8 +17,8 @@ export { loadTree, some } from '../../../../aws-cdk/lib/api/tree'; export * as contextproviders from '../../../../aws-cdk/lib/context-providers'; // @todo APIs not clean import -export { HotswapMode } from '../../../../aws-cdk/lib/api/hotswap/common'; -export { HotswapPropertyOverrides, EcsHotswapProperties } from '../../../../aws-cdk/lib/api/hotswap/common'; +export { HotswapMode } from '../../../../aws-cdk/lib/api/hotswap'; +export { HotswapPropertyOverrides, EcsHotswapProperties } from '../../../../aws-cdk/lib/api/hotswap'; export { RWLock, type ILock } from '../../../../aws-cdk/lib/api/util/rwlock'; // @todo Cloud Assembly and Executable - this is a messy API right now @@ -28,4 +28,3 @@ export { guessExecutable } from '../../../../aws-cdk/lib/api/cxapp/exec'; // @todo Should not use! investigate how to replace export { versionNumber } from '../../../../aws-cdk/lib/cli/version'; -export { CliIoHost } from '../../../../aws-cdk/lib/cli/io-host'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/shared-private.ts b/packages/@aws-cdk/toolkit-lib/lib/api/shared-private.ts index c82b3c567..fec6dc06c 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/shared-private.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/shared-private.ts @@ -1,3 +1,4 @@ /* eslint-disable import/no-restricted-paths */ export * from '../../../tmp-toolkit-helpers/src/api/io/private'; +export * from '../../../tmp-toolkit-helpers/src/private'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/index.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/index.ts index c28e88c5d..a69665dca 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/index.ts @@ -1,2 +1,3 @@ export * from './toolkit'; +export * from './non-interactive-io-host'; export * from '../api/shared-public'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/non-interactive-io-host.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/non-interactive-io-host.ts new file mode 100644 index 000000000..849e31876 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/non-interactive-io-host.ts @@ -0,0 +1,174 @@ +import * as chalk from 'chalk'; +import type { IActivityPrinter } from '../api/shared-private'; +import { HistoryActivityPrinter, isMessageRelevantForLevel } from '../api/shared-private'; +import type { IIoHost, IoMessage, IoMessageLevel, IoRequest } from '../api/shared-public'; +import { isCI, isTTY } from '../util/shell-env'; + +export interface NonInteractiveIoHostProps { + /** + * Determines the verbosity of the output. + * + * The IoHost will still receive all messages and requests, + * but only the messages included in this level will be printed. + * + * @default 'info' + */ + readonly logLevel?: IoMessageLevel; + + /** + * Overrides the automatic TTY detection. + * + * When TTY is disabled, the CLI will have no interactions or color. + * + * @default - determined from the current process + */ + readonly isTTY?: boolean; + + /** + * Whether the IoHost is running in CI mode. + * + * In CI mode, all non-error output goes to stdout instead of stderr. + * Set to false in the IoHost constructor it will be overwritten if the CLI CI argument is passed + * + * @default - determined from the environment, specifically based on `process.env.CI` + */ + readonly isCI?: boolean; +} + +/** + * A simple IO host for a non interactive CLI that writes messages to the console and returns the default answer to all requests. + */ +export class NonInteractiveIoHost implements IIoHost { + /** + * Whether the IoHost is running in CI mode. + * + * In CI mode, all non-error output goes to stdout instead of stderr. + */ + public readonly isCI: boolean; + + /** + * Whether the host can use interactions and message styling. + */ + public readonly isTTY: boolean; + + /** + * The current threshold. + * + * Messages with a lower priority level will be ignored. + */ + public readonly logLevel: IoMessageLevel; + + // Stack Activity Printer + private readonly activityPrinter: IActivityPrinter; + + public constructor(props: NonInteractiveIoHostProps = {}) { + this.logLevel = props.logLevel ?? 'info'; + this.isTTY = props.isTTY ?? isTTY(); + this.isCI = props.isCI ?? isCI(); + + this.activityPrinter = new HistoryActivityPrinter({ + stream: this.selectStreamFromLevel('info'), + }); + } + + /** + * Notifies the host of a message. + * The caller waits until the notification completes. + */ + public async notify(msg: IoMessage): Promise { + if (isStackActivity(msg)) { + return this.activityPrinter.notify(msg); + } + + if (!isMessageRelevantForLevel(msg, this.logLevel)) { + return; + } + + const output = this.formatMessage(msg); + const stream = this.selectStream(msg); + stream?.write(output); + } + + /** + * Determines the output stream, based on message and configuration. + */ + private selectStream(msg: IoMessage): NodeJS.WriteStream | undefined { + return this.selectStreamFromLevel(msg.level); + } + + /** + * Determines the output stream, based on message level and configuration. + */ + private selectStreamFromLevel(level: IoMessageLevel): NodeJS.WriteStream { + // The stream selection policy for the CLI is the following: + // + // (1) Messages of level `result` always go to `stdout` + // (2) Messages of level `error` always go to `stderr`. + // (3a) All remaining messages go to `stderr`. + // (3b) If we are in CI mode, all remaining messages go to `stdout`. + // + switch (level) { + case 'error': + return process.stderr; + case 'result': + return process.stdout; + default: + return this.isCI ? process.stdout : process.stderr; + } + } + + /** + * Notifies the host of a message that requires a response. + * + * If the host does not return a response the suggested + * default response from the input message will be used. + */ + public async requestResponse(msg: IoRequest): Promise { + // in the non-interactive IoHost, no requests are promptable + await this.notify(msg); + return msg.defaultResponse; + } + + /** + * 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.isTTY + ? 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())}`; + } +} + +const styleMap: Record string> = { + error: chalk.red, + warn: chalk.yellow, + result: chalk.white, + info: chalk.white, + debug: chalk.gray, + trace: chalk.gray, +}; + +/** + * Detect stack activity messages so they can be send to the printer. + */ +function isStackActivity(msg: IoMessage) { + return [ + 'CDK_TOOLKIT_I5501', + 'CDK_TOOLKIT_I5502', + 'CDK_TOOLKIT_I5503', + ].includes(msg.code); +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index b66c84710..fb904e566 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -4,6 +4,7 @@ import * as chalk from 'chalk'; import * as chokidar from 'chokidar'; import * as fs from 'fs-extra'; import * as uuid from 'uuid'; +import { NonInteractiveIoHost } from './non-interactive-io-host'; import type { ToolkitServices } from './private'; import { assemblyFromSource } from './private'; import type { BootstrapEnvironments, BootstrapOptions, BootstrapResult, EnvironmentBootstrapResult } from '../actions/bootstrap'; @@ -21,7 +22,7 @@ import type { WatchOptions } from '../actions/watch'; import { patternsArrayForWatch } from '../actions/watch/private'; import { type SdkConfig } from '../api/aws-auth'; import type { SuccessfulDeployStackResult, StackCollection, Concurrency, AssetBuildNode, AssetPublishNode, StackNode } from '../api/aws-cdk'; -import { DEFAULT_TOOLKIT_STACK_NAME, Bootstrapper, SdkProvider, Deployments, HotswapMode, ResourceMigrator, tagsForStack, CliIoHost, WorkGraphBuilder, CloudWatchLogEventMonitor, findCloudWatchLogGroups, createDiffChangeSet } from '../api/aws-cdk'; +import { DEFAULT_TOOLKIT_STACK_NAME, Bootstrapper, SdkProvider, Deployments, HotswapMode, ResourceMigrator, tagsForStack, WorkGraphBuilder, CloudWatchLogEventMonitor, findCloudWatchLogGroups, createDiffChangeSet } from '../api/aws-cdk'; import type { ICloudAssemblySource } from '../api/cloud-assembly'; import { StackSelectionStrategy } from '../api/cloud-assembly'; import type { StackAssembly } from '../api/cloud-assembly/private'; @@ -39,14 +40,14 @@ export interface ToolkitOptions { /** * The IoHost implementation, handling the inline interactions between the Toolkit and an integration. */ - ioHost?: IIoHost; + readonly ioHost?: IIoHost; /** * Allow emojis in messages sent to the IoHost. * * @default true */ - emojis?: boolean; + readonly emojis?: boolean; /** * Whether to allow ANSI colors and formatting in IoHost messages. @@ -56,26 +57,26 @@ export interface ToolkitOptions { * * @default - detects color from the TTY status of the IoHost */ - color?: boolean; + readonly color?: boolean; /** * Configuration options for the SDK. */ - sdkConfig?: SdkConfig; + readonly sdkConfig?: SdkConfig; /** * Name of the toolkit stack to be used. * * @default "CDKToolkit" */ - toolkitStackName?: string; + readonly toolkitStackName?: string; /** * Fail Cloud Assemblies * * @default "error" */ - assemblyFailureAt?: 'error' | 'warn' | 'none'; + readonly assemblyFailureAt?: 'error' | 'warn' | 'none'; } /** @@ -91,18 +92,17 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab * The IoHost of this Toolkit */ public readonly ioHost: IIoHost; + + /** + * Cache of the internal SDK Provider instance + */ private _sdkProvider?: SdkProvider; public constructor(private readonly props: ToolkitOptions = {}) { super(); this.toolkitStackName = props.toolkitStackName ?? DEFAULT_TOOLKIT_STACK_NAME; - // Hacky way to re-use the global IoHost until we have fully removed the need for it - const globalIoHost = CliIoHost.instance(); - if (props.ioHost) { - globalIoHost.registerIoHost(props.ioHost as any); - } - let ioHost = globalIoHost as IIoHost; + let ioHost = props.ioHost ?? new NonInteractiveIoHost(); if (props.emojis === false) { ioHost = withoutEmojis(ioHost); } diff --git a/packages/@aws-cdk/toolkit-lib/lib/util/shell-env.ts b/packages/@aws-cdk/toolkit-lib/lib/util/shell-env.ts new file mode 100644 index 000000000..9d71fd7d8 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/util/shell-env.ts @@ -0,0 +1,15 @@ +/** + * Returns true if the current process is running in a CI environment + * @returns true if the current process is running in a CI environment + */ +export function isCI(): boolean { + return process.env.CI !== undefined && process.env.CI !== 'false' && process.env.CI !== '0'; +} + +/** + * Returns true if the current process is running in a TTY environment + * @returns true if the current process is running in a TTY environment + */ +export function isTTY(): boolean { + return process.stdout.isTTY ?? false; +} diff --git a/packages/aws-cdk/lib/api/hotswap/index.ts b/packages/aws-cdk/lib/api/hotswap/index.ts new file mode 100644 index 000000000..d0b932366 --- /dev/null +++ b/packages/aws-cdk/lib/api/hotswap/index.ts @@ -0,0 +1 @@ +export * from './common'; diff --git a/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts b/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts index 6708f9463..9526a73db 100644 --- a/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts +++ b/packages/aws-cdk/lib/cli/io-host/cli-io-host.ts @@ -6,9 +6,9 @@ import { ToolkitError } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; import type { IIoHost, IoMessage, IoMessageCode, IoMessageLevel, IoRequest, ToolkitAction } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; import type { IoHelper } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private'; import { asIoHelper, IO, IoDefaultMessages, isMessageRelevantForLevel } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private'; +import { CurrentActivityPrinter, HistoryActivityPrinter } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer'; +import type { ActivityPrinterProps, IActivityPrinter } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/private/activity-printer'; import { StackActivityProgress } from '../../commands/deploy'; -import { CurrentActivityPrinter, HistoryActivityPrinter } from '../activity-printer'; -import type { ActivityPrinterProps, IActivityPrinter } from '../activity-printer'; export type { IIoHost, IoMessage, IoMessageCode, IoMessageLevel, IoRequest };