diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/diff-formatter.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/diff-formatter.ts index 569d24d32..9f5b50aac 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/diff-formatter.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/diff/diff-formatter.ts @@ -43,7 +43,7 @@ class StringWriteStream extends Writable { /** * Output of formatSecurityDiff */ -export interface FormatSecurityDiffOutput { +interface FormatSecurityDiffOutput { /** * Complete formatted security diff, if it is prompt-worthy */ @@ -53,7 +53,7 @@ export interface FormatSecurityDiffOutput { /** * Output of formatStackDiff */ -export interface FormatStackDiffOutput { +interface FormatStackDiffOutput { /** * Number of stacks with diff changes */ @@ -68,7 +68,7 @@ export interface FormatStackDiffOutput { /** * Props for the Diff Formatter */ -export interface DiffFormatterProps { +interface DiffFormatterProps { /** * Helper for the IoHost class */ @@ -88,7 +88,7 @@ export interface DiffFormatterProps { /** * Properties specific to formatting the security diff */ -export interface FormatSecurityDiffOptions { +interface FormatSecurityDiffOptions { /** * The approval level of the security diff */ @@ -110,7 +110,7 @@ export interface FormatSecurityDiffOptions { /** * PRoperties specific to formatting the stack diff */ -export interface FormatStackDiffOptions { +interface FormatStackDiffOptions { /** * do not filter out AWS::CDK::Metadata or Rules * @@ -326,7 +326,7 @@ function diffRequiresApproval(diff: TemplateDiff, requireApproval: RequireApprov } } -export function buildLogicalToPathMap(stack: cxapi.CloudFormationStackArtifact) { +function buildLogicalToPathMap(stack: cxapi.CloudFormationStackArtifact) { const map: { [id: string]: string } = {}; for (const md of stack.findMetadataByType(cxschema.ArtifactMetadataEntryType.LOGICAL_ID)) { map[md.data as string] = md.path; @@ -334,7 +334,7 @@ export function buildLogicalToPathMap(stack: cxapi.CloudFormationStackArtifact) return map; } -export function logicalIdMapFromTemplate(template: any) { +function logicalIdMapFromTemplate(template: any) { const ret: Record = {}; for (const [logicalId, resource] of Object.entries(template.Resources ?? {})) { @@ -352,7 +352,7 @@ export function logicalIdMapFromTemplate(template: any) { * - AWS::CDK::Metadata resource * - CheckBootstrapVersion Rule */ -export function obscureDiff(diff: TemplateDiff) { +function obscureDiff(diff: TemplateDiff) { if (diff.unknown) { // see https://github.com/aws/aws-cdk/issues/17942 diff.unknown = diff.unknown.filter(change => { diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/index.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/index.ts index 8ef7e1b26..b4b30e2f6 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/index.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/index.ts @@ -4,3 +4,4 @@ export * from './diff'; export * from './io'; export * from './toolkit-error'; export * from './require-approval'; +export * from './resource-import'; diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/diff.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/diff.ts index 82ab65432..4761bb400 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/diff.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/diff.ts @@ -1,3 +1,5 @@ +import type { Duration } from './types'; + /** * Different types of permission related changes in a diff */ @@ -17,3 +19,18 @@ export enum PermissionChangeType { */ NON_BROADENING = 'non-broadening', } + +/** + * Output of the diff command + */ +export interface DiffResult extends Duration { + /** + * Stack diff formatted as a string + */ + readonly formattedStackDiff: string; + + /** + * Security diff formatted as a string + */ + readonly formattedSecurityDiff: string; +} diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/list.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/list.ts index 47cfbd200..abc7a1be4 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/list.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/payloads/list.ts @@ -1,5 +1,5 @@ import type { StackDetails } from './stack-details'; export interface StackDetailsPayload { - stacks: StackDetails[]; + readonly stacks: StackDetails[]; } diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/private/messages.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/private/messages.ts index e952833fb..c055e9947 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/private/messages.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/io/private/messages.ts @@ -1,6 +1,7 @@ import type * as cxapi from '@aws-cdk/cx-api'; import * as make from './message-maker'; import type { SpanDefinition } from './span'; +import type { DiffResult } from '../payloads'; import type { BootstrapEnvironmentProgress } from '../payloads/bootstrap-environment-progress'; import type { MissingContext, UpdatedContext } from '../payloads/context'; import type { BuildAsset, DeployConfirmationRequest, PublishAsset, StackDeployProgress, SuccessfulDeployStackResult } from '../payloads/deploy'; @@ -81,6 +82,16 @@ export const IO = { }), // 4: Diff (4xxx) + CDK_TOOLKIT_I4000: make.trace({ + code: 'CDK_TOOLKIT_I4000', + description: 'Diff stacks is starting', + interface: 'StackSelectionDetails', + }), + CDK_TOOLKIT_I4001: make.info({ + code: 'CDK_TOOLKIT_I4001', + description: 'Output of the diff command', + interface: 'DiffResult', + }), // 5: Deploy & Watch (5xxx) CDK_TOOLKIT_I5000: make.info({ @@ -484,6 +495,9 @@ export const IO = { ////////////////////////////////////////////////////////////////////////////////////////// +/** + * Payload type of the end message must extend Duration + */ export const SPAN = { SYNTH_ASSEMBLY: { name: 'Synthesis', @@ -500,6 +514,11 @@ export const SPAN = { start: IO.CDK_TOOLKIT_I6100, end: IO.CDK_TOOLKIT_I6000, }, + DIFF_STACK: { + name: 'Diff', + start: IO.CDK_TOOLKIT_I4000, + end: IO.CDK_TOOLKIT_I4001, + }, DESTROY_STACK: { name: 'Destroy', start: IO.CDK_TOOLKIT_I7100, diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/resource-import/importer.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/resource-import/importer.ts new file mode 100644 index 000000000..97d0bb00b --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/resource-import/importer.ts @@ -0,0 +1,12 @@ +import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; + +/** + * Removes CDKMetadata and Outputs in the template so that only resources for importing are left. + * @returns template with import resources only + */ +export function removeNonImportResources(stack: CloudFormationStackArtifact) { + const template = stack.template; + delete template.Resources.CDKMetadata; + delete template.Outputs; + return template; +} diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/src/api/resource-import/index.ts b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/resource-import/index.ts new file mode 100644 index 000000000..2014a486e --- /dev/null +++ b/packages/@aws-cdk/tmp-toolkit-helpers/src/api/resource-import/index.ts @@ -0,0 +1 @@ +export * from './importer'; diff --git a/packages/@aws-cdk/tmp-toolkit-helpers/test/api/diff/diff.test.ts b/packages/@aws-cdk/tmp-toolkit-helpers/test/api/diff/diff.test.ts index d8f893307..e57827dee 100644 --- a/packages/@aws-cdk/tmp-toolkit-helpers/test/api/diff/diff.test.ts +++ b/packages/@aws-cdk/tmp-toolkit-helpers/test/api/diff/diff.test.ts @@ -236,7 +236,7 @@ describe('formatSecurityDiff', () => { }); // THEN - expect(result).toEqual({}); + expect(result.formattedDiff).toBeUndefined(); expect(mockIoDefaultMessages.warning).not.toHaveBeenCalled(); }); @@ -320,7 +320,7 @@ describe('formatSecurityDiff', () => { }); // THEN - expect(result).toEqual({}); + expect(result.formattedDiff).toBeUndefined(); expect(mockIoDefaultMessages.warning).not.toHaveBeenCalled(); }); }); diff --git a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md index efbc44a04..e16450181 100644 --- a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md +++ b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md @@ -17,6 +17,8 @@ group: Documents | `CDK_TOOLKIT_I1902` | Successfully deployed stacks | `result` | {@link AssemblyData} | | `CDK_TOOLKIT_I2901` | Provides details on the selected stacks and their dependencies | `result` | {@link StackDetailsPayload} | | `CDK_TOOLKIT_E3900` | Resource import failed | `error` | {@link ErrorPayload} | +| `CDK_TOOLKIT_I4000` | Diff stacks is starting | `trace` | {@link StackSelectionDetails} | +| `CDK_TOOLKIT_I4001` | Output of the diff command | `info` | {@link DiffResult} | | `CDK_TOOLKIT_I5000` | Provides deployment times | `info` | {@link Duration} | | `CDK_TOOLKIT_I5001` | Provides total time in deploy action, including synth and rollback | `info` | {@link Duration} | | `CDK_TOOLKIT_I5002` | Provides time for resource migration | `info` | {@link Duration} | diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/diff/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/diff/index.ts index 327a200ec..60ef58728 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/diff/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/diff/index.ts @@ -11,14 +11,15 @@ export interface CloudFormationDiffOptions { } 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; + // @TODO: add this as a feature + // /** + // * 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 @@ -28,6 +29,13 @@ export interface ChangeSetDiffOptions extends CloudFormationDiffOptions { readonly parameters?: { [name: string]: string | undefined }; } +export interface LocalFileDiffOptions { + /** + * Path to the local file. + */ + readonly path: string; +} + export class DiffMethod { /** * Use a changeset to compute the diff. @@ -54,10 +62,13 @@ export class DiffMethod { }(options); } + /** + * Use a local template file to compute the diff. + */ public static LocalFile(path: string): DiffMethod { return new class extends DiffMethod { public override readonly options: { path: string }; - public constructor(opts: { path: string }) { + public constructor(opts: LocalFileDiffOptions) { super('local-file', opts); this.options = opts; } @@ -66,11 +77,14 @@ export class DiffMethod { private constructor( public readonly method: 'change-set' | 'template-only' | 'local-file', - public readonly options: ChangeSetDiffOptions | CloudFormationDiffOptions | { path: string }, + public readonly options: ChangeSetDiffOptions | CloudFormationDiffOptions | LocalFileDiffOptions, ) { } } +/** + * Optins for the diff method + */ export interface DiffOptions { /** * Select the stacks @@ -78,10 +92,10 @@ export interface DiffOptions { readonly stacks: StackSelector; /** - * The mode to create a stack diff. + * The method 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. + * In this method, 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. @@ -89,9 +103,9 @@ export interface DiffOptions { * Use local-template diff for a fast, local-only diff that doesn't require * any permissions or internet access. * - * @default DiffMode.ChangeSet + * @default DiffMethod.ChangeSet */ - readonly method: DiffMethod; + readonly method?: DiffMethod; /** * Strict diff mode @@ -112,6 +126,8 @@ export interface DiffOptions { * Only include broadened security changes in the diff * * @default false + * + * @deprecated implement in IoHost */ readonly securityOnly?: boolean; } diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts index add78a1be..d78c8b6e5 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts @@ -1,6 +1,7 @@ export * from './bootstrap'; export * from './deploy'; export * from './destroy'; +export * from './diff'; export * from './list'; export * from './rollback'; export * from './synth'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts b/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts index d4290122c..f40a3df12 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/aws-cdk.ts @@ -3,7 +3,7 @@ // APIs export { SdkProvider } from '../../../../aws-cdk/lib/api/aws-auth'; export { Context, PROJECT_CONTEXT } from '../../../../aws-cdk/lib/api/context'; -export { Deployments, type SuccessfulDeployStackResult, type DeployStackOptions, type DeployStackResult } from '../../../../aws-cdk/lib/api/deployments'; +export { createDiffChangeSet, Deployments, type SuccessfulDeployStackResult, type DeployStackOptions, type DeployStackResult } from '../../../../aws-cdk/lib/api/deployments'; export { Settings } from '../../../../aws-cdk/lib/api/settings'; export { type Tag, tagsForStack } from '../../../../aws-cdk/lib/api/tags'; export { DEFAULT_TOOLKIT_STACK_NAME } from '../../../../aws-cdk/lib/api/toolkit-info'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 36610768f..38abf69fb 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -3,6 +3,7 @@ import * as cxapi from '@aws-cdk/cx-api'; import * as chalk from 'chalk'; import * as chokidar from 'chokidar'; import * as fs from 'fs-extra'; +import * as uuid from 'uuid'; import type { ToolkitServices } from './private'; import { assemblyFromSource } from './private'; import type { BootstrapEnvironments, BootstrapOptions, BootstrapResult, EnvironmentBootstrapResult } from '../actions/bootstrap'; @@ -10,6 +11,8 @@ import { BootstrapSource } from '../actions/bootstrap'; import { AssetBuildTime, type DeployOptions } from '../actions/deploy'; import { type ExtendedDeployOptions, buildParameterMap, createHotswapPropertyOverrides, removePublishedAssets } from '../actions/deploy/private'; import { type DestroyOptions } from '../actions/destroy'; +import type { ChangeSetDiffOptions, DiffOptions, LocalFileDiffOptions } from '../actions/diff'; +import { DiffMethod } from '../actions/diff'; import { determinePermissionType } from '../actions/diff/private'; import { type ListOptions } from '../actions/list'; import { type RollbackOptions } from '../actions/rollback'; @@ -18,7 +21,7 @@ import type { WatchOptions } from '../actions/watch'; import { patternsArrayForWatch } from '../actions/watch/private'; import { type SdkConfig } from '../api/aws-auth'; import type { SuccessfulDeployStackResult, StackCollection, Concurrency, AssetBuildNode, AssetPublishNode, StackNode } from '../api/aws-cdk'; -import { DEFAULT_TOOLKIT_STACK_NAME, Bootstrapper, SdkProvider, Deployments, HotswapMode, ResourceMigrator, tagsForStack, CliIoHost, WorkGraphBuilder, CloudWatchLogEventMonitor, findCloudWatchLogGroups } from '../api/aws-cdk'; +import { DEFAULT_TOOLKIT_STACK_NAME, Bootstrapper, SdkProvider, Deployments, HotswapMode, ResourceMigrator, tagsForStack, CliIoHost, WorkGraphBuilder, CloudWatchLogEventMonitor, findCloudWatchLogGroups, createDiffChangeSet } from '../api/aws-cdk'; import type { ICloudAssemblySource } from '../api/cloud-assembly'; import { StackSelectionStrategy } from '../api/cloud-assembly'; import type { StackAssembly } from '../api/cloud-assembly/private'; @@ -28,8 +31,8 @@ import { IO, SPAN, asSdkLogger, withoutColor, withoutEmojis, withTrimmedWhitespa import type { IoHelper } from '../api/shared-private'; import { asIoHelper } from '../api/shared-private'; import type { AssemblyData, StackDetails, ToolkitAction } from '../api/shared-public'; -import { RequireApproval, ToolkitError } from '../api/shared-public'; -import { obscureTemplate, serializeStructure, validateSnsTopicArn, formatTime, formatErrorMessage } from '../private/util'; +import { DiffFormatter, RequireApproval, ToolkitError, removeNonImportResources } from '../api/shared-public'; +import { obscureTemplate, serializeStructure, validateSnsTopicArn, formatTime, formatErrorMessage, deserializeStructure } from '../private/util'; import { pLimit } from '../util/concurrency'; export interface ToolkitOptions { @@ -249,6 +252,148 @@ export class Toolkit extends CloudAssemblySourceBuilder implements AsyncDisposab return new IdentityCloudAssemblySource(assembly.assembly); } + /** + * Diff Action + */ + public async diff(cx: ICloudAssemblySource, options: DiffOptions): Promise { + const ioHelper = asIoHelper(this.ioHost, 'diff'); + const selectStacks = options.stacks ?? ALL_STACKS; + const synthSpan = await ioHelper.span(SPAN.SYNTH_ASSEMBLY).begin({ stacks: selectStacks }); + const assembly = await assemblyFromSource(ioHelper, cx); + const stacks = await assembly.selectStacksV2(selectStacks); + await synthSpan.end(); + + const diffSpan = await ioHelper.span(SPAN.DIFF_STACK).begin({ stacks: selectStacks }); + const deployments = await this.deploymentsForAction('diff'); + + const strict = !!options.strict; + const contextLines = options.contextLines || 3; + const diffMethod = options.method ?? DiffMethod.ChangeSet(); + + let diffs = 0; + let formattedSecurityDiff = ''; + let formattedStackDiff = ''; + + if (diffMethod.method === 'local-file') { + const methodOptions = diffMethod.options as LocalFileDiffOptions; + + // Compare single stack against fixed template + if (stacks.stackCount !== 1) { + throw new ToolkitError( + 'Can only select one stack when comparing to fixed template. Use --exclusively to avoid selecting multiple stacks.', + ); + } + + if (!(await fs.pathExists(methodOptions.path))) { + throw new ToolkitError(`There is no file at ${path}`); + } + + const file = fs.readFileSync(methodOptions.path).toString(); + const template = deserializeStructure(file); + const formatter = new DiffFormatter({ + ioHelper, + oldTemplate: template, + newTemplate: stacks.firstStack, + }); + if (options.securityOnly) { + const securityDiff = formatter.formatSecurityDiff({ + requireApproval: RequireApproval.BROADENING, + }); + formattedSecurityDiff = securityDiff.formattedDiff ?? ''; + diffs = securityDiff.formattedDiff ? diffs + 1 : diffs; + } else { + const diff = formatter.formatStackDiff({ + strict, + context: contextLines, + }); + formattedStackDiff = diff.formattedDiff; + diffs = diff.numStacksWithChanges; + } + } else { + const methodOptions = diffMethod.options as ChangeSetDiffOptions; + // Compare N stacks against deployed templates + for (const stack of stacks.stackArtifacts) { + const templateWithNestedStacks = await deployments.readCurrentTemplateWithNestedStacks( + stack, + methodOptions.compareAgainstProcessedTemplate, + ); + const currentTemplate = templateWithNestedStacks.deployedRootTemplate; + const nestedStacks = templateWithNestedStacks.nestedStacks; + + const formatter = new DiffFormatter({ + ioHelper, + oldTemplate: currentTemplate, + newTemplate: stack, + }); + + const migrator = new ResourceMigrator({ deployments, ioHelper }); + const resourcesToImport = await migrator.tryGetResources(await deployments.resolveEnvironment(stack)); + if (resourcesToImport) { + removeNonImportResources(stack); + } + + let changeSet = undefined; + + if (diffMethod.method === 'change-set') { + let stackExists = false; + try { + stackExists = await deployments.stackExists({ + stack, + deployName: stack.stackName, + tryLookupRole: true, + }); + } catch (e: any) { + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`Checking if the stack ${stack.stackName} exists before creating the changeset has failed, will base the diff on template differences.\n`)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(formatErrorMessage(e))); + stackExists = false; + } + + if (stackExists) { + changeSet = await createDiffChangeSet(ioHelper, { + stack, + uuid: uuid.v4(), + deployments, + willExecute: false, + sdkProvider: await this.sdkProvider('diff'), + parameters: methodOptions.parameters ?? {}, + resourcesToImport, + }); + } else { + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(`the stack '${stack.stackName}' has not been deployed to CloudFormation or describeStacks call failed, skipping changeset creation.`)); + } + } + + if (options.securityOnly) { + const securityDiff = formatter.formatSecurityDiff({ + requireApproval: RequireApproval.BROADENING, + stackName: stack.displayName, + changeSet, + }); + formattedSecurityDiff = securityDiff.formattedDiff ?? ''; + diffs = securityDiff.formattedDiff ? diffs + 1 : diffs; + } else { + const diff = formatter.formatStackDiff({ + strict, + context: contextLines, + stackName: stack.displayName, + changeSet, + isImport: !!resourcesToImport, + nestedStackTemplates: nestedStacks, + }); + formattedStackDiff = diff.formattedDiff; + diffs = diff.numStacksWithChanges; + } + } + } + + await diffSpan.end(`✨ Number of stacks with differences: ${diffs}`, { + formattedSecurityDiff, + formattedStackDiff, + }); + + return; + } + /** * List Action * diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/diff.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/diff.test.ts new file mode 100644 index 000000000..4b590b953 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/actions/diff.test.ts @@ -0,0 +1,264 @@ +import * as path from 'path'; +import { format } from 'util'; +import * as chalk from 'chalk'; +import { RequireApproval } from '../../lib'; +import { DiffMethod } from '../../lib/actions/diff'; +import * as awsCdkApi from '../../lib/api/aws-cdk'; +import { StackSelectionStrategy, Toolkit } from '../../lib/toolkit'; +import { builderFixture, TestIoHost } from '../_helpers'; +import { MockSdk } from '../util/aws-cdk'; + +let ioHost: TestIoHost; +let toolkit: Toolkit; + +beforeEach(() => { + jest.restoreAllMocks(); + ioHost = new TestIoHost(); + ioHost.requireDeployApproval = RequireApproval.NEVER; + + toolkit = new Toolkit({ ioHost }); + const sdk = new MockSdk(); + + // Some default implementations + jest.spyOn(awsCdkApi.Deployments.prototype, 'readCurrentTemplateWithNestedStacks').mockResolvedValue({ + deployedRootTemplate: { + Parameters: {}, + Resources: {}, + }, + nestedStacks: [] as any, + }); + jest.spyOn(awsCdkApi.Deployments.prototype, 'stackExists').mockResolvedValue(true); + jest.spyOn(awsCdkApi.Deployments.prototype, 'resolveEnvironment').mockResolvedValue({ + name: 'aws://123456789012/us-east-1', + account: '123456789012', + region: 'us-east-1', + }); +}); + +describe('diff', () => { + test('sends diff to IoHost', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + await toolkit.diff(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'diff', + level: 'info', + code: 'CDK_TOOLKIT_I4001', + message: expect.stringContaining('✨ Number of stacks with differences: 1'), + data: expect.objectContaining({ + formattedStackDiff: expect.stringContaining((chalk.bold('Stack1'))), + }), + })); + }); + + // TODO: uncomment when diff returns a value + // test('returns diff', async () => { + // // WHEN + // const cx = await builderFixture(toolkit, 'stack-with-bucket'); + // const result = await toolkit.diff(cx, { + // stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + // }); + + // // THEN + // expect(result).toMatchObject(expect.objectContaining({ + // resources: { + // diffs: expect.objectContaining({ + // MyBucketF68F3FF0: expect.objectContaining({ + // isAddition: true, + // isRemoval: false, + // oldValue: undefined, + // newValue: { + // Type: 'AWS::S3::Bucket', + // UpdateReplacePolicy: 'Retain', + // DeletionPolicy: 'Retain', + // Metadata: { 'aws:cdk:path': 'Stack1/MyBucket/Resource' }, + // }, + // }), + // }), + // }, + // })); + // }); + + test('only security diff', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-role'); + await toolkit.diff(cx, { + stacks: { strategy: StackSelectionStrategy.PATTERN_MUST_MATCH_SINGLE, patterns: ['Stack1'] }, + securityOnly: true, + method: DiffMethod.TemplateOnly({ compareAgainstProcessedTemplate: true }), + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'diff', + level: 'warn', + code: 'CDK_TOOLKIT_W0000', + message: expect.stringContaining('This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening)'), + })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'diff', + level: 'info', + code: 'CDK_TOOLKIT_I4001', + message: expect.stringContaining('✨ Number of stacks with differences: 1'), + data: expect.objectContaining({ + formattedSecurityDiff: expect.stringContaining((chalk.underline(chalk.bold('IAM Statement Changes')))), + }), + })); + // TODO: uncomment when diff returns a value + // expect(result).toMatchObject(expect.objectContaining({ + // iamChanges: expect.objectContaining({ + // statements: expect.objectContaining({ + // additions: [expect.objectContaining({ + // actions: expect.objectContaining({ + // not: false, + // values: ['sts:AssumeRole'], + // }), + // condition: undefined, + // effect: 'Allow', + // principals: expect.objectContaining({ + // not: false, + // values: ['AWS:arn'], + // }), + // })], + // removals: [], + // }), + // }), + // })); + }); + + test('no security diff', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await toolkit.diff(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + securityOnly: true, + }); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'diff', + level: 'info', + code: 'CDK_TOOLKIT_I4001', + message: expect.stringContaining('✨ Number of stacks with differences: 0'), + data: expect.objectContaining({ + formattedSecurityDiff: '', + }), + })); + }); + + test('TemplateOnly diff method does not try to find changeSet', async () => { + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + const result = await toolkit.diff(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + method: DiffMethod.TemplateOnly({ compareAgainstProcessedTemplate: true }), + }); + + // THEN + expect(ioHost.notifySpy).not.toHaveBeenCalledWith(expect.objectContaining({ + action: 'diff', + level: 'info', + code: 'CDK_TOOLKIT_I0000', + message: expect.stringContaining('Could not create a change set'), + })); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + action: 'diff', + level: 'info', + code: 'CDK_TOOLKIT_I4001', + message: expect.stringContaining('✨ Number of stacks with differences: 1'), + data: expect.objectContaining({ + formattedStackDiff: expect.stringContaining(chalk.bold('Stack1')), + }), + })); + }); + + describe('templatePath', () => { + test('fails with multiple stacks', async () => { + // WHEN + THEN + const cx = await builderFixture(toolkit, 'two-empty-stacks'); + await expect(async () => toolkit.diff(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + method: DiffMethod.LocalFile(path.join(__dirname, '..', '_fixtures', 'stack-with-bucket', 'cdk.out', 'Stack1.template.json')), + })).rejects.toThrow(/Can only select one stack when comparing to fixed template./); + }); + + test('fails with bad file path', async () => { + // WHEN + THEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + await expect(async () => toolkit.diff(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + method: DiffMethod.LocalFile(path.join(__dirname, 'blah.json')), + })).rejects.toThrow(/There is no file at/); + }); + + // TODO: uncomment when diff returns a value + // test('returns regular diff', async () => { + // // WHEN + // const cx = await builderFixture(toolkit, 'stack-with-bucket'); + // const result = await toolkit.diff(cx, { + // stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + // method: DiffMethod.LocalFile(path.join(__dirname, '..', '_fixtures', 'two-empty-stacks', 'cdk.out', 'Stack1.template.json')), + // }); + + // // THEN + // expect(result).toMatchObject(expect.objectContaining({ + // resources: { + // diffs: expect.objectContaining({ + // MyBucketF68F3FF0: expect.objectContaining({ + // isAddition: true, + // isRemoval: false, + // oldValue: undefined, + // newValue: { + // Type: 'AWS::S3::Bucket', + // UpdateReplacePolicy: 'Retain', + // DeletionPolicy: 'Retain', + // Metadata: { 'aws:cdk:path': 'Stack1/MyBucket/Resource' }, + // }, + // }), + // }), + // }, + // })); + // }); + + // test('returns security diff', async () => { + // // WHEN + // const cx = await builderFixture(toolkit, 'stack-with-role'); + // const result = await toolkit.diff(cx, { + // stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + // securityOnly: true, + // method: DiffMethod.LocalFile(path.join(__dirname, '..', '_fixtures', 'two-empty-stacks', 'cdk.out', 'Stack1.template.json')), + // }); + + // // THEN + // expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + // action: 'diff', + // level: 'warn', + // code: 'CDK_TOOLKIT_W0000', + // message: expect.stringContaining('This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening)'), + // })); + // expect(result).toMatchObject(expect.objectContaining({ + // iamChanges: expect.objectContaining({ + // statements: expect.objectContaining({ + // additions: [expect.objectContaining({ + // actions: expect.objectContaining({ + // not: false, + // values: ['sts:AssumeRole'], + // }), + // condition: undefined, + // effect: 'Allow', + // principals: expect.objectContaining({ + // not: false, + // values: ['AWS:arn'], + // }), + // })], + // removals: [], + // }), + // }), + // })); + // }); + }); +}); diff --git a/packages/aws-cdk/lib/api/deployments/cfn-api.ts b/packages/aws-cdk/lib/api/deployments/cfn-api.ts index a1e64f4f1..ad0f38a04 100644 --- a/packages/aws-cdk/lib/api/deployments/cfn-api.ts +++ b/packages/aws-cdk/lib/api/deployments/cfn-api.ts @@ -240,7 +240,7 @@ async function uploadBodyParameterAndCreateChangeSet( role: executionRoleArn, }); } catch (e: any) { - await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(e)); + await ioHelper.notify(IO.DEFAULT_TOOLKIT_DEBUG.msg(String(e))); await ioHelper.notify(IO.DEFAULT_TOOLKIT_INFO.msg( 'Could not create a change set, will base the diff on template differences (run again with -v to see the reason)\n', )); diff --git a/packages/aws-cdk/lib/api/deployments/index.ts b/packages/aws-cdk/lib/api/deployments/index.ts index d0d1a4a68..24ed4d89f 100644 --- a/packages/aws-cdk/lib/api/deployments/index.ts +++ b/packages/aws-cdk/lib/api/deployments/index.ts @@ -2,3 +2,4 @@ export * from './deployments'; export * from './deployment-result'; export * from './deployment-method'; export * from './asset-manifest-builder'; +export * from './cfn-api'; diff --git a/packages/aws-cdk/lib/api/resource-import/importer.ts b/packages/aws-cdk/lib/api/resource-import/importer.ts index 1f2b3de55..fd3378b77 100644 --- a/packages/aws-cdk/lib/api/resource-import/importer.ts +++ b/packages/aws-cdk/lib/api/resource-import/importer.ts @@ -6,7 +6,7 @@ import type { ResourceIdentifierSummary, ResourceToImport } from '@aws-sdk/clien import * as chalk from 'chalk'; import * as fs from 'fs-extra'; import * as promptly from 'promptly'; -import { ToolkitError } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; +import { ToolkitError, removeNonImportResources } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; import { IO, type IoHelper } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api/io/private'; import type { DeploymentMethod, Deployments } from '../deployments'; import { assertIsSuccessfulDeployStackResult } from '../deployments'; @@ -14,6 +14,7 @@ import type { Tag } from '../tags'; export type ResourcesToImport = ResourceToImport[]; export type ResourceIdentifierSummaries = ResourceIdentifierSummary[]; +export { removeNonImportResources } from '../../../../@aws-cdk/tmp-toolkit-helpers/src/api'; export interface ResourceImporterProps { deployments: Deployments; @@ -443,17 +444,6 @@ export class ResourceImporter { } } -/** - * Removes CDKMetadata and Outputs in the template so that only resources for importing are left. - * @returns template with import resources only - */ -export function removeNonImportResources(stack: cxapi.CloudFormationStackArtifact) { - const template = stack.template; - delete template.Resources.CDKMetadata; - delete template.Outputs; - return template; -} - /** * Information about a resource in the template that is importable */ diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index b8eb06bff..ebacc184c 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -1349,11 +1349,14 @@ function printSerializedObject(obj: any, json: boolean) { logResult(serializeStructure(obj, json)); } +/** + * Options for the diff command + */ export interface DiffOptions { /** * Stack names to diff */ - stackNames: string[]; + readonly stackNames: string[]; /** * Name of the toolkit stack, if not the default name @@ -1367,42 +1370,42 @@ export interface DiffOptions { * * @default false */ - exclusively?: boolean; + readonly exclusively?: boolean; /** * Used a template from disk instead of from the server * * @default Use from the server */ - templatePath?: string; + readonly templatePath?: string; /** * Strict diff mode * * @default false */ - strict?: boolean; + readonly strict?: boolean; /** * How many lines of context to show in the diff * * @default 3 */ - contextLines?: number; + readonly contextLines?: number; /** * Whether to fail with exit code 1 in case of diff * * @default false */ - fail?: boolean; + readonly fail?: boolean; /** * Only run diff on broadened security changes * * @default false */ - securityOnly?: boolean; + readonly securityOnly?: boolean; /** * Whether to run the diff against the template after the CloudFormation Transforms inside it have been executed @@ -1410,27 +1413,27 @@ export interface DiffOptions { * * @default false */ - compareAgainstProcessedTemplate?: boolean; + readonly compareAgainstProcessedTemplate?: boolean; /* * Run diff in quiet mode without printing the diff statuses * * @default false */ - quiet?: boolean; + readonly quiet?: boolean; /** * Additional parameters for CloudFormation at diff time, used to create a change set * @default {} */ - parameters?: { [name: string]: string | undefined }; + readonly parameters?: { [name: string]: string | undefined }; /** * Whether or not to create, analyze, and subsequently delete a changeset * * @default true */ - changeSet?: boolean; + readonly changeSet?: boolean; } interface CfnDeployOptions {