diff --git a/packages/@aws-cdk/cli-lib-alpha/lib/cli.ts b/packages/@aws-cdk/cli-lib-alpha/lib/cli.ts index 1b96fa146ec4c..2f50aa3fc7916 100644 --- a/packages/@aws-cdk/cli-lib-alpha/lib/cli.ts +++ b/packages/@aws-cdk/cli-lib-alpha/lib/cli.ts @@ -123,7 +123,7 @@ export class AwsCdkCli implements IAwsCdkCli { return new AwsCdkCli(async (args) => changeDir( () => runCli(args, async (sdk, config) => { const env = await prepareDefaultEnvironment(sdk); - const context = await prepareContext(config, env); + const context = await prepareContext(config.settings, config.context.all, env); return withEnv(async() => createAssembly(await producer.produce(context)), env); }), 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/cxapp/cloud-assembly.ts b/packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts index 4f0fa296ec123..3bc37117162d6 100644 --- a/packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts +++ b/packages/aws-cdk/lib/api/cxapp/cloud-assembly.ts @@ -2,8 +2,8 @@ import * as cxapi from '@aws-cdk/cx-api'; import * as chalk from 'chalk'; import { minimatch } from 'minimatch'; import * as semver from 'semver'; -import { error, info, warning } from '../../logging'; -import { ToolkitError } from '../../toolkit/error'; +import { info } from '../../logging'; +import { AssemblyError, ToolkitError } from '../../toolkit/error'; import { flatten } from '../../util'; export enum DefaultSelection { @@ -134,7 +134,7 @@ export class CloudAssembly { } } - private selectMatchingStacks( + protected selectMatchingStacks( stacks: cxapi.CloudFormationStackArtifact[], patterns: string[], extend: ExtendedStackSelection = ExtendedStackSelection.None, @@ -170,7 +170,7 @@ export class CloudAssembly { } } - private extendStacks( + protected extendStacks( matched: cxapi.CloudFormationStackArtifact[], all: cxapi.CloudFormationStackArtifact[], extend: ExtendedStackSelection = ExtendedStackSelection.None, @@ -231,6 +231,10 @@ export class StackCollection { return this.stackArtifacts.map(s => s.id); } + public get hierarchicalIds(): string[] { + return this.stackArtifacts.map(s => s.hierarchicalId); + } + public reversed() { const arts = [...this.stackArtifacts]; arts.reverse(); @@ -241,14 +245,17 @@ export class StackCollection { return new StackCollection(this.assembly, this.stackArtifacts.filter(predicate)); } - public concat(other: StackCollection): StackCollection { - return new StackCollection(this.assembly, this.stackArtifacts.concat(other.stackArtifacts)); + public concat(...others: StackCollection[]): StackCollection { + return new StackCollection(this.assembly, this.stackArtifacts.concat(...others.map(o => o.stackArtifacts))); } /** * Extracts 'aws:cdk:warning|info|error' metadata entries from the stack synthesis */ - public processMetadataMessages(options: MetadataMessageOptions = {}) { + public async validateMetadata( + failAt: 'warn' | 'error' | 'none' = 'error', + logger: (level: 'info' | 'error' | 'warn', msg: cxapi.SynthesisMessage) => Promise = async () => {}, + ) { let warnings = false; let errors = false; @@ -257,33 +264,25 @@ export class StackCollection { switch (message.level) { case cxapi.SynthesisMessageLevel.WARNING: warnings = true; - printMessage(warning, 'Warning', message.id, message.entry); + await logger('warn', message); break; case cxapi.SynthesisMessageLevel.ERROR: errors = true; - printMessage(error, 'Error', message.id, message.entry); + await logger('error', message); break; case cxapi.SynthesisMessageLevel.INFO: - printMessage(info, 'Info', message.id, message.entry); + await logger('info', message); break; } } } - if (errors && !options.ignoreErrors) { - throw new ToolkitError('Found errors'); + if (errors && failAt != 'none') { + throw new AssemblyError('Found errors'); } - if (options.strict && warnings) { - throw new ToolkitError('Found warnings (--strict mode)'); - } - - function printMessage(logFn: (s: string) => void, prefix: string, id: string, entry: cxapi.MetadataEntry) { - logFn(`[${prefix} at ${id}] ${entry.data}`); - - if (options.verbose && entry.trace) { - logFn(` ${entry.trace.join('\n ')}`); - } + if (warnings && failAt === 'warn') { + throw new AssemblyError('Found warnings (--strict mode)'); } } } @@ -380,7 +379,7 @@ function includeUpstreamStacks( } } -function sanitizePatterns(patterns: string[]): string[] { +export function sanitizePatterns(patterns: string[]): string[] { let sanitized = patterns.filter(s => s != null); // filter null/undefined sanitized = [...new Set(sanitized)]; // make them unique return sanitized; diff --git a/packages/aws-cdk/lib/api/cxapp/exec.ts b/packages/aws-cdk/lib/api/cxapp/exec.ts index b02ad38445a07..f17d0c11453df 100644 --- a/packages/aws-cdk/lib/api/cxapp/exec.ts +++ b/packages/aws-cdk/lib/api/cxapp/exec.ts @@ -6,7 +6,7 @@ import * as cxapi from '@aws-cdk/cx-api'; import * as fs from 'fs-extra'; import * as semver from 'semver'; import { debug, warning } from '../../logging'; -import { Configuration, PROJECT_CONFIG, USER_DEFAULTS } from '../../settings'; +import { Configuration, PROJECT_CONFIG, Settings, USER_DEFAULTS } from '../../settings'; import { ToolkitError } from '../../toolkit/error'; import { loadTree, some } from '../../tree'; import { splitBySize } from '../../util/objects'; @@ -22,7 +22,7 @@ export interface ExecProgramResult { /** Invokes the cloud executable and returns JSON output */ export async function execProgram(aws: SdkProvider, config: Configuration): Promise { const env = await prepareDefaultEnvironment(aws); - const context = await prepareContext(config, env); + const context = await prepareContext(config.settings, config.context.all, env); const build = config.settings.get(['build']); if (build) { @@ -44,7 +44,7 @@ export async function execProgram(aws: SdkProvider, config: Configuration): Prom return { assembly: createAssembly(app), lock }; } - const commandLine = await guessExecutable(appToArray(app)); + const commandLine = await guessExecutable(app); const outdir = config.settings.get(['output']); if (!outdir) { @@ -166,16 +166,19 @@ export function createAssembly(appDir: string) { * * @param context The context key/value bash. */ -export async function prepareDefaultEnvironment(aws: SdkProvider): Promise<{ [key: string]: string }> { +export async function prepareDefaultEnvironment( + aws: SdkProvider, + logFn: (msg: string, ...args: any) => any = debug, +): Promise<{ [key: string]: string }> { const env: { [key: string]: string } = { }; env[cxapi.DEFAULT_REGION_ENV] = aws.defaultRegion; - debug(`Setting "${cxapi.DEFAULT_REGION_ENV}" environment variable to`, env[cxapi.DEFAULT_REGION_ENV]); + await logFn(`Setting "${cxapi.DEFAULT_REGION_ENV}" environment variable to`, env[cxapi.DEFAULT_REGION_ENV]); const accountId = (await aws.defaultAccount())?.accountId; if (accountId) { env[cxapi.DEFAULT_ACCOUNT_ENV] = accountId; - debug(`Setting "${cxapi.DEFAULT_ACCOUNT_ENV}" environment variable to`, env[cxapi.DEFAULT_ACCOUNT_ENV]); + await logFn(`Setting "${cxapi.DEFAULT_ACCOUNT_ENV}" environment variable to`, env[cxapi.DEFAULT_ACCOUNT_ENV]); } return env; @@ -186,35 +189,33 @@ export async function prepareDefaultEnvironment(aws: SdkProvider): Promise<{ [ke * The merging of various configuration sources like cli args or cdk.json has already happened. * We now need to set the final values to the context. */ -export async function prepareContext(config: Configuration, env: { [key: string]: string | undefined}) { - const context = config.context.all; - - const debugMode: boolean = config.settings.get(['debug']) ?? true; +export async function prepareContext(settings: Settings, context: {[key: string]: any}, env: { [key: string]: string | undefined}) { + const debugMode: boolean = settings.get(['debug']) ?? true; if (debugMode) { env.CDK_DEBUG = 'true'; } - const pathMetadata: boolean = config.settings.get(['pathMetadata']) ?? true; + const pathMetadata: boolean = settings.get(['pathMetadata']) ?? true; if (pathMetadata) { context[cxapi.PATH_METADATA_ENABLE_CONTEXT] = true; } - const assetMetadata: boolean = config.settings.get(['assetMetadata']) ?? true; + const assetMetadata: boolean = settings.get(['assetMetadata']) ?? true; if (assetMetadata) { context[cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT] = true; } - const versionReporting: boolean = config.settings.get(['versionReporting']) ?? true; + const versionReporting: boolean = settings.get(['versionReporting']) ?? true; if (versionReporting) { context[cxapi.ANALYTICS_REPORTING_ENABLED_CONTEXT] = true; } // We need to keep on doing this for framework version from before this flag was deprecated. if (!versionReporting) { context['aws:cdk:disable-version-reporting'] = true; } - const stagingEnabled = config.settings.get(['staging']) ?? true; + const stagingEnabled = settings.get(['staging']) ?? true; if (!stagingEnabled) { context[cxapi.DISABLE_ASSET_STAGING_CONTEXT] = true; } - const bundlingStacks = config.settings.get(['bundlingStacks']) ?? ['**']; + const bundlingStacks = settings.get(['bundlingStacks']) ?? ['**']; context[cxapi.BUNDLING_STACKS] = bundlingStacks; debug('context:', context); @@ -257,7 +258,8 @@ const EXTENSION_MAP = new Map([ * verify if registry associations have or have not been set up for this * file type, so we'll assume the worst and take control. */ -async function guessExecutable(commandLine: string[]) { +export async function guessExecutable(app: string) { + const commandLine = appToArray(app); if (commandLine.length === 1) { let fstat; @@ -300,7 +302,7 @@ function contextOverflowCleanup(location: string | undefined, assembly: cxapi.Cl } } -function spaceAvailableForContext(env: { [key: string]: string }, limit: number) { +export function spaceAvailableForContext(env: { [key: string]: string }, limit: number) { const size = (value: string) => value != null ? Buffer.byteLength(value) : 0; const usedSpace = Object.entries(env) 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..8b0bd4b9a5026 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 { deserializeStructure, serializeStructure } from './serialize'; +import { ResourceMigrator } from './migrator'; +import { deserializeStructure, obscureTemplate, 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; @@ -1118,7 +1127,7 @@ export class CdkToolkit { }); this.validateStacksSelected(stacks, selector.patterns); - this.validateStacks(stacks); + await this.validateStacks(stacks); return stacks; } @@ -1144,7 +1153,7 @@ export class CdkToolkit { : new StackCollection(assembly, []); this.validateStacksSelected(selectedForDiff.concat(autoValidateStacks), stackNames); - this.validateStacks(selectedForDiff.concat(autoValidateStacks)); + await this.validateStacks(selectedForDiff.concat(autoValidateStacks)); return selectedForDiff; } @@ -1164,12 +1173,12 @@ export class CdkToolkit { /** * Validate the stacks for errors and warnings according to the CLI's current settings */ - private validateStacks(stacks: StackCollection) { - stacks.processMetadataMessages({ - ignoreErrors: this.props.ignoreErrors, - strict: this.props.strict, - verbose: this.props.verbose, - }); + private async validateStacks(stacks: StackCollection) { + let failAt: 'warn' | 'error' | 'none' = 'error'; + if (this.props.ignoreErrors) { failAt = 'none'; } + if (this.props.strict) { failAt = 'warn'; } + + await stacks.validateMetadata(failAt, stackMetadataLogger(this.props.verbose)); } /** @@ -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: | { @@ -1907,24 +1814,6 @@ function buildParameterMap( return parameterMap; } -/** - * Remove any template elements that we don't want to show users. - */ -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; -} - /** * Ask the user for a yes/no confirmation * @@ -1951,3 +1840,28 @@ async function askUserConfirmation( if (!confirmed) { throw new ToolkitError('Aborted by user'); } }); } + +/** + * Logger for processing stack metadata + */ +function stackMetadataLogger(verbose?: boolean): (level: 'info' | 'error' | 'warn', msg: cxapi.SynthesisMessage) => Promise { + const makeLogger = (level: string): [logger: (m: string) => void, prefix: string] => { + switch (level) { + case 'error': + return [error, 'Error']; + case 'warn': + return [warning, 'Warning']; + default: + return [info, 'Info']; + } + }; + + return async (level, msg) => { + const [logFn, prefix] = makeLogger(level); + logFn(`[${prefix} at ${msg.id}] ${msg.entry.data}`); + + if (verbose && msg.entry.trace) { + logFn(` ${msg.entry.trace.join('\n ')}`); + } + }; +} diff --git a/packages/aws-cdk/lib/cli.ts b/packages/aws-cdk/lib/cli.ts index 75ab612a8d828..b1a40738fc819 100644 --- a/packages/aws-cdk/lib/cli.ts +++ b/packages/aws-cdk/lib/cli.ts @@ -175,6 +175,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise { + CliIoHost.currentAction = command as any; const toolkitStackName: string = ToolkitInfo.determineName(configuration.settings.get(['toolkitStackName'])); debug(`Toolkit stack: ${chalk.bold(toolkitStackName)}`); 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/logging.ts b/packages/aws-cdk/lib/logging.ts index 2df85c308708b..9262e94ea6f4a 100644 --- a/packages/aws-cdk/lib/logging.ts +++ b/packages/aws-cdk/lib/logging.ts @@ -4,7 +4,7 @@ import { IoMessageLevel, IoMessage, CliIoHost, IoMessageSpecificCode, IoMessageC // Corking mechanism let CORK_COUNTER = 0; -const logBuffer: IoMessage[] = []; +const logBuffer: IoMessage[] = []; const levelPriority: Record = { error: 0, @@ -86,12 +86,12 @@ function log(options: LogOptions) { return; } - const ioMessage: IoMessage = { + const ioMessage: IoMessage = { level: options.level, message: options.message, forceStdout: options.forceStdout, time: new Date(), - action: CliIoHost.currentAction ?? 'none', + action: CliIoHost.currentAction, code: options.code, }; diff --git a/packages/aws-cdk/lib/migrator.ts b/packages/aws-cdk/lib/migrator.ts new file mode 100644 index 0000000000000..5bc82061456af --- /dev/null +++ b/packages/aws-cdk/lib/migrator.ts @@ -0,0 +1,87 @@ +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; +} + +type ResourceMigratorOptions = Pick + +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: ResourceMigratorOptions): 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: ResourceMigratorOptions, + ) { + 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/serialize.ts b/packages/aws-cdk/lib/serialize.ts index ea7ea30c3b89c..9f310c95b3f78 100644 --- a/packages/aws-cdk/lib/serialize.ts +++ b/packages/aws-cdk/lib/serialize.ts @@ -33,3 +33,21 @@ export async function loadStructuredFile(fileName: string) { const contents = await fs.readFile(fileName, { encoding: 'utf-8' }); return deserializeStructure(contents); } + +/** + * 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/lib/settings.ts b/packages/aws-cdk/lib/settings.ts index 9c6e680e6c8d8..a14b712f05a3a 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'; @@ -126,6 +126,7 @@ export class Configuration { this._projectConfig = await loadAndLog(PROJECT_CONFIG); this._projectContext = await loadAndLog(PROJECT_CONTEXT); + // @todo cannot currently be disabled by cli users const readUserContext = this.props.readUserContext ?? true; if (userConfig.get(['build'])) { 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/lib/toolkit/cli-io-host.ts b/packages/aws-cdk/lib/toolkit/cli-io-host.ts index 41038734d9c25..d55c394a9efc6 100644 --- a/packages/aws-cdk/lib/toolkit/cli-io-host.ts +++ b/packages/aws-cdk/lib/toolkit/cli-io-host.ts @@ -9,7 +9,7 @@ export type IoMessageCode = IoMessageSpecificCode; * Basic message structure for toolkit notifications. * Messages are emitted by the toolkit and handled by the IoHost. */ -export interface IoMessage { +export interface IoMessage { /** * The time the message was emitted. */ @@ -55,6 +55,18 @@ export interface IoMessage { * @default false */ readonly forceStdout?: boolean; + + /** + * 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 type IoMessageLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'; @@ -62,7 +74,15 @@ export type IoMessageLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'; /** * The current action being performed by the CLI. 'none' represents the absence of an action. */ -export type ToolkitAction = 'synth' | 'list' | 'deploy' | 'destroy' | 'none'; +export type ToolkitAction = +| 'bootstrap' +| 'synth' +| 'list' +| 'diff' +| 'deploy' +| 'rollback' +| 'watch' +| 'destroy'; /** * A simple IO host for the CLI that writes messages to the console. @@ -116,14 +136,14 @@ export class CliIoHost { /** * the current {@link ToolkitAction} set by the CLI. */ - private currentAction: ToolkitAction | undefined; + private currentAction: ToolkitAction = 'synth'; private constructor() { this.isTTY = process.stdout.isTTY ?? false; this.ci = false; } - public static get currentAction(): ToolkitAction | undefined { + public static get currentAction(): ToolkitAction { return CliIoHost.getIoHost().currentAction; } @@ -151,7 +171,7 @@ export class CliIoHost { * Notifies the host of a message. * The caller waits until the notification completes. */ - async notify(msg: IoMessage): Promise { + async notify(msg: IoMessage): Promise { const output = this.formatMessage(msg); const stream = CliIoHost.getStream(msg.level, msg.forceStdout ?? false); @@ -167,10 +187,21 @@ export class CliIoHost { }); } + /** + * 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. + */ + async requestResponse(msg: IoRequest): Promise { + await this.notify(msg); + return msg.defaultResponse; + } + /** * Formats a message for console output with optional color support */ - private formatMessage(msg: IoMessage): string { + 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) diff --git a/packages/aws-cdk/lib/toolkit/error.ts b/packages/aws-cdk/lib/toolkit/error.ts index 3fc628f0555d4..f61a4245e66fb 100644 --- a/packages/aws-cdk/lib/toolkit/error.ts +++ b/packages/aws-cdk/lib/toolkit/error.ts @@ -1,10 +1,11 @@ -const TOOLKIT_ERROR_SYMBOL = Symbol.for('@aws-cdk/core.ToolkitError'); -const AUTHENTICATION_ERROR_SYMBOL = Symbol.for('@aws-cdk/core.AuthenticationError'); +const TOOLKIT_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit.ToolkitError'); +const AUTHENTICATION_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit.AuthenticationError'); +const ASSEMBLY_ERROR_SYMBOL = Symbol.for('@aws-cdk/toolkit.AssemblyError'); /** * Represents a general toolkit error in the AWS CDK Toolkit. */ -class ToolkitError extends Error { +export class ToolkitError extends Error { /** * Determines if a given error is an instance of ToolkitError. */ @@ -19,6 +20,13 @@ class ToolkitError extends Error { return this.isToolkitError(x) && AUTHENTICATION_ERROR_SYMBOL in x; } + /** + * Determines if a given error is an instance of AssemblyError. + */ + public static isAssemblyError(x: any): x is AssemblyError { + return this.isToolkitError(x) && ASSEMBLY_ERROR_SYMBOL in x; + } + /** * The type of the error, defaults to "toolkit". */ @@ -36,7 +44,7 @@ class ToolkitError extends Error { /** * Represents an authentication-specific error in the AWS CDK Toolkit. */ -class AuthenticationError extends ToolkitError { +export class AuthenticationError extends ToolkitError { constructor(message: string) { super(message, 'authentication'); Object.setPrototypeOf(this, AuthenticationError.prototype); @@ -44,5 +52,13 @@ class AuthenticationError extends ToolkitError { } } -// Export classes for internal usage only -export { ToolkitError, AuthenticationError }; +/** + * Represents an authentication-specific error in the AWS CDK Toolkit. + */ +export class AssemblyError extends ToolkitError { + constructor(message: string) { + super(message, 'assembly'); + Object.setPrototypeOf(this, AssemblyError.prototype); + Object.defineProperty(this, ASSEMBLY_ERROR_SYMBOL, { value: true }); + } +} diff --git a/packages/aws-cdk/test/api/cloud-assembly.test.ts b/packages/aws-cdk/test/api/cloud-assembly.test.ts index c97e6f4925d76..3000646a15deb 100644 --- a/packages/aws-cdk/test/api/cloud-assembly.test.ts +++ b/packages/aws-cdk/test/api/cloud-assembly.test.ts @@ -4,33 +4,6 @@ import { DefaultSelection } from '../../lib/api/cxapp/cloud-assembly'; import { MockCloudExecutable } from '../util'; import { cliAssemblyWithForcedVersion } from './assembly-versions'; -test('do not throw when selecting stack without errors', async () => { - // GIVEN - const cxasm = await testCloudAssembly(); - - // WHEN - const selected = await cxasm.selectStacks( { patterns: ['withouterrorsNODEPATH'] }, { - defaultBehavior: DefaultSelection.AllStacks, - }); - selected.processMetadataMessages(); - - // THEN - expect(selected.firstStack.template.resource).toBe('noerrorresource'); -}); - -test('do throw when selecting stack with errors', async () => { - // GIVEN - const cxasm = await testCloudAssembly(); - - // WHEN - const selected = await cxasm.selectStacks({ patterns: ['witherrors'] }, { - defaultBehavior: DefaultSelection.AllStacks, - }); - - // THEN - expect(() => selected.processMetadataMessages()).toThrow(/Found errors/); -}); - test('select all top level stacks in the presence of nested assemblies', async () => { // GIVEN const cxasm = await testNestedCloudAssembly(); @@ -52,7 +25,7 @@ test('select stacks by glob pattern', async () => { const x = await cxasm.selectStacks({ patterns: ['with*'] }, { defaultBehavior: DefaultSelection.AllStacks }); // THEN - expect(x.stackCount).toBe(2); + expect(x.stackCount).toBe(3); expect(x.stackIds).toContain('witherrors'); expect(x.stackIds).toContain('withouterrors'); }); @@ -65,7 +38,7 @@ test('select behavior: all', async () => { const x = await cxasm.selectStacks({ patterns: [] }, { defaultBehavior: DefaultSelection.AllStacks }); // THEN - expect(x.stackCount).toBe(2); + expect(x.stackCount).toBe(3); }); test('select behavior: none', async () => { @@ -186,6 +159,95 @@ test('select behavior with no stacks and default ignore stacks options (false)', .rejects.toThrow('This app contains no stacks'); }); +describe('StackCollection', () => { + test('returns hierarchicalIds', async () => { + // GIVEN + const cxasm = await testNestedCloudAssembly(); + + // WHEN + const x = await cxasm.selectStacks({ allTopLevel: true, patterns: [] }, { defaultBehavior: DefaultSelection.AllStacks }); + + // THEN + expect(x.stackCount).toBe(2); + expect(x.hierarchicalIds).toEqual(['witherrors', 'deeply/hidden/withouterrors']); + }); + + describe('validateMetadata', () => { + test('do not throw when selecting stack without errors', async () => { + // GIVEN + const cxasm = await testCloudAssembly(); + + // WHEN + const selected = await cxasm.selectStacks( { patterns: ['withouterrorsNODEPATH'] }, { + defaultBehavior: DefaultSelection.AllStacks, + }); + await selected.validateMetadata(); + + // THEN + expect(selected.stackCount).toBe(1); + expect(selected.firstStack.template.resource).toBe('noerrorresource'); + }); + + test('do not throw when selecting stack with warnings', async () => { + // GIVEN + const cxasm = await testCloudAssembly(); + + // WHEN + const selected = await cxasm.selectStacks( { patterns: ['withwarns'] }, { + defaultBehavior: DefaultSelection.AllStacks, + }); + await selected.validateMetadata(); + + // THEN + expect(selected.stackCount).toBe(1); + expect(selected.firstStack.template.resource).toBe('warnresource'); + }); + + test('do not throw when selecting stack with errors but errors are ignored', async () => { + // GIVEN + const cxasm = await testCloudAssembly(); + + // WHEN + const selected = await cxasm.selectStacks({ patterns: ['witherrors'] }, { + defaultBehavior: DefaultSelection.AllStacks, + }); + await selected.validateMetadata('none'); + + // THEN + expect(selected.stackCount).toBe(1); + expect(selected.firstStack.template.resource).toBe('errorresource'); + }); + + test('do throw when selecting stack with errors', async () => { + // GIVEN + const cxasm = await testCloudAssembly(); + + // WHEN + const selected = await cxasm.selectStacks({ patterns: ['witherrors'] }, { + defaultBehavior: DefaultSelection.AllStacks, + }); + + // THEN + expect(selected.stackCount).toBe(1); + await expect(async () => selected.validateMetadata()).rejects.toThrow(/Found errors/); + }); + + test('do throw when selecting stack with warnings and we are on strict mode', async () => { + // GIVEN + const cxasm = await testCloudAssembly(); + + // WHEN + const selected = await cxasm.selectStacks( { patterns: ['withwarns'] }, { + defaultBehavior: DefaultSelection.AllStacks, + }); + + // THEN + expect(selected.stackCount).toBe(1); + await expect(async () => selected.validateMetadata('warn')).rejects.toThrow(/Found warnings/); + }); + }); +}); + async function testCloudAssembly({ env }: { env?: string; versionReporting?: boolean } = {}) { const cloudExec = new MockCloudExecutable({ stacks: [{ @@ -206,6 +268,19 @@ async function testCloudAssembly({ env }: { env?: string; versionReporting?: boo }, ], }, + }, + { + stackName: 'withwarns', + env, + template: { resource: 'warnresource' }, + metadata: { + '/resource': [ + { + type: cxschema.ArtifactMetadataEntryType.WARN, + data: 'this is a warning', + }, + ], + }, }], }); 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/serialize.test.ts b/packages/aws-cdk/test/serialize.test.ts index c30e3caec2715..e465f1c8f8630 100644 --- a/packages/aws-cdk/test/serialize.test.ts +++ b/packages/aws-cdk/test/serialize.test.ts @@ -1,5 +1,5 @@ /* eslint-disable import/order */ -import { toYAML } from '../lib/serialize'; +import { toYAML, obscureTemplate } from '../lib/serialize'; describe(toYAML, () => { test('does not wrap lines', () => { @@ -7,3 +7,30 @@ describe(toYAML, () => { expect(toYAML({ longString })).toEqual(`longString: ${longString}\n`); }); }); + +describe(obscureTemplate, () => { + test('removes CheckBootstrapVersion rule only', () => { + const template = { + Rules: { + CheckBootstrapVersion: { Assertions: [{ AssertDescription: 'bootstrap' }] }, + MyOtherRule: { Assertions: [{ AssertDescription: 'other' }] }, + }, + }; + + const obscured = obscureTemplate(template); + expect(obscured).not.toHaveProperty('Rules.CheckBootstrapVersion'); + expect(obscured).toHaveProperty('Rules.MyOtherRule.Assertions.0.AssertDescription', 'other'); + }); + + test('removes all rules when CheckBootstrapVersion is the only rule', () => { + const template = { + Rules: { + CheckBootstrapVersion: { Assertions: [{ AssertDescription: 'bootstrap' }] }, + }, + }; + + const obscured = obscureTemplate(template); + expect(obscured).not.toHaveProperty('Rules.CheckBootstrapVersion'); + expect(obscured).not.toHaveProperty('Rules'); + }); +}); 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/packages/aws-cdk/test/toolkit-error.test.ts b/packages/aws-cdk/test/toolkit-error.test.ts index 1aef772e186a5..5f2ff259300f1 100644 --- a/packages/aws-cdk/test/toolkit-error.test.ts +++ b/packages/aws-cdk/test/toolkit-error.test.ts @@ -1,17 +1,30 @@ -import { AuthenticationError, ToolkitError } from '../lib/toolkit/error'; +import { AssemblyError, AuthenticationError, ToolkitError } from '../lib/toolkit/error'; describe('toolkit error', () => { let toolkitError = new ToolkitError('Test toolkit error'); let authError = new AuthenticationError('Test authentication error'); + let assemblyError = new AssemblyError('Test authentication error'); + test('types are correctly assigned', async () => { expect(toolkitError.type).toBe('toolkit'); expect(authError.type).toBe('authentication'); + expect(assemblyError.type).toBe('assembly'); }); - test('isToolkitError and isAuthenticationError functions work', () => { + test('isToolkitError works', () => { expect(ToolkitError.isToolkitError(toolkitError)).toBe(true); expect(ToolkitError.isToolkitError(authError)).toBe(true); + expect(ToolkitError.isToolkitError(assemblyError)).toBe(true); + }); + + test('isAuthenticationError works', () => { expect(ToolkitError.isAuthenticationError(toolkitError)).toBe(false); expect(ToolkitError.isAuthenticationError(authError)).toBe(true); }); + + test('isAssemblyError works', () => { + expect(ToolkitError.isAssemblyError(assemblyError)).toBe(true); + expect(ToolkitError.isAssemblyError(toolkitError)).toBe(false); + expect(ToolkitError.isAssemblyError(authError)).toBe(false); + }); }); diff --git a/packages/aws-cdk/test/toolkit/cli-io-host.test.ts b/packages/aws-cdk/test/toolkit/cli-io-host.test.ts index 7ea32efcf6c2e..210a9a310a95e 100644 --- a/packages/aws-cdk/test/toolkit/cli-io-host.test.ts +++ b/packages/aws-cdk/test/toolkit/cli-io-host.test.ts @@ -4,7 +4,7 @@ import { CliIoHost, IoMessage } from '../../lib/toolkit/cli-io-host'; describe('CliIoHost', () => { let mockStdout: jest.Mock; let mockStderr: jest.Mock; - let defaultMessage: IoMessage; + let defaultMessage: IoMessage; beforeEach(() => { mockStdout = jest.fn(); @@ -13,7 +13,7 @@ describe('CliIoHost', () => { // Reset singleton state CliIoHost.isTTY = process.stdout.isTTY ?? false; CliIoHost.ci = false; - CliIoHost.currentAction = 'none'; + CliIoHost.currentAction = 'synth'; defaultMessage = { time: new Date('2024-01-01T12:00:00'), @@ -239,4 +239,21 @@ describe('CliIoHost', () => { })).rejects.toThrow('Write failed'); }); }); + + describe('requestResponse', () => { + test('logs messages and returns default', async () => { + CliIoHost.isTTY = true; + const response = await CliIoHost.getIoHost().requestResponse({ + time: new Date(), + level: 'info', + action: 'synth', + code: 'CDK_TOOLKIT_I0001', + message: 'test message', + defaultResponse: 'default response', + }); + + expect(mockStderr).toHaveBeenCalledWith(chalk.white('test message') + '\n'); + expect(response).toBe('default response'); + }); + }); });