From 0d62d34b733d834f1a740fe4e0291ea4c6031e3b Mon Sep 17 00:00:00 2001 From: Momo Kornher Date: Mon, 13 Jan 2025 18:06:53 +0000 Subject: [PATCH] feat(toolkit): a programmatic toolkit for the AWS CDK --- .../@aws-cdk/toolkit/lib/actions/deploy.ts | 29 + .../@aws-cdk/toolkit/lib/actions/destroy.ts | 7 + packages/@aws-cdk/toolkit/lib/actions/diff.ts | 89 +++ packages/@aws-cdk/toolkit/lib/actions/list.ts | 8 + .../@aws-cdk/toolkit/lib/actions/synth.ts | 18 + .../@aws-cdk/toolkit/lib/api/aws-auth/sdk.ts | 43 ++ .../lib/api/cloud-assembly/cached-source.ts | 25 + .../cloud-assembly/context-aware-source.ts | 121 ++++ .../lib/api/cloud-assembly/from-app.ts | 63 ++ .../lib/api/cloud-assembly/identity-source.ts | 13 + .../lib/api/cloud-assembly/stack-assembly.ts | 12 + .../toolkit/lib/api/cloud-assembly/types.ts | 8 + packages/@aws-cdk/toolkit/lib/api/errors.ts | 48 ++ .../toolkit/lib/cloud-assembly-source.ts | 32 +- packages/@aws-cdk/toolkit/lib/index.ts | 2 +- packages/@aws-cdk/toolkit/lib/io-host.ts | 30 - packages/@aws-cdk/toolkit/lib/io/io-host.ts | 81 +++ packages/@aws-cdk/toolkit/lib/io/logger.ts | 111 ++++ packages/@aws-cdk/toolkit/lib/io/messages.ts | 134 +++++ packages/@aws-cdk/toolkit/lib/io/timer.ts | 32 ++ packages/@aws-cdk/toolkit/lib/toolkit.ts | 543 +++++++++++++++++- packages/@aws-cdk/toolkit/lib/types.ts | 32 +- packages/@aws-cdk/toolkit/package.json | 12 +- packages/@aws-cdk/toolkit/tsconfig.json | 6 +- .../lib/api/bootstrap/bootstrap-props.ts | 2 +- packages/aws-cdk/lib/api/deployments.ts | 12 +- .../lib/api/util/string-manipulation.ts | 24 + packages/aws-cdk/lib/cdk-toolkit.ts | 117 +--- packages/aws-cdk/lib/import.ts | 2 +- packages/aws-cdk/lib/migrator.ts | 85 +++ packages/aws-cdk/lib/settings.ts | 2 +- packages/aws-cdk/lib/tags.ts | 13 + packages/aws-cdk/test/cdk-toolkit.test.ts | 3 +- packages/aws-cdk/test/settings.test.ts | 2 +- yarn.lock | 15 + 35 files changed, 1574 insertions(+), 202 deletions(-) create mode 100644 packages/@aws-cdk/toolkit/lib/actions/diff.ts create mode 100644 packages/@aws-cdk/toolkit/lib/actions/list.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/aws-auth/sdk.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/cached-source.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/context-aware-source.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/from-app.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/identity-source.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-assembly.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/cloud-assembly/types.ts create mode 100644 packages/@aws-cdk/toolkit/lib/api/errors.ts delete mode 100644 packages/@aws-cdk/toolkit/lib/io-host.ts create mode 100644 packages/@aws-cdk/toolkit/lib/io/io-host.ts create mode 100644 packages/@aws-cdk/toolkit/lib/io/logger.ts create mode 100644 packages/@aws-cdk/toolkit/lib/io/messages.ts create mode 100644 packages/@aws-cdk/toolkit/lib/io/timer.ts create mode 100644 packages/aws-cdk/lib/migrator.ts create mode 100644 packages/aws-cdk/lib/tags.ts diff --git a/packages/@aws-cdk/toolkit/lib/actions/deploy.ts b/packages/@aws-cdk/toolkit/lib/actions/deploy.ts index 6018ae37a6fe4..6355d4b8b8b1e 100644 --- a/packages/@aws-cdk/toolkit/lib/actions/deploy.ts +++ b/packages/@aws-cdk/toolkit/lib/actions/deploy.ts @@ -225,4 +225,33 @@ export interface DeployOptions extends BaseDeployOptions { * @default AssetBuildTime.ALL_BEFORE_DEPLOY */ readonly assetBuildTime?: AssetBuildTime; + + /** + * Change stack watcher output to CI mode. + * + * @deprecated Implement in IoHost instead + */ + readonly ci?: boolean; +} + +export function buildParameterMap(parameters?: Map): { [name: string]: { [name: string]: string | undefined } } { + const parameterMap: { + [name: string]: { [name: string]: string | undefined }; + } = {}; + parameterMap['*'] = {}; + + const entries = parameters?.entries() ?? []; + for (const [key, value] of entries) { + const [stack, parameter] = key.split(':', 2) as [string, string | undefined]; + if (!parameter) { + parameterMap['*'][stack] = value; + } else { + if (!parameterMap[stack]) { + parameterMap[stack] = {}; + } + parameterMap[stack][parameter] = value; + } + } + + return parameterMap; } diff --git a/packages/@aws-cdk/toolkit/lib/actions/destroy.ts b/packages/@aws-cdk/toolkit/lib/actions/destroy.ts index c2f566cdc8236..6a2194af4ae8b 100644 --- a/packages/@aws-cdk/toolkit/lib/actions/destroy.ts +++ b/packages/@aws-cdk/toolkit/lib/actions/destroy.ts @@ -10,4 +10,11 @@ export interface DestroyOptions { * The arn of the IAM role to use */ readonly roleArn?: string; + + /** + * Change stack watcher output to CI mode. + * + * @deprecated Implement in IoHost instead + */ + readonly ci?: boolean; } diff --git a/packages/@aws-cdk/toolkit/lib/actions/diff.ts b/packages/@aws-cdk/toolkit/lib/actions/diff.ts new file mode 100644 index 0000000000000..d041d45b4c1c7 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/actions/diff.ts @@ -0,0 +1,89 @@ +import { StackSelector } from '../types'; + +export interface CloudFormationDiffOptions { + /** + * Whether to run the diff against the template after the CloudFormation Transforms inside it have been executed + * (as opposed to the original template, the default, which contains the unprocessed Transforms). + * + * @default false + */ + readonly compareAgainstProcessedTemplate?: boolean; +} + +export interface ChangeSetDiffOptions extends CloudFormationDiffOptions { + /** + * Enable falling back to template-based diff in case creating the changeset is not possible or results in an error. + * + * Should be used for stacks containing nested stacks or when change set permissions aren't available. + * + * @default true + */ + readonly fallbackToTemplate?: boolean; + + /** + * Additional parameters for CloudFormation when creating a diff change set + * + * @default {} + */ + readonly parameters?: { [name: string]: string | undefined }; +} + +export class DiffMode { + /** + * Use a changeset to compute the diff. + * + * This will create, analyze, and subsequently delete a changeset against the CloudFormation stack. + */ + public static ChangeSet(options: ChangeSetDiffOptions = {}) {} + public static TemplateOnly(options: CloudFormationDiffOptions = {}) {} + public static LocalTemplate(path: string) {} + + private constructor(public readonly mode: string) { + + } +} + +export interface DiffOptions { + /** + * Select the stacks + */ + readonly stacks: StackSelector; + + /** + * The mode to create a stack diff. + * + * Use changeset diff for the highest fidelity, including analyze resource replacements. + * In this mode, diff will use the deploy role instead of the lookup role. + * + * Use template-only diff for a faster, less accurate diff that doesn't require + * permissions to create a change-set. + * + * Use local-template diff for a fast, local-only diff that doesn't require + * any permissions or internet access. + * + * @default DiffMode.ChangeSet + */ + readonly mode: DiffMode; + + /** + * Strict diff mode + * When enabled, this will not filter out AWS::CDK::Metadata resources, mangled non-ASCII characters, or the CheckBootstrapVersionRule. + * + * @default false + */ + readonly strict?: boolean; + + /** + * How many lines of context to show in the diff + * + * @default 3 + */ + readonly contextLines?: number; + + /** + * Only include broadened security changes in the diff + * + * @default false + */ + readonly securityOnly?: boolean; +} diff --git a/packages/@aws-cdk/toolkit/lib/actions/list.ts b/packages/@aws-cdk/toolkit/lib/actions/list.ts new file mode 100644 index 0000000000000..a29a2d064ce7a --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/actions/list.ts @@ -0,0 +1,8 @@ +import { StackSelector } from '../types'; + +export interface ListOptions { + /** + * Select the stacks + */ + readonly stacks: StackSelector; +} diff --git a/packages/@aws-cdk/toolkit/lib/actions/synth.ts b/packages/@aws-cdk/toolkit/lib/actions/synth.ts index 0fca66440b1f3..e2f68d45a1ecc 100644 --- a/packages/@aws-cdk/toolkit/lib/actions/synth.ts +++ b/packages/@aws-cdk/toolkit/lib/actions/synth.ts @@ -12,3 +12,21 @@ export interface SynthOptions { */ readonly validateStacks?: boolean; } + +/** + * Remove any template elements that we don't want to show users. + */ +export function obscureTemplate(template: any = {}) { + if (template.Rules) { + // see https://github.com/aws/aws-cdk/issues/17942 + if (template.Rules.CheckBootstrapVersion) { + if (Object.keys(template.Rules).length > 1) { + delete template.Rules.CheckBootstrapVersion; + } else { + delete template.Rules; + } + } + } + + return template; +} diff --git a/packages/@aws-cdk/toolkit/lib/api/aws-auth/sdk.ts b/packages/@aws-cdk/toolkit/lib/api/aws-auth/sdk.ts new file mode 100644 index 0000000000000..a8f2df4e70386 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/aws-auth/sdk.ts @@ -0,0 +1,43 @@ + +/** + * Options for the default SDK provider + */ +export interface SdkOptions { + /** + * Profile to read from ~/.aws + * + * @default - No profile + */ + readonly profile?: string; + + /** + * Proxy address to use + * + * @default No proxy + */ + readonly region?: string; + + /** + * HTTP options for SDK + */ + readonly httpOptions?: SdkHttpOptions; +} + +/** + * Options for individual SDKs + */ +export interface SdkHttpOptions { + /** + * Proxy address to use + * + * @default No proxy + */ + readonly proxyAddress?: string; + + /** + * A path to a certificate bundle that contains a cert to be trusted. + * + * @default No certificate bundle + */ + readonly caBundlePath?: string; +} diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/cached-source.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/cached-source.ts new file mode 100644 index 0000000000000..5e2a27788cff7 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/cached-source.ts @@ -0,0 +1,25 @@ +import { CloudAssembly } from '@aws-cdk/cx-api'; +import { ICloudAssemblySource } from './types'; + +/** + * A CloudAssemblySource that is caching its result once produced. + * + * Most Toolkit interactions should use a cached source. + * Not caching is relevant when the source changes frequently + * and it is to expensive to predict if the source has changed. + */ +export class CachedCloudAssemblySource implements ICloudAssemblySource { + private source: ICloudAssemblySource; + private cloudAssembly: CloudAssembly | undefined; + + public constructor(source: ICloudAssemblySource) { + this.source = source; + } + + public async produce(): Promise { + if (!this.cloudAssembly) { + this.cloudAssembly = await this.source.produce(); + } + return this.cloudAssembly; + } +} diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/context-aware-source.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/context-aware-source.ts new file mode 100644 index 0000000000000..0519a020d6673 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/context-aware-source.ts @@ -0,0 +1,121 @@ +import type { MissingContext } from '@aws-cdk/cloud-assembly-schema'; +import * as cxapi from '@aws-cdk/cx-api'; +import { SdkProvider } from 'aws-cdk/lib/api/aws-auth'; +import * as contextproviders from 'aws-cdk/lib/context-providers'; +import { debug } from 'aws-cdk/lib/logging'; +import { Context, PROJECT_CONTEXT } from 'aws-cdk/lib/settings'; +import { ICloudAssemblySource } from './types'; +import { ToolkitError } from '../errors'; + +export interface CloudExecutableProps { + /** + * AWS object (used by contextprovider) + */ + readonly sdkProvider: SdkProvider; + + /** + * Application context + */ + readonly context: Context; + + /** + * The file used to store application context in (relative to cwd). + * + * @default "cdk.context.json" + */ + readonly contextFile?: string; + + /** + * Enable context lookups. + * + * Producing a `cxapi.CloudAssembly` will fail if this is disabled and context lookups need to be performed. + * + * @default true + */ + readonly lookups?: boolean; +} + +/** + * Represent the Cloud Executable and the synthesis we can do on it + */ +export class ContextAwareCloudAssembly implements ICloudAssemblySource { + private canLookup: boolean; + private context: Context; + private contextFile: string; + + constructor(private readonly source: ICloudAssemblySource, private readonly props: CloudExecutableProps) { + this.canLookup = props.lookups ?? true; + this.context = props.context; + this.contextFile = props.contextFile ?? PROJECT_CONTEXT; // @todo new feature not needed right now + } + + /** + * Produce a Cloud Assembly, i.e. a set of stacks + */ + public async produce(): Promise { + // We may need to run the cloud executable multiple times in order to satisfy all missing context + // (When the executable runs, it will tell us about context it wants to use + // but it missing. We'll then look up the context and run the executable again, and + // again, until it doesn't complain anymore or we've stopped making progress). + let previouslyMissingKeys: Set | undefined; + while (true) { + const assembly = await this.source.produce(); + + if (assembly.manifest.missing && assembly.manifest.missing.length > 0) { + const missingKeys = missingContextKeys(assembly.manifest.missing); + + if (!this.canLookup) { + throw new ToolkitError( + 'Context lookups have been disabled. ' + + 'Make sure all necessary context is already in \'cdk.context.json\' by running \'cdk synth\' on a machine with sufficient AWS credentials and committing the result. ' + + `Missing context keys: '${Array.from(missingKeys).join(', ')}'`); + } + + let tryLookup = true; + if (previouslyMissingKeys && equalSets(missingKeys, previouslyMissingKeys)) { + debug('Not making progress trying to resolve environmental context. Giving up.'); + tryLookup = false; + } + + previouslyMissingKeys = missingKeys; + + if (tryLookup) { + debug('Some context information is missing. Fetching...'); + + await contextproviders.provideContextValues( + assembly.manifest.missing, + this.context, + this.props.sdkProvider, + ); + + // Cache the new context to disk + await this.context.save(this.contextFile); + + // Execute again + continue; + } + } + + return assembly; + } + } + +} + +/** + * Return all keys of missing context items + */ +function missingContextKeys(missing?: MissingContext[]): Set { + return new Set((missing || []).map(m => m.key)); +} + +/** + * Are two sets equal to each other + */ +function equalSets(a: Set, b: Set) { + if (a.size !== b.size) { return false; } + for (const x of a) { + if (!b.has(x)) { return false; } + } + return true; +} diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/from-app.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/from-app.ts new file mode 100644 index 0000000000000..0125b911c42f3 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/from-app.ts @@ -0,0 +1,63 @@ +import { ToolkitError } from '../errors'; +import { ContextAwareCloudAssembly } from './context-aware-source'; +import { ICloudAssemblySource } from './types'; + +/** + * Configuration for creating a CLI from an AWS CDK App directory + */ +export interface FromCdkAppProps { +/** + * @default - current working directory + */ + readonly workingDirectory?: string; + + /** + * Emits the synthesized cloud assembly into a directory + * + * @default cdk.out + */ + readonly output?: string; +} + +/** + * Use a directory containing an AWS CDK app as source. + * @param directory the directory of the AWS CDK app. Defaults to the current working directory. + * @param props additional configuration properties + * @returns an instance of `AwsCdkCli` + */ +export function fromCdkApp(app: string, props: FromCdkAppProps = {}): ICloudAssemblySource { + return new ContextAwareCloudAssembly( + { + produce: async () => { + await this.lock?.release(); + + try { + const build = this.props.configuration.settings.get(['build']); + if (build) { + await exec(build, { cwd: props.workingDirectory }); + } + + const commandLine = await guessExecutable(app); + const outdir = props.output ?? 'cdk.out'; + + try { + fs.mkdirpSync(outdir); + } catch (e: any) { + throw new ToolkitError(`Could not create output directory at '${outdir}' (${e.message}).`); + } + + this.lock = await new RWLock(outdir).acquireWrite(); + + const env = await prepareDefaultEnvironment(this.props.sdkProvider, { outdir }); + return await withContext(env, this.props.configuration, async (envWithContext, _context) => { + await exec(commandLine.join(' '), { extraEnv: envWithContext, cwd: props.workingDirectory }); + return createAssembly(outdir); + }); + } finally { + await this.lock?.release(); + } + }, + }, + this.propsForContextAssembly, + ); +} diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/identity-source.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/identity-source.ts new file mode 100644 index 0000000000000..03af4537d870e --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/identity-source.ts @@ -0,0 +1,13 @@ +import type * as cxapi from '@aws-cdk/cx-api'; +import { ICloudAssemblySource } from './types'; + +/** + * A CloudAssemblySource that is representing a already existing and produced CloudAssembly. + */ +export class IdentityCloudAssemblySource implements ICloudAssemblySource { + public constructor(private readonly cloudAssembly: cxapi.CloudAssembly) {} + + public async produce(): Promise { + return this.cloudAssembly; + } +} diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-assembly.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-assembly.ts new file mode 100644 index 0000000000000..6a423e37d3a51 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/stack-assembly.ts @@ -0,0 +1,12 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import { CloudAssembly } from 'aws-cdk/lib/api/cxapp/cloud-assembly'; +import { ICloudAssemblySource } from './types'; + +/** + * A single Cloud Assembly wrapped to provide additional stack operations. + */ +export class StackAssembly extends CloudAssembly implements ICloudAssemblySource { + public async produce(): Promise { + return this.assembly; + } +} diff --git a/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/types.ts b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/types.ts new file mode 100644 index 0000000000000..99e2fefe9560b --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/cloud-assembly/types.ts @@ -0,0 +1,8 @@ +import type * as cxapi from '@aws-cdk/cx-api'; + +export interface ICloudAssemblySource { + /** + * Produce a CloudAssembly from the current source + */ + produce(): Promise; +} diff --git a/packages/@aws-cdk/toolkit/lib/api/errors.ts b/packages/@aws-cdk/toolkit/lib/api/errors.ts new file mode 100644 index 0000000000000..3fc628f0555d4 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/api/errors.ts @@ -0,0 +1,48 @@ +const TOOLKIT_ERROR_SYMBOL = Symbol.for('@aws-cdk/core.ToolkitError'); +const AUTHENTICATION_ERROR_SYMBOL = Symbol.for('@aws-cdk/core.AuthenticationError'); + +/** + * Represents a general toolkit error in the AWS CDK Toolkit. + */ +class ToolkitError extends Error { + /** + * Determines if a given error is an instance of ToolkitError. + */ + public static isToolkitError(x: any): x is ToolkitError { + return x !== null && typeof(x) === 'object' && TOOLKIT_ERROR_SYMBOL in x; + } + + /** + * Determines if a given error is an instance of AuthenticationError. + */ + public static isAuthenticationError(x: any): x is AuthenticationError { + return this.isToolkitError(x) && AUTHENTICATION_ERROR_SYMBOL in x; + } + + /** + * The type of the error, defaults to "toolkit". + */ + public readonly type: string; + + constructor(message: string, type: string = 'toolkit') { + super(message); + Object.setPrototypeOf(this, ToolkitError.prototype); + Object.defineProperty(this, TOOLKIT_ERROR_SYMBOL, { value: true }); + this.name = new.target.name; + this.type = type; + } +} + +/** + * Represents an authentication-specific error in the AWS CDK Toolkit. + */ +class AuthenticationError extends ToolkitError { + constructor(message: string) { + super(message, 'authentication'); + Object.setPrototypeOf(this, AuthenticationError.prototype); + Object.defineProperty(this, AUTHENTICATION_ERROR_SYMBOL, { value: true }); + } +} + +// Export classes for internal usage only +export { ToolkitError, AuthenticationError }; diff --git a/packages/@aws-cdk/toolkit/lib/cloud-assembly-source.ts b/packages/@aws-cdk/toolkit/lib/cloud-assembly-source.ts index 5973d0cc144e1..840f0dd832b0c 100644 --- a/packages/@aws-cdk/toolkit/lib/cloud-assembly-source.ts +++ b/packages/@aws-cdk/toolkit/lib/cloud-assembly-source.ts @@ -1,12 +1,5 @@ import { CloudAssembly } from '@aws-cdk/cx-api'; - -export interface ICloudAssemblySource { - /** - * produce - */ - produce(): Promise; -} - +import { ICloudAssemblySource } from './cloud-assembly-source/types'; /** * Configuration for creating a CLI from an AWS CDK App directory */ @@ -47,26 +40,3 @@ export class CloudAssemblySource implements ICloudAssemblySource { throw new Error('Method not implemented.'); } } - -/** - * A CloudAssemblySource that is caching its result once produced. - * - * Most Toolkit interactions should use a cached source. - * Not caching is relevant when the source changes frequently - * and it is to expensive to predict if the source has changed. - */ -export class CachedCloudAssemblySource implements ICloudAssemblySource { - private source: ICloudAssemblySource; - private cloudAssembly: CloudAssembly | undefined; - - public constructor(source: ICloudAssemblySource) { - this.source = source; - } - - public async produce(): Promise { - if (!this.cloudAssembly) { - this.cloudAssembly = await this.source.produce(); - } - return this.cloudAssembly; - } -} diff --git a/packages/@aws-cdk/toolkit/lib/index.ts b/packages/@aws-cdk/toolkit/lib/index.ts index 567c3c4ae0e7e..13c5a11ec6828 100644 --- a/packages/@aws-cdk/toolkit/lib/index.ts +++ b/packages/@aws-cdk/toolkit/lib/index.ts @@ -6,5 +6,5 @@ export * from './actions/import'; export * from './actions/synth'; export * from './actions/watch'; -export * from './io-host'; +export * from './io/io-host'; export * from './types'; diff --git a/packages/@aws-cdk/toolkit/lib/io-host.ts b/packages/@aws-cdk/toolkit/lib/io-host.ts deleted file mode 100644 index 7038bf89138a2..0000000000000 --- a/packages/@aws-cdk/toolkit/lib/io-host.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { MessageLevel, ToolkitAction } from './types'; - -export interface IoMessage { - time: string; - level: MessageLevel; - action: ToolkitAction; - code: string; - message: string; - data?: T; -} - -export interface IoRequest extends IoMessage { - defaultResponse: U; -} - -export interface IIoHost { - /** - * Notifies the host of a message. - * The caller waits until the notification completes. - */ - notify(msg: IoMessage): Promise; - - /** - * 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. - */ - requestResponse(msg: IoRequest): Promise; -} diff --git a/packages/@aws-cdk/toolkit/lib/io/io-host.ts b/packages/@aws-cdk/toolkit/lib/io/io-host.ts new file mode 100644 index 0000000000000..c320628771772 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/io/io-host.ts @@ -0,0 +1,81 @@ +import { ToolkitAction } from '../types'; + +/** + * The reporting level of the message. + * All messages are always reported, it's up to the IoHost to decide what to log. + */ +export type IoMessageLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'; + +export type IoMessageCodeCategory = 'TOOLKIT' | 'SDK' | 'ASSETS'; +export type IoCodeLevel = 'E' | 'W' | 'I'; +export type IoMessageSpecificCode = `CDK_${IoMessageCodeCategory}_${L}${number}${number}${number}${number}`; +export type IoMessageCode = IoMessageSpecificCode; + +export 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: ToolkitAction; + + /** + * A short message code uniquely identifying a message type using the format CDK_[CATEGORY]_[E/W/I][0000-9999]. + * + * The level indicator follows these rules: + * - 'E' for error level messages + * - 'W' for warning level messages + * - 'I' for info/debug/trace level messages + * + * Codes ending in 000 0 are generic messages, while codes ending in 0001-9999 are specific to a particular message. + * The following are examples of valid and invalid message codes: + * ```ts + * 'CDK_ASSETS_I0000' // valid: generic assets info message + * 'CDK_TOOLKIT_E0002' // valid: specific toolkit error message + * 'CDK_SDK_W0023' // valid: specific sdk warning message + * ``` + */ + readonly code: IoMessageCode; + + /** + * The message text. + * This is safe to print to an end-user. + */ + readonly message: string; + + /** + * The data attached to the message. + */ + readonly data?: T; +} + +export interface IoRequest extends IoMessage { + /** + * The default response that will be used if no data is returned. + */ + readonly defaultResponse: U; +} + +export interface IIoHost { + /** + * Notifies the host of a message. + * The caller waits until the notification completes. + */ + notify(msg: IoMessage): Promise; + + /** + * 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. + */ + requestResponse(msg: IoRequest): Promise; +} diff --git a/packages/@aws-cdk/toolkit/lib/io/logger.ts b/packages/@aws-cdk/toolkit/lib/io/logger.ts new file mode 100644 index 0000000000000..e829f0ecf3984 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/io/logger.ts @@ -0,0 +1,111 @@ +import type { Logger } from '@smithy/types'; +import { formatSdkLoggerContent } from 'aws-cdk/lib/api/aws-auth/sdk-logger'; +import { ToolkitAction } from '../types'; +import { IIoHost, IoMessage, IoRequest } from './io-host'; +import { trace } from './messages'; + +export function withAction(ioHost: IIoHost, action: ToolkitAction) { + return { + notify: async (msg: Omit, 'action'>) => { + await ioHost.notify({ + ...msg, + action, + }); + }, + requestResponse: async (msg: Omit, 'action'>) => { + return ioHost.requestResponse({ + ...msg, + action, + }); + }, + }; +} + +export function asSdkLogger(ioHost: IIoHost, action: ToolkitAction): Logger { + return new class implements Logger { + // This is too much detail for our logs + public trace(..._content: any[]) {} + public debug(..._content: any[]) {} + + /** + * Info is called mostly (exclusively?) for successful API calls + * + * Payload: + * + * (Note the input contains entire CFN templates, for example) + * + * ``` + * { + * clientName: 'S3Client', + * commandName: 'GetBucketLocationCommand', + * input: { + * Bucket: '.....', + * ExpectedBucketOwner: undefined + * }, + * output: { LocationConstraint: 'eu-central-1' }, + * metadata: { + * httpStatusCode: 200, + * requestId: '....', + * extendedRequestId: '...', + * cfId: undefined, + * attempts: 1, + * totalRetryDelay: 0 + * } + * } + * ``` + */ + public info(...content: any[]) { + void ioHost.notify({ + action, + ...trace(`[sdk info] ${formatSdkLoggerContent(content)}`), + data: { + sdkLevel: 'info', + content, + }, + }); + } + + public warn(...content: any[]) { + void ioHost.notify({ + action, + ...trace(`[sdk warn] ${formatSdkLoggerContent(content)}`), + data: { + sdkLevel: 'warn', + content, + }, + }); + } + + /** + * Error is called mostly (exclusively?) for failing API calls + * + * Payload (input would be the entire API call arguments). + * + * ``` + * { + * clientName: 'STSClient', + * commandName: 'GetCallerIdentityCommand', + * input: {}, + * error: AggregateError [ECONNREFUSED]: + * at internalConnectMultiple (node:net:1121:18) + * at afterConnectMultiple (node:net:1688:7) { + * code: 'ECONNREFUSED', + * '$metadata': { attempts: 3, totalRetryDelay: 600 }, + * [errors]: [ [Error], [Error] ] + * }, + * metadata: { attempts: 3, totalRetryDelay: 600 } + * } + * ``` + */ + public error(...content: any[]) { + void ioHost.notify({ + action, + ...trace(`[sdk error] ${formatSdkLoggerContent(content)}`), + data: { + sdkLevel: 'error', + content, + }, + }); + } + }; +} diff --git a/packages/@aws-cdk/toolkit/lib/io/messages.ts b/packages/@aws-cdk/toolkit/lib/io/messages.ts new file mode 100644 index 0000000000000..6ec6c41e212e2 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/io/messages.ts @@ -0,0 +1,134 @@ +import * as chalk from 'chalk'; +import { IoMessage, IoMessageCode, IoMessageCodeCategory, IoMessageLevel } from './io-host'; + +type Optional = Pick, K> & Omit; + +/** + * Internal helper that processes log inputs into a consistent format. + * Handles string interpolation, format strings, and object parameter styles. + * Applies optional styling and prepares the final message for logging. + */ +function formatMessage( + msg: Pick, 'code'>, 'level' | 'code' | 'message' | 'data'>, + style?: (str: string) => string, +): Omit, 'action'> { + // Apply style if provided + const formattedMessage = style ? style(msg.message) : msg.message; + + return { + time: new Date(), + level: msg.level, + code: msg.code ?? messageCode(msg.level), + message: formattedMessage, + data: msg.data, + }; +} + +/** + * Build a message code from level and category + */ +function messageCode(level: IoMessageLevel, category: IoMessageCodeCategory = 'TOOLKIT', number?: `${number}${number}${number}${number}`): IoMessageCode { + const levelIndicator = level === 'error' ? 'E' : + level === 'warn' ? 'W' : + 'I'; + return `CDK_${category}_${levelIndicator}${number ?? '0000'}`; +} + +/** + * Logs an error level message. + */ +export const error = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'error', + code, + message, + data: payload, + }); +}; + +/** + * Logs an warning level message. + */ +export const warning = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'warn', + code, + message, + data: payload, + }); +}; + +/** + * Logs an info level message. + */ +export const info = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'info', + code, + message, + data: payload, + }); +}; + +/** + * Logs an info level message to stdout. + * @deprecated + */ +export const data = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'info', + code, + message, + data: payload, + }); +}; + +/** + * Logs a debug level message. + */ +export const debug = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'debug', + code, + message, + data: payload, + }); +}; + +/** + * Logs a trace level message. + */ +export const trace = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'trace', + code, + message, + data: payload, + }); +}; + +/** + * Logs an info level success message in green text. + * @deprecated + */ +export const success = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'info', + code, + message, + data: payload, + }, chalk.green); +}; + +/** + * Logs an info level message in bold text. + * @deprecated + */ +export const highlight = (message: string, code?: IoMessageCode, payload?: T) => { + return formatMessage({ + level: 'info', + code, + message, + data: payload, + }, chalk.bold); +}; diff --git a/packages/@aws-cdk/toolkit/lib/io/timer.ts b/packages/@aws-cdk/toolkit/lib/io/timer.ts new file mode 100644 index 0000000000000..b3f167589d901 --- /dev/null +++ b/packages/@aws-cdk/toolkit/lib/io/timer.ts @@ -0,0 +1,32 @@ +import { formatTime } from 'aws-cdk/lib/api/util/string-manipulation'; + +/** + * Helper class to measure the time of code. + */ +export class Timer { + /** + * Start the timer. + * @return the timer instance + */ + public static start(): Timer { + return new Timer(); + } + + private readonly startTime: number; + + private constructor() { + this.startTime = new Date().getTime(); + } + + /** + * End the current timer. + * @returns the elapsed time + */ + public end() { + const elapsedTime = new Date().getTime() - this.startTime; + return { + asMs: elapsedTime, + asSec: formatTime(elapsedTime), + }; + } +} diff --git a/packages/@aws-cdk/toolkit/lib/toolkit.ts b/packages/@aws-cdk/toolkit/lib/toolkit.ts index e442cc28a7bf9..f06ff985343e9 100644 --- a/packages/@aws-cdk/toolkit/lib/toolkit.ts +++ b/packages/@aws-cdk/toolkit/lib/toolkit.ts @@ -1,31 +1,556 @@ -import { DeployOptions } from './actions/deploy'; +import * as path from 'node:path'; +import * as cxapi from '@aws-cdk/cx-api'; +import { DEFAULT_TOOLKIT_STACK_NAME, SdkProvider, SuccessfulDeployStackResult } from 'aws-cdk/lib/api'; +import { StackCollection } from 'aws-cdk/lib/api/cxapp/cloud-assembly'; +import { Deployments } from 'aws-cdk/lib/api/deployments'; +import { HotswapMode } from 'aws-cdk/lib/api/hotswap/common'; +import { StackActivityProgress } from 'aws-cdk/lib/api/util/cloudformation/stack-activity-monitor'; +import { formatTime } from 'aws-cdk/lib/api/util/string-manipulation'; +import { ResourceMigrator } from 'aws-cdk/lib/migrator'; +import { serializeStructure } from 'aws-cdk/lib/serialize'; +import { tagsForStack } from 'aws-cdk/lib/tags'; +import { validateSnsTopicArn } from 'aws-cdk/lib/util/validate-notification-arn'; +import { Concurrency } from 'aws-cdk/lib/util/work-graph'; +import { WorkGraphBuilder } from 'aws-cdk/lib/util/work-graph-builder'; +import { AssetBuildNode, AssetPublishNode, StackNode } from 'aws-cdk/lib/util/work-graph-types'; +import * as chalk from 'chalk'; +import * as fs from 'fs-extra'; +import { AssetBuildTime, buildParameterMap, DeployOptions, RequireApproval } from './actions/deploy'; import { DestroyOptions } from './actions/destroy'; -import { SynthOptions } from './actions/synth'; +import { DiffOptions } from './actions/diff'; +import { ListOptions } from './actions/list'; +import { obscureTemplate, SynthOptions } from './actions/synth'; import { WatchOptions } from './actions/watch'; -import { ICloudAssemblySource } from './cloud-assembly-source'; -import { IIoHost } from './io-host'; +import { SdkOptions } from './api/aws-auth/sdk'; +import { CachedCloudAssemblySource } from './api/cloud-assembly/cached-source'; +import { IdentityCloudAssemblySource } from './api/cloud-assembly/identity-source'; +import { StackAssembly } from './api/cloud-assembly/stack-assembly'; +import { ICloudAssemblySource } from './api/cloud-assembly/types'; +import { ToolkitError } from './api/errors'; +import { IIoHost } from './io/io-host'; +import { asSdkLogger, withAction } from './io/logger'; +import { error, highlight, info, success, warning } from './io/messages'; +import { Timer } from './io/timer'; +import { StackSelectionStrategy, ToolkitAction } from './types'; export interface ToolkitOptions { + /** + * The IoHost implementation, handling the inline interactions between the Toolkit and an integration. + */ ioHost: IIoHost; + + /** + * Configuration options for the SDK. + */ + sdkOptions?: SdkOptions; + + /** + * Name of the toolkit stack to be used. + * + * @default "CDKToolkit" + */ + toolkitStackName?: string; } +/** + * The AWS CDK Programmatic Toolkit + */ export class Toolkit { - public constructor(_options: ToolkitOptions) {} + private readonly ioHost: IIoHost; + private _sdkProvider?: SdkProvider; + private toolkitStackName: string; + + public constructor(private readonly options: ToolkitOptions) { + this.ioHost = options.ioHost; + this.toolkitStackName = options.toolkitStackName ?? DEFAULT_TOOLKIT_STACK_NAME; + } + + private async sdkProvider(action: ToolkitAction): Promise { + // @todo this needs to be different per action + if (!this._sdkProvider) { + this._sdkProvider = await SdkProvider.withAwsCliCompatibleDefaults({ + ...this.options.sdkOptions, + logger: asSdkLogger(this.ioHost, action), + }); + } - public async synth(_cx: ICloudAssemblySource, _options: SynthOptions): Promise { + return this._sdkProvider; + } + + /** + * Synth Action + */ + public async synth(cx: ICloudAssemblySource, options: SynthOptions): Promise { + const ioHost = withAction(this.ioHost, 'synth'); + const assembly = await this.assemblyFromSource(cx); + const stacks = await assembly.selectStacks(options.stacks, false); + const autoValidateStacks = options.validateStacks ? await this.selectStacksForValidation(assembly) : new StackCollection(assembly, []); + this.processStackMessages(stacks.concat(autoValidateStacks)); + + // if we have a single stack, print it to STDOUT + if (stacks.stackCount === 1) { + const template = stacks.firstStack?.template; + const obscuredTemplate = obscureTemplate(template); + await ioHost.notify(info('', 'CDK_TOOLKIT_I0001', { + raw: template, + json: serializeStructure(obscuredTemplate, true), + yaml: serializeStructure(obscuredTemplate, false), + }, + )); + } else { + // not outputting template to stdout, let's explain things to the user a little bit... + await ioHost.notify(success(`Successfully synthesized to ${chalk.blue(path.resolve(stacks.assembly.directory))}`)); + await ioHost.notify(info(`Supply a stack id (${stacks.stackArtifacts.map((s) => chalk.green(s.hierarchicalId)).join(', ')}) to display its template.`)); + } + + return new IdentityCloudAssemblySource(assembly.assembly); + } + + /** + * + */ + public async list(cx: ICloudAssemblySource, _options: ListOptions): Promise { + const ioHost = withAction(this.ioHost, 'list'); + const assembly = await this.assemblyFromSource(cx); throw new Error('Not implemented yet'); } - public async deploy(_cx: ICloudAssemblySource, _options: DeployOptions): Promise { + /** + * Compares the specified stack with the deployed stack or a local template file and returns a structured diff. + */ + public async diff(cx: ICloudAssemblySource, options: DiffOptions): Promise { + const ioHost = withAction(this.ioHost, 'diff'); + const assembly = await this.assemblyFromSource(cx); + const stacks = await assembly.selectStacks(options.stacks, {}); throw new Error('Not implemented yet'); } - public async watch(_cx: ICloudAssemblySource, _options: WatchOptions): Promise { + /** + * Deploys the selected stacks into an AWS account + */ + public async deploy(cx: ICloudAssemblySource, options: DeployOptions): Promise { + const ioHost = withAction(this.ioHost, 'deploy'); + const timer = Timer.start(); + const assembly = await this.assemblyFromSource(cx); + const stackCollection = await assembly.selectStacks(options.stacks, {}); + + const synthTime = timer.end(); + await ioHost.notify(info(`\n✨ Synthesis time: ${synthTime.asSec}s\n`, 'CDK_TOOLKIT_I5001', { + time: synthTime.asMs, + })); + + if (stackCollection.stackCount === 0) { + await ioHost.notify(error('This app contains no stacks')); + return; + } + + const deployments = await this.deploymentsForAction('deploy'); + + const migrator = new ResourceMigrator({ + deployments, + }); + await migrator.tryMigrateResources(stackCollection, options); + + const requireApproval = options.requireApproval ?? RequireApproval.BROADENING; + + const parameterMap = buildParameterMap(options.parameters?.parameters); + + if (options.hotswap !== HotswapMode.FULL_DEPLOYMENT) { + warning( + '⚠️ The --hotswap and --hotswap-fallback flags deliberately introduce CloudFormation drift to speed up deployments', + ); + warning('⚠️ They should only be used for development - never use them for your production Stacks!\n'); + } + + // @TODO + // let hotswapPropertiesFromSettings = this.props.configuration.settings.get(['hotswap']) || {}; + + // let hotswapPropertyOverrides = new HotswapPropertyOverrides(); + // hotswapPropertyOverrides.ecsHotswapProperties = new EcsHotswapProperties( + // hotswapPropertiesFromSettings.ecs?.minimumHealthyPercent, + // hotswapPropertiesFromSettings.ecs?.maximumHealthyPercent, + // ); + + const stacks = stackCollection.stackArtifacts; + + const stackOutputs: { [key: string]: any } = {}; + const outputsFile = options.outputsFile; + + const buildAsset = async (assetNode: AssetBuildNode) => { + await deployments.buildSingleAsset( + assetNode.assetManifestArtifact, + assetNode.assetManifest, + assetNode.asset, + { + stack: assetNode.parentStack, + roleArn: options.roleArn, + stackName: assetNode.parentStack.stackName, + }, + ); + }; + + const publishAsset = async (assetNode: AssetPublishNode) => { + await deployments.publishSingleAsset(assetNode.assetManifest, assetNode.asset, { + stack: assetNode.parentStack, + roleArn: options.roleArn, + stackName: assetNode.parentStack.stackName, + }); + }; + + const deployStack = async (stackNode: StackNode) => { + const stack = stackNode.stack; + if (stackCollection.stackCount !== 1) { + + highlight(stack.displayName); + } + + if (!stack.environment) { + // eslint-disable-next-line max-len + throw new ToolkitError( + `Stack ${stack.displayName} does not define an environment, and AWS credentials could not be obtained from standard locations or no region was configured.`, + ); + } + + if (Object.keys(stack.template.Resources || {}).length === 0) { + // The generated stack has no resources + if (!(await deployments.stackExists({ stack }))) { + warning('%s: stack has no resources, skipping deployment.', chalk.bold(stack.displayName)); + } else { + warning('%s: stack has no resources, deleting existing stack.', chalk.bold(stack.displayName)); + await this._destroy(assembly, 'deploy', { + selector: { patterns: [stack.hierarchicalId] }, + exclusively: true, + force: true, + roleArn: options.roleArn, + fromDeploy: true, + ci: options.ci, + }); + } + return; + } + + if (requireApproval !== RequireApproval.Never) { + const currentTemplate = await this.props.deployments.readCurrentTemplate(stack); + if (printSecurityDiff(currentTemplate, stack, requireApproval)) { + await askUserConfirmation( + concurrency, + '"--require-approval" is enabled and stack includes security-sensitive updates', + 'Do you wish to deploy these changes', + ); + } + } + + // Following are the same semantics we apply with respect to Notification ARNs (dictated by the SDK) + // + // - undefined => cdk ignores it, as if it wasn't supported (allows external management). + // - []: => cdk manages it, and the user wants to wipe it out. + // - ['arn-1'] => cdk manages it, and the user wants to set it to ['arn-1']. + const notificationArns = (!!options.notificationArns || !!stack.notificationArns) + ? (options.notificationArns ?? []).concat(stack.notificationArns ?? []) + : undefined; + + for (const notificationArn of notificationArns ?? []) { + if (!validateSnsTopicArn(notificationArn)) { + throw new ToolkitError(`Notification arn ${notificationArn} is not a valid arn for an SNS topic`); + } + } + + const stackIndex = stacks.indexOf(stack) + 1; + await ioHost.notify( + info('%s: deploying... [%s/%s]', chalk.bold(stack.displayName), stackIndex, stackCollection.stackCount), + ); + const startDeployTime = new Date().getTime(); + + let tags = options.tags; + if (!tags || tags.length === 0) { + tags = tagsForStack(stack); + } + + let elapsedDeployTime = 0; + try { + let deployResult: SuccessfulDeployStackResult | undefined; + + let rollback = options.rollback; + let iteration = 0; + while (!deployResult) { + if (++iteration > 2) { + throw new ToolkitError('This loop should have stabilized in 2 iterations, but didn\'t. If you are seeing this error, please report it at https://github.com/aws/aws-cdk/issues/new/choose'); + } + + const r = await deployments.deployStack({ + stack, + deployName: stack.stackName, + roleArn: options.roleArn, + toolkitStackName: options.toolkitStackName, + reuseAssets: options.reuseAssets, + notificationArns, + tags, + deploymentMethod: options.deploymentMethod, + force: options.force, + parameters: Object.assign({}, parameterMap['*'], parameterMap[stack.stackName]), + usePreviousParameters: options.parameters?.keepExistingParameters, + progress, + ci: options.ci, + rollback, + hotswap: options.hotswap, + // hotswapPropertyOverrides: hotswapPropertyOverrides, + + assetParallelism: options.assetParallelism, + }); + + switch (r.type) { + case 'did-deploy-stack': + deployResult = r; + break; + + case 'failpaused-need-rollback-first': { + const motivation = r.reason === 'replacement' + ? `Stack is in a paused fail state (${r.status}) and change includes a replacement which cannot be deployed with "--no-rollback"` + : `Stack is in a paused fail state (${r.status}) and command line arguments do not include "--no-rollback"`; + + if (options.force) { + warning(`${motivation}. Rolling back first (--force).`); + } else { + await askUserConfirmation( + concurrency, + motivation, + `${motivation}. Roll back first and then proceed with deployment`, + ); + } + + // Perform a rollback + await this.rollback({ + selector: { patterns: [stack.hierarchicalId] }, + toolkitStackName: options.toolkitStackName, + force: options.force, + }); + + // Go around through the 'while' loop again but switch rollback to true. + rollback = true; + break; + } + + case 'replacement-requires-rollback': { + const motivation = 'Change includes a replacement which cannot be deployed with "--no-rollback"'; + + if (options.force) { + warning(`${motivation}. Proceeding with regular deployment (--force).`); + } else { + await askUserConfirmation( + concurrency, + motivation, + `${motivation}. Perform a regular deployment`, + ); + } + + // Go around through the 'while' loop again but switch rollback to false. + rollback = true; + break; + } + + default: + throw new ToolkitError(`Unexpected result type from deployStack: ${JSON.stringify(r)}. If you are seeing this error, please report it at https://github.com/aws/aws-cdk/issues/new/choose`); + } + } + + const message = deployResult.noOp + ? ' ✅ %s (no changes)' + : ' ✅ %s'; + + success('\n' + message, stack.displayName); + elapsedDeployTime = new Date().getTime() - startDeployTime; + print('\n✨ Deployment time: %ss\n', formatTime(elapsedDeployTime)); + + if (Object.keys(deployResult.outputs).length > 0) { + print('Outputs:'); + + stackOutputs[stack.stackName] = deployResult.outputs; + } + + for (const name of Object.keys(deployResult.outputs).sort()) { + const value = deployResult.outputs[name]; + print('%s.%s = %s', chalk.cyan(stack.id), chalk.cyan(name), chalk.underline(chalk.cyan(value))); + } + + print('Stack ARN:'); + + data(deployResult.stackArn); + } catch (e: any) { + // It has to be exactly this string because an integration test tests for + // "bold(stackname) failed: ResourceNotReady: " + throw new ToolkitError( + [`❌ ${chalk.bold(stack.stackName)} failed:`, ...(e.name ? [`${e.name}:`] : []), e.message].join(' '), + ); + } finally { + if (options.cloudWatchLogMonitor) { + const foundLogGroupsResult = await findCloudWatchLogGroups(this.props.sdkProvider, stack); + options.cloudWatchLogMonitor.addLogGroups( + foundLogGroupsResult.env, + foundLogGroupsResult.sdk, + foundLogGroupsResult.logGroupNames, + ); + } + // If an outputs file has been specified, create the file path and write stack outputs to it once. + // Outputs are written after all stacks have been deployed. If a stack deployment fails, + // all of the outputs from successfully deployed stacks before the failure will still be written. + if (outputsFile) { + fs.ensureFileSync(outputsFile); + await fs.writeJson(outputsFile, stackOutputs, { + spaces: 2, + encoding: 'utf8', + }); + } + } + print('\n✨ Total time: %ss\n', formatTime(elapsedSynthTime + elapsedDeployTime)); + }; + + const assetBuildTime = options.assetBuildTime ?? AssetBuildTime.ALL_BEFORE_DEPLOY; + const prebuildAssets = assetBuildTime === AssetBuildTime.ALL_BEFORE_DEPLOY; + const concurrency = options.concurrency || 1; + const progress = concurrency > 1 ? StackActivityProgress.EVENTS : options.progress; + if (concurrency > 1 && options.progress && options.progress != StackActivityProgress.EVENTS) { + warning('⚠️ The --concurrency flag only supports --progress "events". Switching to "events".'); + } + + const stacksAndTheirAssetManifests = stacks.flatMap((stack) => [ + stack, + ...stack.dependencies.filter(cxapi.AssetManifestArtifact.isAssetManifestArtifact), + ]); + const workGraph = new WorkGraphBuilder(prebuildAssets).build(stacksAndTheirAssetManifests); + + // Unless we are running with '--force', skip already published assets + if (!options.force) { + await this.removePublishedAssets(workGraph, options); + } + + const graphConcurrency: Concurrency = { + 'stack': concurrency, + 'asset-build': 1, // This will be CPU-bound/memory bound, mostly matters for Docker builds + 'asset-publish': (options.assetParallelism ?? true) ? 8 : 1, // This will be I/O-bound, 8 in parallel seems reasonable + }; + + await workGraph.doParallel(graphConcurrency, { + deployStack, + buildAsset, + publishAsset, + }); + } + + /** + * Watch Action + * + * Continuously observe project files and deploy the selected stacks automatically when changes are detected. + * Implies hotswap deployments. + */ + public async watch(_cx: ICloudAssemblySource, _options: WatchOptions): Promise { + const ioHost = withAction(this.ioHost, 'watch'); throw new Error('Not implemented yet'); } - public async destroy(_cx: ICloudAssemblySource, _options: DestroyOptions): Promise { + /** + * Rollback Action + * + * Rolls back the selected stacks. + */ + public async rollback(_cx: ICloudAssemblySource, _options: WatchOptions): Promise { + const ioHost = withAction(this.ioHost, 'rollback'); throw new Error('Not implemented yet'); } + /** + * Destroy Action + * + * Destroys the selected Stacks. + */ + public async destroy(cx: ICloudAssemblySource, options: DestroyOptions): Promise { + const assembly = await this.assemblyFromSource(cx); + return this._destroy(assembly, 'destroy', options); + } + + /** + * Helper to allow destroy being called as part of the deploy action. + */ + private async _destroy(assembly: StackAssembly, action: 'deploy' | 'destroy', options: DestroyOptions): Promise { + const ioHost = withAction(this.ioHost, action); + let stacks = await await assembly.selectStacks(options.stacks, false); + + // The stacks will have been ordered for deployment, so reverse them for deletion. + stacks = stacks.reversed(); + + const msg = `Are you sure you want to delete: ${chalk.blue(stacks.stackArtifacts.map((s) => s.hierarchicalId).join(', '))} (y/n)?`; + const confirmed = await this.ioHost.requestResponse(prompt(msg, true)); + if (!confirmed) { + return; + } + + for (const [index, stack] of stacks.stackArtifacts.entries()) { + await ioHost.notify(success('%s: destroying... [%s/%s]', chalk.blue(stack.displayName), index + 1, stacks.stackCount)); + try { + const deployments = await this.deploymentsForAction(action, this.toolkitStackName); + await deployments.destroyStack({ + stack, + deployName: stack.stackName, + roleArn: options.roleArn, + ci: options.ci, + }); + await ioHost.notify(success(`\n ✅ %s: ${action}ed`, chalk.blue(stack.displayName))); + } catch (e) { + await ioHost.notify(error(`\n ❌ %s: ${action} failed`, chalk.blue(stack.displayName), e)); + throw e; + } + } + } + + /** + * Creates a Toolkit internal CloudAssembly from a CloudAssemblySource. + * @param assemblySource the source for the cloud assembly + * @param cache if the assembly should be cached, default: `true` + * @returns the CloudAssembly object + */ + private async assemblyFromSource(assemblySource: ICloudAssemblySource, cache: boolean = true): Promise { + if (assemblySource instanceof StackAssembly) { + return assemblySource; + } + + if (cache) { + return new StackAssembly(await new CachedCloudAssemblySource(assemblySource).produce()); + } + + return new StackAssembly(await assemblySource.produce()); + } + + /** + * Create a deployments class + * @param action C + * @returns + */ + private async deploymentsForAction(action: ToolkitAction): Promise { + return new Deployments({ + sdkProvider: await this.sdkProvider(action), + toolkitStackName: this.toolkitStackName, + }); + } + + /** + * Select all stacks that have the validateOnSynth flag et. + * + * @param assembly + * @returns a `StackCollection` of all stacks that needs to be validated + */ + private async selectStacksForValidation(assembly: StackAssembly) { + const allStacks = await assembly.selectStacks({ strategy: StackSelectionStrategy.ALL_STACKS }, false); + return allStacks.filter((art) => art.validateOnSynth ?? false); + } + + /** + * Validate the stacks for errors and warnings according to the CLI's current settings + * @deprecated remove and use directly in synth + */ + private processStackMessages(stacks: StackCollection) { + stacks.processMetadataMessages({ + ignoreErrors: this.props.ignoreErrors, + strict: this.props.strict, + verbose: this.props.verbose, + }); + } } diff --git a/packages/@aws-cdk/toolkit/lib/types.ts b/packages/@aws-cdk/toolkit/lib/types.ts index 459c3b9a04f22..dbccb1da39fa6 100644 --- a/packages/@aws-cdk/toolkit/lib/types.ts +++ b/packages/@aws-cdk/toolkit/lib/types.ts @@ -1,11 +1,15 @@ +/** + * The current action being performed by the CLI. 'none' represents the absence of an action. + */ export type ToolkitAction = - | 'bootstrap' - | 'synth' - | 'list' - | 'deploy' - | 'destroy'; - -export type MessageLevel = 'error' | 'warn' | 'info' | 'debug'; +| 'bootstrap' +| 'synth' +| 'list' +| 'diff' +| 'deploy' +| 'rollback' +| 'watch' +| 'destroy'; export enum StackSelectionStrategy { /** @@ -14,15 +18,17 @@ export enum StackSelectionStrategy { NONE = 'none', /** - * If the app includes a single stack, returns it. Otherwise throws an exception. - * This behavior is used by "deploy". + * Return matched stacks. If no patterns are provided, return the single stack in the app. + * If the app has more than one stack, an error is thrown. + * + * This is the default strategy used by "deploy" and "destroy". */ - ONLY_SINGLE = 'single', + MATCH_OR_SINGLE = 'match-or-single', /** - * Throws an exception if the app doesn't contain at least one stack. + * Throws an exception if the selector doesn't match at least one stack in the app. */ - AT_LEAST_ONE = 'at-least-one', + MUST_MATCH_PATTERN = 'must-match-pattern', /** * Returns all stacks in the main (top level) assembly only. @@ -63,7 +69,7 @@ export interface StackSelector { /** * A list of patterns to match the stack hierarchical ids */ - patterns: string[]; + patterns?: string[]; /** * Extend the selection to upstream/downstream stacks diff --git a/packages/@aws-cdk/toolkit/package.json b/packages/@aws-cdk/toolkit/package.json index 360f1ce16b743..4a62cee23b1e5 100644 --- a/packages/@aws-cdk/toolkit/package.json +++ b/packages/@aws-cdk/toolkit/package.json @@ -27,14 +27,24 @@ }, "license": "Apache-2.0", "devDependencies": { + "@smithy/types": "3.5.0", + "@types/fs-extra": "^11.0.4", "@types/jest": "^29.5.14", + "@types/node": "^18.18.14", "@aws-cdk/cdk-build-tools": "0.0.0", "@aws-cdk/pkglint": "0.0.0", "jest": "^29.7.0", "typescript": "~5.6.3" }, "dependencies": { - "@aws-cdk/cx-api": "0.0.0" + "@aws-cdk/cloudformation-diff": "0.0.0", + "@aws-cdk/cx-api": "0.0.0", + "aws-cdk": "0.0.0", + "chalk": "^4", + "fs-extra": "^11.2.0", + "minimatch": "^9.0.5", + "semver": "^7.6.3", + "yaml": "1.10.2" }, "repository": { "url": "https://github.com/aws/aws-cdk.git", diff --git a/packages/@aws-cdk/toolkit/tsconfig.json b/packages/@aws-cdk/toolkit/tsconfig.json index fc285ab52c7c5..95fd939a36cef 100644 --- a/packages/@aws-cdk/toolkit/tsconfig.json +++ b/packages/@aws-cdk/toolkit/tsconfig.json @@ -20,5 +20,9 @@ }, "include": ["**/*.ts"], "exclude": ["node_modules", "**/*.d.ts", "dist"], - "references": [{ "path": "../cx-api" }] + "references": [ + { "path": "../cx-api" }, + { "path": "../cloudformation-diff" }, + { "path": "../../aws-cdk" } + ] } diff --git a/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts b/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts index 079a0322b73e5..f32608e627a2f 100644 --- a/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts +++ b/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts @@ -1,5 +1,5 @@ import { BootstrapSource } from './bootstrap-environment'; -import { Tag } from '../../cdk-toolkit'; +import { Tag } from '../../tags'; import { StringWithoutPlaceholders } from '../util/placeholders'; export const BUCKET_NAME_OUTPUT = 'BucketName'; diff --git a/packages/aws-cdk/lib/api/deployments.ts b/packages/aws-cdk/lib/api/deployments.ts index 32650ac7a3549..32633db0a122c 100644 --- a/packages/aws-cdk/lib/api/deployments.ts +++ b/packages/aws-cdk/lib/api/deployments.ts @@ -5,10 +5,10 @@ import { AssetManifest, IManifestEntry } from 'cdk-assets'; import * as chalk from 'chalk'; import type { SdkProvider } from './aws-auth/sdk-provider'; import { type DeploymentMethod, deployStack, DeployStackResult, destroyStack } from './deploy-stack'; +import { EnvironmentAccess } from './environment-access'; import { type EnvironmentResources } from './environment-resources'; -import type { Tag } from '../cdk-toolkit'; import { debug, warning } from '../logging'; -import { EnvironmentAccess } from './environment-access'; +import type { Tag } from '../tags'; import { HotswapMode, HotswapPropertyOverrides } from './hotswap/common'; import { loadCurrentTemplate, @@ -25,10 +25,6 @@ import { Template, uploadStackTemplateAssets, } from './util/cloudformation'; -import { StackActivityMonitor, StackActivityProgress } from './util/cloudformation/stack-activity-monitor'; -import { StackEventPoller } from './util/cloudformation/stack-event-poller'; -import { RollbackChoice } from './util/cloudformation/stack-status'; -import { makeBodyParameter } from './util/template-body-parameter'; import { AssetManifestBuilder } from '../util/asset-manifest-builder'; import { buildAssets, @@ -38,6 +34,10 @@ import { type PublishAssetsOptions, PublishingAws, } from '../util/asset-publishing'; +import { StackActivityMonitor, StackActivityProgress } from './util/cloudformation/stack-activity-monitor'; +import { StackEventPoller } from './util/cloudformation/stack-event-poller'; +import { RollbackChoice } from './util/cloudformation/stack-status'; +import { makeBodyParameter } from './util/template-body-parameter'; import { formatErrorMessage } from '../util/error'; const BOOTSTRAP_STACK_VERSION_FOR_ROLLBACK = 23; diff --git a/packages/aws-cdk/lib/api/util/string-manipulation.ts b/packages/aws-cdk/lib/api/util/string-manipulation.ts index 46ea54f42f422..87f6453be95b8 100644 --- a/packages/aws-cdk/lib/api/util/string-manipulation.ts +++ b/packages/aws-cdk/lib/api/util/string-manipulation.ts @@ -5,3 +5,27 @@ export function leftPad(s: string, n: number, char: string) { const padding = Math.max(0, n - s.length); return char.repeat(padding) + s; } + +/** + * Formats time in milliseconds (which we get from 'Date.getTime()') + * to a human-readable time; returns time in seconds rounded to 2 + * decimal places. + */ +export function formatTime(num: number): number { + return roundPercentage(millisecondsToSeconds(num)); +} + +/** + * Rounds a decimal number to two decimal points. + * The function is useful for fractions that need to be outputted as percentages. + */ +function roundPercentage(num: number): number { + return Math.round(100 * num) / 100; +} + +/** + * Given a time in milliseconds, return an equivalent amount in seconds. + */ +function millisecondsToSeconds(num: number): number { + return num / 1000; +} diff --git a/packages/aws-cdk/lib/cdk-toolkit.ts b/packages/aws-cdk/lib/cdk-toolkit.ts index 3f0db42c289ba..4459db424f175 100644 --- a/packages/aws-cdk/lib/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cdk-toolkit.ts @@ -22,8 +22,9 @@ import { GarbageCollector } from './api/garbage-collection/garbage-collector'; import { HotswapMode, HotswapPropertyOverrides, EcsHotswapProperties } from './api/hotswap/common'; import { findCloudWatchLogGroups } from './api/logs/find-cloudwatch-logs'; import { CloudWatchLogEventMonitor } from './api/logs/logs-monitor'; -import { createDiffChangeSet, ResourcesToImport } from './api/util/cloudformation'; +import { createDiffChangeSet } from './api/util/cloudformation'; import { StackActivityProgress } from './api/util/cloudformation/stack-activity-monitor'; +import { formatTime } from './api/util/string-manipulation'; import { generateCdkApp, generateStack, @@ -46,8 +47,10 @@ import { printSecurityDiff, printStackDiff, RequireApproval } from './diff'; import { ResourceImporter, removeNonImportResources } from './import'; import { listStacks } from './list-stacks'; import { data, debug, error, highlight, info, success, warning, withCorkedLogging } from './logging'; +import { ResourceMigrator } from './migrator'; import { deserializeStructure, serializeStructure } from './serialize'; import { Configuration, PROJECT_CONFIG } from './settings'; +import { Tag, tagsForStack } from './tags'; import { ToolkitError } from './toolkit/error'; import { numberFromBool, partition } from './util'; import { formatErrorMessage } from './util/error'; @@ -186,7 +189,10 @@ export class CdkToolkit { const currentTemplate = templateWithNestedStacks.deployedRootTemplate; const nestedStacks = templateWithNestedStacks.nestedStacks; - const resourcesToImport = await this.tryGetResources(await this.props.deployments.resolveEnvironment(stack)); + const migrator = new ResourceMigrator({ + deployments: this.props.deployments, + }); + const resourcesToImport = await migrator.tryGetResources(await this.props.deployments.resolveEnvironment(stack)); if (resourcesToImport) { removeNonImportResources(stack); } @@ -282,7 +288,10 @@ export class CdkToolkit { return; } - await this.tryMigrateResources(stackCollection, options); + const migrator = new ResourceMigrator({ + deployments: this.props.deployments, + }); + await migrator.tryMigrateResources(stackCollection, options); const requireApproval = options.requireApproval ?? RequireApproval.Broadening; @@ -1250,72 +1259,6 @@ export class CdkToolkit { stackName: assetNode.parentStack.stackName, })); } - - /** - * Checks to see if a migrate.json file exists. If it does and the source is either `filepath` or - * is in the same environment as the stack deployment, a new stack is created and the resources are - * migrated to the stack using an IMPORT changeset. The normal deployment will resume after this is complete - * to add back in any outputs and the CDKMetadata. - */ - private async tryMigrateResources(stacks: StackCollection, options: DeployOptions): Promise { - const stack = stacks.stackArtifacts[0]; - const migrateDeployment = new ResourceImporter(stack, this.props.deployments); - const resourcesToImport = await this.tryGetResources(await migrateDeployment.resolveEnvironment()); - - if (resourcesToImport) { - info('%s: creating stack for resource migration...', chalk.bold(stack.displayName)); - info('%s: importing resources into stack...', chalk.bold(stack.displayName)); - - await this.performResourceMigration(migrateDeployment, resourcesToImport, options); - - fs.rmSync('migrate.json'); - info('%s: applying CDKMetadata and Outputs to stack (if applicable)...', chalk.bold(stack.displayName)); - } - } - - /** - * Creates a new stack with just the resources to be migrated - */ - private async performResourceMigration( - migrateDeployment: ResourceImporter, - resourcesToImport: ResourcesToImport, - options: DeployOptions, - ) { - const startDeployTime = new Date().getTime(); - let elapsedDeployTime = 0; - - // Initial Deployment - await migrateDeployment.importResourcesFromMigrate(resourcesToImport, { - roleArn: options.roleArn, - toolkitStackName: options.toolkitStackName, - deploymentMethod: options.deploymentMethod, - usePreviousParameters: true, - progress: options.progress, - rollback: options.rollback, - }); - - elapsedDeployTime = new Date().getTime() - startDeployTime; - info('\n✨ Resource migration time: %ss\n', formatTime(elapsedDeployTime)); - } - - private async tryGetResources(environment: cxapi.Environment): Promise { - try { - const migrateFile = fs.readJsonSync('migrate.json', { - encoding: 'utf-8', - }); - const sourceEnv = (migrateFile.Source as string).split(':'); - if ( - sourceEnv[0] === 'localfile' || - (sourceEnv[4] === environment.account && sourceEnv[3] === environment.region) - ) { - return migrateFile.Resources; - } - } catch (e) { - // Nothing to do - } - - return undefined; - } } /** @@ -1844,42 +1787,6 @@ export interface MigrateOptions { readonly compress?: boolean; } -/** - * @returns an array with the tags available in the stack metadata. - */ -function tagsForStack(stack: cxapi.CloudFormationStackArtifact): Tag[] { - return Object.entries(stack.tags).map(([Key, Value]) => ({ Key, Value })); -} - -export interface Tag { - readonly Key: string; - readonly Value: string; -} - -/** - * Formats time in milliseconds (which we get from 'Date.getTime()') - * to a human-readable time; returns time in seconds rounded to 2 - * decimal places. - */ -function formatTime(num: number): number { - return roundPercentage(millisecondsToSeconds(num)); -} - -/** - * Rounds a decimal number to two decimal points. - * The function is useful for fractions that need to be outputted as percentages. - */ -function roundPercentage(num: number): number { - return Math.round(100 * num) / 100; -} - -/** - * Given a time in milliseconds, return an equivalent amount in seconds. - */ -function millisecondsToSeconds(num: number): number { - return num / 1000; -} - function buildParameterMap( parameters: | { diff --git a/packages/aws-cdk/lib/import.ts b/packages/aws-cdk/lib/import.ts index 47451ce76d874..bbfe268cecab6 100644 --- a/packages/aws-cdk/lib/import.ts +++ b/packages/aws-cdk/lib/import.ts @@ -10,8 +10,8 @@ import { assertIsSuccessfulDeployStackResult } from './api/deploy-stack'; import { Deployments } from './api/deployments'; import { ResourceIdentifierProperties, ResourcesToImport } from './api/util/cloudformation'; import { StackActivityProgress } from './api/util/cloudformation/stack-activity-monitor'; -import { Tag } from './cdk-toolkit'; import { error, info, success, warning } from './logging'; +import { Tag } from './tags'; import { ToolkitError } from './toolkit/error'; export interface ImportDeploymentOptions extends DeployOptions { diff --git a/packages/aws-cdk/lib/migrator.ts b/packages/aws-cdk/lib/migrator.ts new file mode 100644 index 0000000000000..2c6a27af62a51 --- /dev/null +++ b/packages/aws-cdk/lib/migrator.ts @@ -0,0 +1,85 @@ +import type * as cxapi from '@aws-cdk/cx-api'; +import * as chalk from 'chalk'; +import * as fs from 'fs-extra'; +import { StackCollection } from './api/cxapp/cloud-assembly'; +import { Deployments } from './api/deployments'; +import { ResourcesToImport } from './api/util/cloudformation'; +import { formatTime } from './api/util/string-manipulation'; +import { DeployOptions } from './cdk-toolkit'; +import { ResourceImporter } from './import'; +import { info } from './logging'; + +export interface ResourceMigratorProps { + deployments: Deployments; +} + +export class ResourceMigrator { + public constructor(private readonly props: ResourceMigratorProps) {} + + /** + * Checks to see if a migrate.json file exists. If it does and the source is either `filepath` or + * is in the same environment as the stack deployment, a new stack is created and the resources are + * migrated to the stack using an IMPORT changeset. The normal deployment will resume after this is complete + * to add back in any outputs and the CDKMetadata. + */ + public async tryMigrateResources(stacks: StackCollection, options: DeployOptions): Promise { + const stack = stacks.stackArtifacts[0]; + const migrateDeployment = new ResourceImporter(stack, this.props.deployments); + const resourcesToImport = await this.tryGetResources(await migrateDeployment.resolveEnvironment()); + + if (resourcesToImport) { + info('%s: creating stack for resource migration...', chalk.bold(stack.displayName)); + info('%s: importing resources into stack...', chalk.bold(stack.displayName)); + + await this.performResourceMigration(migrateDeployment, resourcesToImport, options); + + fs.rmSync('migrate.json'); + info('%s: applying CDKMetadata and Outputs to stack (if applicable)...', chalk.bold(stack.displayName)); + } + } + + /** + * Creates a new stack with just the resources to be migrated + */ + private async performResourceMigration( + migrateDeployment: ResourceImporter, + resourcesToImport: ResourcesToImport, + options: DeployOptions, + ) { + const startDeployTime = new Date().getTime(); + let elapsedDeployTime = 0; + + // Initial Deployment + await migrateDeployment.importResourcesFromMigrate(resourcesToImport, { + roleArn: options.roleArn, + toolkitStackName: options.toolkitStackName, + deploymentMethod: options.deploymentMethod, + usePreviousParameters: true, + progress: options.progress, + rollback: options.rollback, + }); + + elapsedDeployTime = new Date().getTime() - startDeployTime; + info('\n✨ Resource migration time: %ss\n', formatTime(elapsedDeployTime)); + } + + public async tryGetResources(environment: cxapi.Environment): Promise { + try { + const migrateFile = fs.readJsonSync('migrate.json', { + encoding: 'utf-8', + }); + const sourceEnv = (migrateFile.Source as string).split(':'); + if ( + sourceEnv[0] === 'localfile' || + (sourceEnv[4] === environment.account && sourceEnv[3] === environment.region) + ) { + return migrateFile.Resources; + } + } catch (e) { + // Nothing to do + } + + return undefined; + } +} + diff --git a/packages/aws-cdk/lib/settings.ts b/packages/aws-cdk/lib/settings.ts index 9c6e680e6c8d8..67ef8dabfa728 100644 --- a/packages/aws-cdk/lib/settings.ts +++ b/packages/aws-cdk/lib/settings.ts @@ -1,8 +1,8 @@ import * as os from 'os'; import * as fs_path from 'path'; import * as fs from 'fs-extra'; -import { Tag } from './cdk-toolkit'; import { debug, warning } from './logging'; +import { Tag } from './tags'; import { ToolkitError } from './toolkit/error'; import * as util from './util'; diff --git a/packages/aws-cdk/lib/tags.ts b/packages/aws-cdk/lib/tags.ts new file mode 100644 index 0000000000000..df6ed884c1cca --- /dev/null +++ b/packages/aws-cdk/lib/tags.ts @@ -0,0 +1,13 @@ +import * as cxapi from '@aws-cdk/cx-api'; + +/** + * @returns an array with the tags available in the stack metadata. + */ +export function tagsForStack(stack: cxapi.CloudFormationStackArtifact): Tag[] { + return Object.entries(stack.tags).map(([Key, Value]) => ({ Key, Value })); +} + +export interface Tag { + readonly Key: string; + readonly Value: string; +} diff --git a/packages/aws-cdk/test/cdk-toolkit.test.ts b/packages/aws-cdk/test/cdk-toolkit.test.ts index 8dad7142baea7..4f51ec2ec1885 100644 --- a/packages/aws-cdk/test/cdk-toolkit.test.ts +++ b/packages/aws-cdk/test/cdk-toolkit.test.ts @@ -88,9 +88,10 @@ import { import { HotswapMode } from '../lib/api/hotswap/common'; import { Mode } from '../lib/api/plugin/mode'; import { Template } from '../lib/api/util/cloudformation'; -import { CdkToolkit, markTesting, Tag } from '../lib/cdk-toolkit'; +import { CdkToolkit, markTesting } from '../lib/cdk-toolkit'; import { RequireApproval } from '../lib/diff'; import { Configuration } from '../lib/settings'; +import { Tag } from '../lib/tags'; import { flatten } from '../lib/util'; markTesting(); diff --git a/packages/aws-cdk/test/settings.test.ts b/packages/aws-cdk/test/settings.test.ts index 7edc2e9b487a2..4e2053e8e2848 100644 --- a/packages/aws-cdk/test/settings.test.ts +++ b/packages/aws-cdk/test/settings.test.ts @@ -1,6 +1,6 @@ /* eslint-disable import/order */ import { Command, Context, Settings } from '../lib/settings'; -import { Tag } from '../lib/cdk-toolkit'; +import { Tag } from '../lib/tags'; test('can delete values from Context object', () => { // GIVEN diff --git a/yarn.lock b/yarn.lock index bc30463b93516..34f244670a843 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8233,6 +8233,14 @@ dependencies: "@types/node" "*" +"@types/fs-extra@^11.0.4": + version "11.0.4" + resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz#e16a863bb8843fba8c5004362b5a73e17becca45" + integrity sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ== + dependencies: + "@types/jsonfile" "*" + "@types/node" "*" + "@types/fs-extra@^9.0.13": version "9.0.13" resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz#7594fbae04fe7f1918ce8b3d213f74ff44ac1f45" @@ -8297,6 +8305,13 @@ resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/jsonfile@*": + version "6.1.4" + resolved "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz#614afec1a1164e7d670b4a7ad64df3e7beb7b702" + integrity sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ== + dependencies: + "@types/node" "*" + "@types/license-checker@^25.0.6": version "25.0.6" resolved "https://registry.npmjs.org/@types/license-checker/-/license-checker-25.0.6.tgz#c346285ee7e42bac58a4922059453f50a5d4175d"