diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js index 269b5cdf3..8735e727c 100755 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js @@ -475,6 +475,33 @@ class LambdaStack extends cdk.Stack { } } +class DriftableStack extends cdk.Stack { + constructor(parent, id, props) { + const synthesizer = parent.node.tryGetContext('legacySynth') === 'true' ? + new LegacyStackSynthesizer({ + fileAssetsBucketName: parent.node.tryGetContext('bootstrapBucket'), + }) + : new DefaultStackSynthesizer({ + fileAssetsBucketName: parent.node.tryGetContext('bootstrapBucket'), + }) + super(parent, id, { + ...props, + synthesizer: synthesizer, + }); + + const fn = new lambda.Function(this, 'my-function', { + code: lambda.Code.asset(path.join(__dirname, 'lambda')), + runtime: lambda.Runtime.NODEJS_LATEST, + handler: 'index.handler', + description: 'This is my function!', + timeout: cdk.Duration.seconds(5), + memorySize: 128 + }); + + new cdk.CfnOutput(this, 'FunctionArn', { value: fn.functionArn }); + } +} + class IamRolesStack extends cdk.Stack { constructor(parent, id, props) { super(parent, id, props); @@ -942,6 +969,8 @@ switch (stackSet) { new BundlingStage(app, `${stackPrefix}-bundling-stage`); new MetadataStack(app, `${stackPrefix}-metadata`); + + new DriftableStack(app, `${stackPrefix}-driftable`); break; case 'stage-using-context': diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift---fail-throws-when-drift-is-detected.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift---fail-throws-when-drift-is-detected.integtest.ts new file mode 100644 index 000000000..68ad5a4bc --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift---fail-throws-when-drift-is-detected.integtest.ts @@ -0,0 +1,77 @@ +import { DescribeStackResourcesCommand } from '@aws-sdk/client-cloudformation'; +import { GetFunctionCommand, UpdateFunctionConfigurationCommand } from '@aws-sdk/client-lambda'; +import { integTest, sleep, withDefaultFixture } from '../../../lib'; + +jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime + +integTest( + 'cdk drift --fail throws when drift is detected', + withDefaultFixture(async (fixture) => { + await fixture.cdkDeploy('driftable', {}); + + // Assert that, right after deploying, there is no drift (because we just deployed it) + const drift = await fixture.cdk(['drift', '--fail', fixture.fullStackName('driftable')], { verbose: false }); + + expect(drift).toContain('No drift detected'); + + // Get the Lambda, we want to now make it drift + const response = await fixture.aws.cloudFormation.send( + new DescribeStackResourcesCommand({ + StackName: fixture.fullStackName('driftable'), + }), + ); + const lambdaResource = response.StackResources?.find( + resource => resource.ResourceType === 'AWS::Lambda::Function', + ); + if (!lambdaResource || !lambdaResource.PhysicalResourceId) { + throw new Error('Could not find Lambda function in stack resources'); + } + const functionName = lambdaResource.PhysicalResourceId; + + // Update the Lambda function, introducing drift + await fixture.aws.lambda.send( + new UpdateFunctionConfigurationCommand({ + FunctionName: functionName, + Description: 'I\'m slowly drifting (drifting away)', + }), + ); + + // Wait for the stack update to complete + await waitForLambdaUpdateComplete(fixture, functionName); + + await expect( + fixture.cdk(['drift', '--fail', fixture.fullStackName('driftable')], { verbose: false }), + ).rejects.toThrow('exited with error'); + }), +); + +async function waitForLambdaUpdateComplete(fixture: any, functionName: string): Promise { + const delaySeconds = 5; + const timeout = 30_000; // timeout after 30s + const deadline = Date.now() + timeout; + + while (true) { + const response = await fixture.aws.lambda.send( + new GetFunctionCommand({ + FunctionName: functionName, + }), + ); + + const lastUpdateStatus = response.Configuration?.LastUpdateStatus; + + if (lastUpdateStatus === 'Successful') { + return; // Update completed successfully + } + + if (lastUpdateStatus === 'Failed') { + throw new Error('Lambda function update failed'); + } + + if (Date.now() > deadline) { + throw new Error(`Timed out after ${timeout / 1000} seconds.`); + } + + // Wait before checking again + await sleep(delaySeconds * 1000); + } +} diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift---verbose-shows-unchecked-resources.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift---verbose-shows-unchecked-resources.integtest.ts new file mode 100644 index 000000000..e56f2befa --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift---verbose-shows-unchecked-resources.integtest.ts @@ -0,0 +1,19 @@ +import { integTest, withDefaultFixture } from '../../../lib'; + +jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime + +integTest( + 'cdk drift --verbose shows unchecked resources', + withDefaultFixture(async (fixture) => { + await fixture.cdkDeploy('define-vpc', { modEnv: { ENABLE_VPC_TESTING: 'DEFINE' } }); + + // Assert that there's no drift when we deploy it, but there should be + // unchecked resources, as there are some EC2 connection resources + // (e.g. SubnetRouteTableAssociation) that do not support drift detection + const drift = await fixture.cdk(['drift', '--verbose', fixture.fullStackName('define-vpc')], { modEnv: { ENABLE_VPC_TESTING: 'DEFINE' } }); + + expect(drift).toMatch(/Stack.*define-vpc/); // cant just .toContain because of formatting + expect(drift).toContain('No drift detected'); + expect(drift).toContain('(3 unchecked)'); // 2 SubnetRouteTableAssociations, 1 VPCGatewayAttachment + }), +); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift.integtest.ts new file mode 100644 index 000000000..edfc8384b --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/drift/cdk-cdk-drift.integtest.ts @@ -0,0 +1,96 @@ +import { DescribeStackResourcesCommand } from '@aws-sdk/client-cloudformation'; +import { GetFunctionCommand, UpdateFunctionConfigurationCommand } from '@aws-sdk/client-lambda'; +import { integTest, sleep, withDefaultFixture } from '../../../lib'; + +jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime + +integTest( + 'cdk drift', + withDefaultFixture(async (fixture) => { + await fixture.cdkDeploy('driftable', {}); + + // Assert that, right after deploying, there is no drift (because we just deployed it) + const drift = await fixture.cdk(['drift', fixture.fullStackName('driftable')], { verbose: false }); + + expect(drift).toMatch(/Stack.*driftable/); // can't just .toContain because of formatting + expect(drift).toContain('No drift detected'); + expect(drift).toContain('✨ Number of resources with drift: 0'); + expect(drift).not.toContain('unchecked'); // should not see unchecked resources unless verbose + + // Get the Lambda, we want to now make it drift + const response = await fixture.aws.cloudFormation.send( + new DescribeStackResourcesCommand({ + StackName: fixture.fullStackName('driftable'), + }), + ); + const lambdaResource = response.StackResources?.find( + resource => resource.ResourceType === 'AWS::Lambda::Function', + ); + if (!lambdaResource || !lambdaResource.PhysicalResourceId) { + throw new Error('Could not find Lambda function in stack resources'); + } + const functionName = lambdaResource.PhysicalResourceId; + + // Update the Lambda function, introducing drift + await fixture.aws.lambda.send( + new UpdateFunctionConfigurationCommand({ + FunctionName: functionName, + Description: 'I\'m slowly drifting (drifting away)', + }), + ); + + // Wait for the stack update to complete + await waitForLambdaUpdateComplete(fixture, functionName); + + const driftAfterModification = await fixture.cdk(['drift', fixture.fullStackName('driftable')], { verbose: false }); + + const expectedMatches = [ + /Stack.*driftable/, + /[-].*This is my function!/m, + /[+].*I'm slowly drifting \(drifting away\)/m, + ]; + const expectedSubstrings = [ + '1 resource has drifted', // num resources drifted + '✨ Number of resources with drift: 1', + 'AWS::Lambda::Function', // the lambda should be marked drifted + '/Description', // the resources that have drifted + ]; + for (const expectedMatch of expectedMatches) { + expect(driftAfterModification).toMatch(expectedMatch); + } + for (const expectedSubstring of expectedSubstrings) { + expect(driftAfterModification).toContain(expectedSubstring); + } + }), +); + +async function waitForLambdaUpdateComplete(fixture: any, functionName: string): Promise { + const delaySeconds = 5; + const timeout = 30_000; // timeout after 30s + const deadline = Date.now() + timeout; + + while (true) { + const response = await fixture.aws.lambda.send( + new GetFunctionCommand({ + FunctionName: functionName, + }), + ); + + const lastUpdateStatus = response.Configuration?.LastUpdateStatus; + + if (lastUpdateStatus === 'Successful') { + return; // Update completed successfully + } + + if (lastUpdateStatus === 'Failed') { + throw new Error('Lambda function update failed'); + } + + if (Date.now() > deadline) { + throw new Error(`Timed out after ${timeout / 1000} seconds.`); + } + + // Wait before checking again + await sleep(delaySeconds * 1000); + } +} diff --git a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md index b44b823ce..f58ac233b 100644 --- a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md +++ b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md @@ -72,6 +72,8 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu | `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_I4590` | Results of the drift command | `result` | {@link DriftResultPayload} | +| `CDK_TOOLKIT_I4591` | Missing drift result fort a stack. | `warn` | {@link SingleStack} | | `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/drift/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/drift/index.ts new file mode 100644 index 000000000..cf113c8df --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/drift/index.ts @@ -0,0 +1,58 @@ +import type { StackSelector } from '../../api/cloud-assembly'; + +export interface DriftOptions { + /** + * Criteria for selecting stacks to check for drift + */ + readonly stacks: StackSelector; +} + +/** + * The different types of drift as formatted drift output + * + * A missing type implies no drift of this type. + * If no drift was detected at all, all will be missing. + */ +export interface FormattedDrift { + /** + * Resources that have not changed + */ + readonly unchanged?: string; + + /** + * Resources that were not checked for drift + */ + readonly unchecked?: string; + + /** + * Resources with drift + */ + readonly modified?: string; + + /** + * Resources that have been deleted (drift) + */ + readonly deleted?: string; +} + +/** + * Combined drift for selected stacks of the app + */ +export interface DriftResult { + /** + * Number of resources with drift. If undefined, then an error occurred + * and resources were not properly checked for drift. + */ + readonly numResourcesWithDrift: number; + + /** + * How many resources were not checked for drift. If undefined, then an + * error occurred and resources were not properly checked for drift. + */ + readonly numResourcesUnchecked: number; + + /** + * Complete formatted drift + */ + readonly formattedDrift: FormattedDrift; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts index d1cf7672e..b82900894 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/index.ts @@ -2,6 +2,7 @@ export * from './bootstrap'; export * from './deploy'; export * from './destroy'; export * from './diff'; +export * from './drift'; export * from './list'; export * from './refactor'; export * from './rollback'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts b/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts index 1e73e4581..ea60eacef 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts @@ -89,6 +89,14 @@ import type { UpdateTerminationProtectionCommandInput, UpdateTerminationProtectionCommandOutput, StackSummary, + DescribeStackDriftDetectionStatusCommandInput, + DescribeStackDriftDetectionStatusCommandOutput, + DescribeStackResourceDriftsCommandOutput, + DetectStackDriftCommandInput, + DetectStackDriftCommandOutput, + DetectStackResourceDriftCommandInput, + DetectStackResourceDriftCommandOutput, + DescribeStackResourceDriftsCommandInput, } from '@aws-sdk/client-cloudformation'; import { paginateListStacks, @@ -120,6 +128,10 @@ import { StartResourceScanCommand, UpdateStackCommand, UpdateTerminationProtectionCommand, + DescribeStackDriftDetectionStatusCommand, + DescribeStackResourceDriftsCommand, + DetectStackDriftCommand, + DetectStackResourceDriftCommand, } from '@aws-sdk/client-cloudformation'; import type { FilterLogEventsCommandInput, @@ -419,8 +431,12 @@ export interface ICloudFormationClient { input: DescribeGeneratedTemplateCommandInput, ): Promise; describeResourceScan(input: DescribeResourceScanCommandInput): Promise; + describeStackDriftDetectionStatus(input: DescribeStackDriftDetectionStatusCommandInput): Promise; describeStacks(input: DescribeStacksCommandInput): Promise; + describeStackResourceDrifts(input: DescribeStackResourceDriftsCommandInput): Promise; describeStackResources(input: DescribeStackResourcesCommandInput): Promise; + detectStackDrift(input: DetectStackDriftCommandInput): Promise; + detectStackResourceDrift(input: DetectStackResourceDriftCommandInput): Promise; executeChangeSet(input: ExecuteChangeSetCommandInput): Promise; getGeneratedTemplate(input: GetGeneratedTemplateCommandInput): Promise; getTemplate(input: GetTemplateCommandInput): Promise; @@ -681,6 +697,10 @@ export class SDK { ): Promise => client.send(new DeleteGeneratedTemplateCommand(input)), deleteStack: (input: DeleteStackCommandInput): Promise => client.send(new DeleteStackCommand(input)), + detectStackDrift: (input: DetectStackDriftCommandInput): Promise => + client.send(new DetectStackDriftCommand(input)), + detectStackResourceDrift: (input: DetectStackResourceDriftCommandInput): Promise => + client.send(new DetectStackResourceDriftCommand(input)), describeChangeSet: (input: DescribeChangeSetCommandInput): Promise => client.send(new DescribeChangeSetCommand(input)), describeGeneratedTemplate: ( @@ -688,6 +708,10 @@ export class SDK { ): Promise => client.send(new DescribeGeneratedTemplateCommand(input)), describeResourceScan: (input: DescribeResourceScanCommandInput): Promise => client.send(new DescribeResourceScanCommand(input)), + describeStackDriftDetectionStatus: (input: DescribeStackDriftDetectionStatusCommandInput): + Promise => client.send(new DescribeStackDriftDetectionStatusCommand(input)), + describeStackResourceDrifts: (input: DescribeStackResourceDriftsCommandInput): Promise => + client.send(new DescribeStackResourceDriftsCommand(input)), describeStacks: (input: DescribeStacksCommandInput): Promise => client.send(new DescribeStacksCommand(input)), describeStackResources: (input: DescribeStackResourcesCommandInput): Promise => diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/diff/diff-formatter.ts b/packages/@aws-cdk/toolkit-lib/lib/api/diff/diff-formatter.ts index 78cfee280..87023fd60 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/diff/diff-formatter.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/diff/diff-formatter.ts @@ -56,7 +56,7 @@ interface DiffFormatterProps { } /** - * PRoperties specific to formatting the stack diff + * Properties specific to formatting the stack diff */ interface FormatStackDiffOptions { /** diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/drift/drift-formatter.ts b/packages/@aws-cdk/toolkit-lib/lib/api/drift/drift-formatter.ts new file mode 100644 index 000000000..2f61a1bfb --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/drift/drift-formatter.ts @@ -0,0 +1,289 @@ +import { format } from 'node:util'; +import * as cxschema from '@aws-cdk/cloud-assembly-schema'; +import { Difference } from '@aws-cdk/cloudformation-diff'; +import type * as cxapi from '@aws-cdk/cx-api'; +import type { StackResourceDrift } from '@aws-sdk/client-cloudformation'; +import { StackResourceDriftStatus } from '@aws-sdk/client-cloudformation'; +import * as chalk from 'chalk'; +import type { FormattedDrift } from '../../actions/drift'; + +/** + * Props for the Drift Formatter + */ +export interface DriftFormatterProps { + /** + * The CloudFormation stack artifact + */ + readonly stack: cxapi.CloudFormationStackArtifact; + + /** + * The results of stack drift detection + */ + readonly resourceDrifts: StackResourceDrift[]; +} + +interface DriftFormatterOutput { + /** + * Number of resources with drift. If undefined, then an error occurred + * and resources were not properly checked for drift. + */ + readonly numResourcesWithDrift: number; + + /** + * How many resources were not checked for drift. If undefined, then an + * error occurred and resources were not properly checked for drift. + */ + readonly numResourcesUnchecked: number; + + /** + * Resources that have not changed + */ + readonly unchanged?: string; + + /** + * Resources that were not checked for drift + */ + readonly unchecked?: string; + + /** + * Resources with drift + */ + readonly modified?: string; + + /** + * Resources that have been deleted (drift) + */ + readonly deleted?: string; + + /** + * The header, containing the stack name + */ + readonly stackHeader: string; + + /** + * The final results (summary) of the drift results + */ + readonly summary: string; +} + +/** + * Class for formatting drift detection output + */ +export class DriftFormatter { + public readonly stackName: string; + + private readonly stack: cxapi.CloudFormationStackArtifact; + private readonly resourceDriftResults: StackResourceDrift[]; + private readonly allStackResources: Map; + + constructor(props: DriftFormatterProps) { + this.stack = props.stack; + this.stackName = props.stack.displayName ?? props.stack.stackName; + this.resourceDriftResults = props.resourceDrifts; + + this.allStackResources = new Map(); + Object.keys(this.stack.template.Resources || {}).forEach(id => { + const resource = this.stack.template.Resources[id]; + // always ignore the metadata resource + if (resource.Type === 'AWS::CDK::Metadata') { + return; + } + this.allStackResources.set(id, resource.Type); + }); + } + + /** + * Format the stack drift detection results + */ + public formatStackDrift(): DriftFormatterOutput { + const formatterOutput = this.formatStackDriftChanges(this.buildLogicalToPathMap()); + + // we are only interested in actual drifts and always ignore the metadata resource + const actualDrifts = this.resourceDriftResults.filter(d => + d.StackResourceDriftStatus === 'MODIFIED' || + d.StackResourceDriftStatus === 'DELETED' || + d.ResourceType === 'AWS::CDK::Metadata', + ); + + // must output the stack name if there are drifts + const stackHeader = format(`Stack ${chalk.bold(this.stackName)}\n`); + + if (actualDrifts.length === 0) { + const finalResult = chalk.green('No drift detected\n'); + return { + numResourcesWithDrift: 0, + numResourcesUnchecked: this.allStackResources.size - this.resourceDriftResults.length, + stackHeader, + summary: finalResult, + }; + } + + const finalResult = chalk.yellow(`\n${actualDrifts.length} resource${actualDrifts.length === 1 ? '' : 's'} ${actualDrifts.length === 1 ? 'has' : 'have'} drifted from their expected configuration\n`); + return { + numResourcesWithDrift: actualDrifts.length, + numResourcesUnchecked: this.allStackResources.size - this.resourceDriftResults.length, + stackHeader, + unchanged: formatterOutput.unchanged, + unchecked: formatterOutput.unchecked, + modified: formatterOutput.modified, + deleted: formatterOutput.deleted, + summary: finalResult, + }; + } + + private buildLogicalToPathMap() { + const map: { [id: string]: string } = {}; + for (const md of this.stack.findMetadataByType(cxschema.ArtifactMetadataEntryType.LOGICAL_ID)) { + map[md.data as string] = md.path; + } + return map; + } + + /** + * Renders stack drift information to the given stream + * + * @param driftResults The stack resource drifts from CloudFormation + * @param allStackResources A map of all stack resources + * @param verbose Whether to output more verbose text (include undrifted resources) + * @param logicalToPathMap A map from logical ID to construct path + */ + private formatStackDriftChanges( + logicalToPathMap: { [logicalId: string]: string } = {}): FormattedDrift { + if (this.resourceDriftResults.length === 0) { + return {}; + } + + let unchanged; + let unchecked; + let modified; + let deleted; + + const drifts = this.resourceDriftResults; + + // Process unchanged resources + const unchangedResources = drifts.filter(d => d.StackResourceDriftStatus === StackResourceDriftStatus.IN_SYNC); + if (unchangedResources.length > 0) { + unchanged = this.printSectionHeader('Resources In Sync'); + + for (const drift of unchangedResources) { + if (!drift.LogicalResourceId || !drift.ResourceType) continue; + unchanged += `${CONTEXT} ${this.formatValue(drift.ResourceType, chalk.cyan)} ${this.formatLogicalId(logicalToPathMap, drift.LogicalResourceId)}\n`; + } + unchanged += this.printSectionFooter(); + } + + // Process all unchecked resources + if (this.allStackResources) { + const uncheckedResources = Array.from(this.allStackResources.keys()).filter((logicalId) => { + return !drifts.find((drift) => drift.LogicalResourceId === logicalId); + }); + if (uncheckedResources.length > 0) { + unchecked = this.printSectionHeader('Unchecked Resources'); + for (const logicalId of uncheckedResources) { + const resourceType = this.allStackResources.get(logicalId); + unchecked += `${CONTEXT} ${this.formatValue(resourceType, chalk.cyan)} ${this.formatLogicalId(logicalToPathMap, logicalId)}\n`; + } + unchecked += this.printSectionFooter(); + } + } + + // Process modified resources + const modifiedResources = drifts.filter(d => d.StackResourceDriftStatus === StackResourceDriftStatus.MODIFIED); + if (modifiedResources.length > 0) { + modified = this.printSectionHeader('Modified Resources'); + + for (const drift of modifiedResources) { + if (!drift.LogicalResourceId || !drift.ResourceType) continue; + if (modified === undefined) modified = ''; + modified += `${UPDATE} ${this.formatValue(drift.ResourceType, chalk.cyan)} ${this.formatLogicalId(logicalToPathMap, drift.LogicalResourceId)}\n`; + if (drift.PropertyDifferences) { + const propDiffs = drift.PropertyDifferences; + for (let i = 0; i < propDiffs.length; i++) { + const diff = propDiffs[i]; + if (!diff.PropertyPath) continue; + const difference = new Difference(diff.ExpectedValue, diff.ActualValue); + modified += this.formatTreeDiff(diff.PropertyPath, difference, i === propDiffs.length - 1); + } + } + } + modified += this.printSectionFooter(); + } + + // Process deleted resources + const deletedResources = drifts.filter(d => d.StackResourceDriftStatus === StackResourceDriftStatus.DELETED); + if (deletedResources.length > 0) { + deleted = this.printSectionHeader('Deleted Resources'); + for (const drift of deletedResources) { + if (!drift.LogicalResourceId || !drift.ResourceType) continue; + deleted += `${REMOVAL} ${this.formatValue(drift.ResourceType, chalk.cyan)} ${this.formatLogicalId(logicalToPathMap, drift.LogicalResourceId)}\n`; + } + deleted += this.printSectionFooter(); + } + + return { unchanged, unchecked, modified, deleted }; + } + + private formatLogicalId(logicalToPathMap: { [logicalId: string]: string }, logicalId: string): string { + const path = logicalToPathMap[logicalId]; + if (!path) return logicalId; + + let normalizedPath = path; + if (normalizedPath.startsWith('/')) { + normalizedPath = normalizedPath.slice(1); + } + + let parts = normalizedPath.split('/'); + if (parts.length > 1) { + parts = parts.slice(1); + + // remove the last component if it's "Resource" or "Default" (if we have more than a single component) + if (parts.length > 1) { + const last = parts[parts.length - 1]; + if (last === 'Resource' || last === 'Default') { + parts = parts.slice(0, parts.length - 1); + } + } + + normalizedPath = parts.join('/'); + } + + return `${normalizedPath} ${chalk.gray(logicalId)}`; + } + + private formatValue(value: any, colorFn: (str: string) => string): string { + if (value == null) { + return ''; + } + if (typeof value === 'string') { + return colorFn(value); + } + return colorFn(JSON.stringify(value)); + } + + private printSectionHeader(title: string): string { + return `${chalk.underline(chalk.bold(title))}\n`; + } + + private printSectionFooter(): string { + return '\n'; + } + + private formatTreeDiff(propertyPath: string, difference: Difference, isLast: boolean): string { + let result = format(' %s─ %s %s\n', isLast ? '└' : '├', + difference.isAddition ? ADDITION : + difference.isRemoval ? REMOVAL : + UPDATE, + propertyPath, + ); + if (difference.isUpdate) { + result += format(' ├─ %s %s\n', REMOVAL, this.formatValue(difference.oldValue, chalk.red)); + result += format(' └─ %s %s\n', ADDITION, this.formatValue(difference.newValue, chalk.green)); + } + return result; + } +} + +const ADDITION = chalk.green('[+]'); +const CONTEXT = chalk.grey('[ ]'); +const UPDATE = chalk.yellow('[~]'); +const REMOVAL = chalk.red('[-]'); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/drift/drift.ts b/packages/@aws-cdk/toolkit-lib/lib/api/drift/drift.ts new file mode 100644 index 000000000..fa6a4ee54 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/drift/drift.ts @@ -0,0 +1,87 @@ +import { format } from 'util'; +import type { DescribeStackDriftDetectionStatusCommandOutput, DescribeStackResourceDriftsCommandOutput } from '@aws-sdk/client-cloudformation'; +import { ToolkitError } from '../../toolkit/toolkit-error'; +import type { ICloudFormationClient } from '../aws-auth/private'; +import type { IoHelper } from '../io/private'; + +/** + * Detect drift for a CloudFormation stack and wait for the detection to complete + * + * @param cfn - a CloudFormation client + * @param ioHelper - helper for IO operations + * @param stackName - the name of the stack to check for drift + * @returns the CloudFormation description of the drift detection results + */ +export async function detectStackDrift( + cfn: ICloudFormationClient, + ioHelper: IoHelper, + stackName: string, +): Promise { + // Start drift detection + const driftDetection = await cfn.detectStackDrift({ + StackName: stackName, + }); + + await ioHelper.defaults.trace( + format('Detecting drift with ID %s for stack %s...', driftDetection.StackDriftDetectionId, stackName), + ); + + // Wait for drift detection to complete + const driftStatus = await waitForDriftDetection(cfn, ioHelper, driftDetection.StackDriftDetectionId!); + + if (!driftStatus) { + throw new ToolkitError('Drift detection took too long to complete. Aborting'); + } + + if (driftStatus?.DetectionStatus === 'DETECTION_FAILED') { + throw new ToolkitError( + `Failed to detect drift: ${driftStatus.DetectionStatusReason || 'No reason provided'}`, + ); + } + + // Get the drift results + return cfn.describeStackResourceDrifts({ + StackName: stackName, + }); +} + +/** + * Wait for a drift detection operation to complete + */ +async function waitForDriftDetection( + cfn: ICloudFormationClient, + ioHelper: IoHelper, + driftDetectionId: string, +): Promise { + const maxWaitForDrift = 300_000; // if takes longer than 5min, fail + const timeBetweenOutputs = 10_000; // how long to wait before telling user we're still checking + const timeBetweenApiCalls = 2_000; // wait 2s per API call + const deadline = Date.now() + maxWaitForDrift; + let checkIn = Date.now() + timeBetweenOutputs; + + while (true) { + const response = await cfn.describeStackDriftDetectionStatus({ + StackDriftDetectionId: driftDetectionId, + }); + + if (response.DetectionStatus === 'DETECTION_COMPLETE') { + return response; + } + + if (response.DetectionStatus === 'DETECTION_FAILED') { + throw new ToolkitError(`Drift detection failed: ${response.DetectionStatusReason}`); + } + + if (Date.now() > deadline) { + throw new ToolkitError(`Drift detection failed: Timed out after ${maxWaitForDrift / 1000} seconds.`); + } + + if (Date.now() > checkIn) { + await ioHelper.defaults.trace('Waiting for drift detection to complete...'); + checkIn = Date.now() + timeBetweenOutputs; + } + + // Wait a short while between API calls so we don't create a flood + await new Promise(resolve => setTimeout(resolve, timeBetweenApiCalls)); + } +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/drift/index.ts b/packages/@aws-cdk/toolkit-lib/lib/api/drift/index.ts new file mode 100644 index 000000000..f45cb346c --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/drift/index.ts @@ -0,0 +1,2 @@ +export * from './drift-formatter'; +export * from './drift'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/index.ts b/packages/@aws-cdk/toolkit-lib/lib/api/index.ts index 2ef16dc9f..dc221c8d7 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/index.ts @@ -21,3 +21,4 @@ export * from './toolkit-info'; export * from './work-graph'; export * from './tree'; export * from './tags'; +export * from './drift'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts index e9f0346f9..a1aca41c1 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts @@ -6,6 +6,7 @@ import type { BootstrapEnvironmentProgress } from '../../../payloads/bootstrap-e import type { MissingContext, UpdatedContext } from '../../../payloads/context'; import type { BuildAsset, DeployConfirmationRequest, PublishAsset, StackDeployProgress, SuccessfulDeployStackResult } from '../../../payloads/deploy'; import type { StackDestroy, StackDestroyProgress } from '../../../payloads/destroy'; +import type { DriftResultPayload } from '../../../payloads/drift'; import type { AssetBatchDeletionRequest } from '../../../payloads/gc'; import type { HotswapDeploymentDetails, HotswapDeploymentAttempt, HotswappableChange, HotswapResult } from '../../../payloads/hotswap'; import type { ResourceIdentificationRequest, ResourceImportRequest } from '../../../payloads/import'; @@ -16,7 +17,7 @@ import type { StackRollbackProgress } from '../../../payloads/rollback'; import type { MfaTokenRequest, SdkTrace } from '../../../payloads/sdk'; import type { StackActivity, StackMonitoringControlEvent } from '../../../payloads/stack-activity'; import type { StackSelectionDetails } from '../../../payloads/synth'; -import type { AssemblyData, ConfirmationRequest, ContextProviderMessageSource, Duration, ErrorPayload, StackAndAssemblyData } from '../../../payloads/types'; +import type { AssemblyData, ConfirmationRequest, ContextProviderMessageSource, Duration, ErrorPayload, SingleStack, StackAndAssemblyData } from '../../../payloads/types'; import type { FileWatchEvent, WatchSettings } from '../../../payloads/watch'; /** @@ -78,7 +79,7 @@ export const IO = { interface: 'ErrorPayload', }), - // 4: Diff (4xxx) + // 4: Diff (40xx - 44xx) CDK_TOOLKIT_I4000: make.trace({ code: 'CDK_TOOLKIT_I4000', description: 'Diff stacks is starting', @@ -90,6 +91,18 @@ export const IO = { interface: 'DiffResult', }), + // 4: Drift (45xx - 49xx) + CDK_TOOLKIT_I4590: make.result({ + code: 'CDK_TOOLKIT_I4590', + description: 'Results of the drift command', + interface: 'DriftResultPayload', + }), + CDK_TOOLKIT_I4591: make.warn({ + code: 'CDK_TOOLKIT_I4591', + description: 'Missing drift result fort a stack.', + interface: 'SingleStack', + }), + // 5: Deploy & Watch (5xxx) CDK_TOOLKIT_I5000: make.info({ code: 'CDK_TOOLKIT_I5000', diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts index c08669bcf..b477f4971 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/toolkit-action.ts @@ -8,6 +8,7 @@ export type ToolkitAction = | 'list' | 'diff' | 'deploy' +| 'drift' | 'rollback' | 'watch' | 'destroy' diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/deploy.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/deploy.ts index 77f65f932..a7351d197 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/payloads/deploy.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/deploy.ts @@ -1,5 +1,5 @@ import type { TemplateDiff } from '@aws-cdk/cloudformation-diff'; -import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; +import type * as cxapi from '@aws-cdk/cx-api'; import type { IManifestEntry } from 'cdk-assets'; import type { PermissionChangeType } from './diff'; import type { ConfirmationRequest } from './types'; @@ -21,7 +21,7 @@ export interface StackDeployProgress { /** * The stack that's currently being deployed */ - readonly stack: CloudFormationStackArtifact; + readonly stack: cxapi.CloudFormationStackArtifact; } /** diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/destroy.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/destroy.ts index ebf1ec098..23862d7a7 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/payloads/destroy.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/destroy.ts @@ -1,10 +1,10 @@ -import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; +import type * as cxapi from '@aws-cdk/cx-api'; export interface StackDestroy { /** * The stacks that will be destroyed */ - readonly stacks: CloudFormationStackArtifact[]; + readonly stacks: cxapi.CloudFormationStackArtifact[]; } export interface StackDestroyProgress { @@ -21,5 +21,5 @@ export interface StackDestroyProgress { /** * The stack that's currently being destroyed */ - readonly stack: CloudFormationStackArtifact; + readonly stack: cxapi.CloudFormationStackArtifact; } diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/drift.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/drift.ts new file mode 100644 index 000000000..247e52944 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/drift.ts @@ -0,0 +1,14 @@ +import type * as cxapi from '@aws-cdk/cx-api'; +import type { DriftResult } from '../actions'; + +export interface DriftResultPayload { + /** + * The stack that's currently being checked for drift + */ + readonly stack: cxapi.CloudFormationStackArtifact; + + /** + * The drift result for this stack specifically + */ + readonly drift: DriftResult; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts index d4c221bbb..f2428cc42 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/index.ts @@ -1,6 +1,7 @@ export * from './bootstrap-environment-progress'; export * from './deploy'; export * from './destroy'; +export * from './drift'; export * from './list'; export * from './sdk'; export * from './context'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/rollback.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/rollback.ts index 1a97bd1d7..a7c6d2285 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/payloads/rollback.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/rollback.ts @@ -1,4 +1,4 @@ -import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; +import type * as cxapi from '@aws-cdk/cx-api'; export interface StackRollbackProgress { /** @@ -14,5 +14,5 @@ export interface StackRollbackProgress { /** * The stack that's currently being rolled back */ - readonly stack: CloudFormationStackArtifact; + readonly stack: cxapi.CloudFormationStackArtifact; } diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/stack-activity.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/stack-activity.ts index 247a7fa39..448188db8 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/payloads/stack-activity.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/stack-activity.ts @@ -1,4 +1,4 @@ -import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api'; +import type * as cxapi from '@aws-cdk/cx-api'; import type { StackEvent } from '@aws-sdk/client-cloudformation'; import type { StackProgress } from './progress'; import type { ResourceMetadata } from '../api/resource-metadata/resource-metadata'; @@ -17,7 +17,7 @@ export interface StackMonitoringControlEvent { /** * The stack artifact that is getting deployed */ - readonly stack: CloudFormationStackArtifact; + readonly stack: cxapi.CloudFormationStackArtifact; /** * The name of the Stack that is getting deployed diff --git a/packages/@aws-cdk/toolkit-lib/lib/payloads/types.ts b/packages/@aws-cdk/toolkit-lib/lib/payloads/types.ts index 4873e94bc..94332d560 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/payloads/types.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/payloads/types.ts @@ -1,3 +1,5 @@ +import type * as cxapi from '@aws-cdk/cx-api'; + /** * Assembly data returned in the payload of an IO Message. */ @@ -58,6 +60,16 @@ export interface StackAndAssemblyData extends AssemblyData { readonly stack: StackData; } +/** + * A payload identifying a single stacks + */ +export interface SingleStack { + /** + * A single stack + */ + readonly stack: cxapi.CloudFormationStackArtifact; +} + /** * Duration information returned in the payload of an IO Message. */ diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index a8e1072ca..a8f43408b 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -26,6 +26,7 @@ import { import { type DestroyOptions } from '../actions/destroy'; import type { DiffOptions } from '../actions/diff'; import { appendObject, prepareDiff } from '../actions/diff/private'; +import type { DriftOptions, DriftResult } from '../actions/drift'; import { type ListOptions } from '../actions/list'; import type { MappingGroup, RefactorOptions } from '../actions/refactor'; import { type RollbackOptions } from '../actions/rollback'; @@ -45,11 +46,13 @@ import { CloudAssemblySourceBuilder } from '../api/cloud-assembly/source-builder import type { StackCollection } from '../api/cloud-assembly/stack-collection'; import { Deployments } from '../api/deployments'; import { DiffFormatter } from '../api/diff'; +import { detectStackDrift } from '../api/drift'; +import { DriftFormatter } from '../api/drift/drift-formatter'; import type { IIoHost, IoMessageLevel, ToolkitAction } from '../api/io'; import type { IoHelper } from '../api/io/private'; import { asIoHelper, IO, SPAN, withoutColor, withoutEmojis, withTrimmedWhitespace } from '../api/io/private'; import { CloudWatchLogEventMonitor, findCloudWatchLogGroups } from '../api/logs-monitor'; -import { PluginHost } from '../api/plugin'; +import { Mode, PluginHost } from '../api/plugin'; import { AmbiguityError, ambiguousMovements, @@ -376,6 +379,79 @@ export class Toolkit extends CloudAssemblySourceBuilder { return templateDiffs; } + /** + * Drift Action + */ + public async drift(cx: ICloudAssemblySource, options: DriftOptions): Promise<{ [name: string]: DriftResult }> { + const ioHelper = asIoHelper(this.ioHost, 'drift'); + const sdkProvider = await this.sdkProvider('drift'); + const selectStacks = options.stacks ?? ALL_STACKS; + await using assembly = await assemblyFromSource(ioHelper, cx); + const stacks = await assembly.selectStacksV2(selectStacks); + + const allDriftResults: { [name: string]: DriftResult } = {}; + const unavailableDrifts = []; + + for (const stack of stacks.stackArtifacts) { + const cfn = (await sdkProvider.forEnvironment(stack.environment, Mode.ForReading)).sdk.cloudFormation(); + const driftResults = await detectStackDrift(cfn, ioHelper, stack.stackName); + + if (!driftResults.StackResourceDrifts) { + const stackName = stack.displayName ?? stack.stackName; + unavailableDrifts.push(stackName); + await ioHelper.notify(IO.CDK_TOOLKIT_I4591.msg(`${stackName}: No drift results available`, { stack })); + continue; + } + + const formatter = new DriftFormatter({ stack, resourceDrifts: driftResults.StackResourceDrifts }); + const driftOutput = formatter.formatStackDrift(); + const stackDrift = { + numResourcesWithDrift: driftOutput.numResourcesWithDrift, + numResourcesUnchecked: driftOutput.numResourcesUnchecked, + formattedDrift: { + unchanged: driftOutput.unchanged, + unchecked: driftOutput.unchecked, + modified: driftOutput.modified, + deleted: driftOutput.deleted, + }, + }; + allDriftResults[formatter.stackName] = stackDrift; + + // header + await ioHelper.defaults.info(driftOutput.stackHeader); + + // print the different sections at different levels + if (driftOutput.unchanged) { + await ioHelper.defaults.debug(driftOutput.unchanged); + } + if (driftOutput.unchecked) { + await ioHelper.defaults.debug(driftOutput.unchecked); + } + if (driftOutput.modified) { + await ioHelper.defaults.info(driftOutput.modified); + } + if (driftOutput.deleted) { + await ioHelper.defaults.info(driftOutput.deleted); + } + + // main stack result + await ioHelper.notify(IO.CDK_TOOLKIT_I4590.msg(driftOutput.summary, { + stack, + drift: stackDrift, + })); + } + + // print summary + const totalDrifts = Object.values(allDriftResults).reduce((total, current) => total + (current.numResourcesWithDrift ?? 0), 0); + const totalUnchecked = Object.values(allDriftResults).reduce((total, current) => total + (current.numResourcesUnchecked ?? 0), 0); + await ioHelper.defaults.result(`\n✨ Number of resources with drift: ${totalDrifts}${totalUnchecked ? ` (${totalUnchecked} unchecked)` : ''}`); + if (unavailableDrifts.length) { + await ioHelper.defaults.warn(`\n⚠️ Failed to check drift for ${unavailableDrifts.length} stack(s). Check log for more details.`); + } + + return allDriftResults; + } + /** * List Action * diff --git a/packages/@aws-cdk/toolkit-lib/test/_helpers/test-io-host.ts b/packages/@aws-cdk/toolkit-lib/test/_helpers/test-io-host.ts index a425bee6a..d52d4898f 100644 --- a/packages/@aws-cdk/toolkit-lib/test/_helpers/test-io-host.ts +++ b/packages/@aws-cdk/toolkit-lib/test/_helpers/test-io-host.ts @@ -28,8 +28,10 @@ export class TestIoHost implements IIoHost { public messages: Array> = []; public readonly notifySpy: MessageMock; public readonly requestSpy: RequestMock; + private readonly stripSpecial: boolean; - constructor(public level: IoMessageLevel = 'info') { + constructor(public level: IoMessageLevel = 'info', stripSpecial = false) { + this.stripSpecial = stripSpecial; this.notifySpy = jest.fn(); this.requestSpy = jest.fn(); this.clear(); @@ -47,6 +49,12 @@ export class TestIoHost implements IIoHost { public async notify(msg: IoMessage): Promise { if (isMessageRelevantForLevel(msg, this.level)) { + if (this.stripSpecial) { + msg = { + ...msg, + message: msg.message.replace(/\p{Emoji_Presentation}/gu, '').replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''), + }; + } this.messages.push(msg); this.notifySpy(msg); } diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/drift.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/drift.test.ts new file mode 100644 index 000000000..0fbd26c3c --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/actions/drift.test.ts @@ -0,0 +1,76 @@ +import { DescribeStackDriftDetectionStatusCommand, DescribeStackResourceDriftsCommand, DetectStackDriftCommand } from '@aws-sdk/client-cloudformation'; +import * as awsauth from '../../lib/api/aws-auth/private'; +import { StackSelectionStrategy } from '../../lib/api/cloud-assembly'; +import { Toolkit } from '../../lib/toolkit'; +import { builderFixture, TestIoHost } from '../_helpers'; +import { mockCloudFormationClient, MockSdk, restoreSdkMocksToDefault, setDefaultSTSMocks } from '../_helpers/mock-sdk'; + +let ioHost: TestIoHost; +let toolkit: Toolkit; + +beforeEach(() => { + jest.restoreAllMocks(); + restoreSdkMocksToDefault(); + setDefaultSTSMocks(); + ioHost = new TestIoHost('info', true); + toolkit = new Toolkit({ ioHost }); + + // Some default implementations + jest.spyOn(awsauth.SdkProvider.prototype, '_makeSdk').mockReturnValue(new MockSdk()); +}); + +describe('drift', () => { + test('if no drift is returned, warn user', async () => { + // GIVEN + mockCloudFormationClient.on(DetectStackDriftCommand).resolves({ StackDriftDetectionId: '12345' }); + mockCloudFormationClient.on(DescribeStackDriftDetectionStatusCommand).resolves({ DetectionStatus: 'DETECTION_COMPLETE' }); + mockCloudFormationClient.on(DescribeStackResourceDriftsCommand).resolvesOnce({}); + + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + const result = await toolkit.drift(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + }); + + // THEN + expect(Object.keys(result).length).toBe(0); + ioHost.expectMessage({ containing: 'No drift results available', level: 'warn' }); + }); + + test('returns stack drift and ignores metadata resource', async () => { + // GIVEN + mockCloudFormationClient.on(DetectStackDriftCommand).resolves({ StackDriftDetectionId: '12345' }); + mockCloudFormationClient.on(DescribeStackDriftDetectionStatusCommand).resolves({ DetectionStatus: 'DETECTION_COMPLETE' }); + mockCloudFormationClient.on(DescribeStackResourceDriftsCommand).resolvesOnce({ + StackResourceDrifts: [ + { + StackId: 'some:stack:arn', + StackResourceDriftStatus: 'MODIFIED', + LogicalResourceId: 'MyBucketF68F3FF0', + PhysicalResourceId: 'physical-id-1', + ResourceType: 'AWS::S3::Bucket', + PropertyDifferences: [{ + PropertyPath: '/BucketName', + ExpectedValue: 'expected-name', + ActualValue: 'actual-name', + DifferenceType: 'NOT_EQUAL', + }], + Timestamp: new Date(Date.now()), + }, + ], + }); + + // WHEN + const cx = await builderFixture(toolkit, 'stack-with-bucket'); + const result = await toolkit.drift(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + }); + + // THEN + expect(result).toHaveProperty('Stack1'); + expect(result.Stack1.numResourcesWithDrift).toBe(1); + expect(result.Stack1.numResourcesUnchecked).toBe(0); + ioHost.expectMessage({ containing: 'Modified Resources', level: 'info' }); + ioHost.expectMessage({ containing: '[~] AWS::S3::Bucket MyBucket MyBucketF68F3FF0', level: 'info' }); + }); +}); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/drift/drift.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/drift/drift.test.ts new file mode 100644 index 000000000..479a53265 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/drift/drift.test.ts @@ -0,0 +1,583 @@ +import type * as cxapi from '@aws-cdk/cx-api'; +import type { DescribeStackResourceDriftsCommandOutput, StackResourceDrift } from '@aws-sdk/client-cloudformation'; +import { + DescribeStackDriftDetectionStatusCommand, + DescribeStackResourceDriftsCommand, + DetectStackDriftCommand, + DetectStackResourceDriftCommand, +} from '@aws-sdk/client-cloudformation'; +import { detectStackDrift, DriftFormatter } from '../../../lib/api/drift'; +import { ToolkitError } from '../../../lib/toolkit/toolkit-error'; +import { mockCloudFormationClient, MockSdk } from '../../_helpers/mock-sdk'; +import { TestIoHost } from '../../_helpers/test-io-host'; + +let ioHost = new TestIoHost(); +let ioHelper = ioHost.asHelper('deploy'); + +describe('CloudFormation drift commands', () => { + let sdk: MockSdk; + + beforeEach(() => { + jest.resetAllMocks(); + sdk = new MockSdk(); + }); + + test('detectStackDrift sends the correct command', async () => { + // GIVEN + const cfnClient = mockCloudFormationClient; + cfnClient.on(DetectStackDriftCommand).resolves({ + StackDriftDetectionId: 'drift-detection-id', + }); + + // WHEN + await sdk.cloudFormation().detectStackDrift({ + StackName: 'test-stack', + }); + + // THEN + expect(cfnClient).toHaveReceivedCommandWith(DetectStackDriftCommand, { + StackName: 'test-stack', + }); + }); + + test('describeStackDriftDetectionStatus sends the correct command', async () => { + // GIVEN + const cfnClient = mockCloudFormationClient; + cfnClient.on(DescribeStackDriftDetectionStatusCommand).resolves({ + StackId: 'stack-id', + StackDriftDetectionId: 'drift-detection-id', + DetectionStatus: 'DETECTION_COMPLETE', + }); + + // WHEN + await sdk.cloudFormation().describeStackDriftDetectionStatus({ + StackDriftDetectionId: 'drift-detection-id', + }); + + // THEN + expect(cfnClient).toHaveReceivedCommandWith(DescribeStackDriftDetectionStatusCommand, { + StackDriftDetectionId: 'drift-detection-id', + }); + }); + + test('describeStackResourceDrifts sends the correct command', async () => { + // GIVEN + const cfnClient = mockCloudFormationClient; + cfnClient.on(DescribeStackResourceDriftsCommand).resolves({ + StackResourceDrifts: [ + { + StackId: 'stack-id', + LogicalResourceId: 'resource-id', + PhysicalResourceId: 'physical-id', + ResourceType: 'AWS::S3::Bucket', + ExpectedProperties: '{}', + ActualProperties: '{}', + PropertyDifferences: [], + StackResourceDriftStatus: 'IN_SYNC', + Timestamp: new Date(), + }, + ], + }); + + // WHEN + await sdk.cloudFormation().describeStackResourceDrifts({ + StackName: 'test-stack', + }); + + // THEN + expect(cfnClient).toHaveReceivedCommandWith(DescribeStackResourceDriftsCommand, { + StackName: 'test-stack', + }); + }); + + test('detectStackResourceDrift sends the correct command', async () => { + // GIVEN + const cfnClient = mockCloudFormationClient; + cfnClient.on(DetectStackResourceDriftCommand).resolves({ + StackResourceDrift: { + StackId: 'stack-id', + LogicalResourceId: 'resource-id', + PhysicalResourceId: 'physical-id', + ResourceType: 'AWS::S3::Bucket', + ExpectedProperties: '{}', + ActualProperties: '{}', + PropertyDifferences: [], + StackResourceDriftStatus: 'IN_SYNC', + Timestamp: new Date(), + }, + }); + + // WHEN + await sdk.cloudFormation().detectStackResourceDrift({ + StackName: 'test-stack', + LogicalResourceId: 'resource-id', + }); + + // THEN + expect(cfnClient).toHaveReceivedCommandWith(DetectStackResourceDriftCommand, { + StackName: 'test-stack', + LogicalResourceId: 'resource-id', + }); + }); +}); + +describe('detectStackDrift', () => { + let mockCfn: any; + + beforeEach(() => { + jest.resetAllMocks(); + ioHost = new TestIoHost(); + // Set level to trace to capture all messages + ioHost.level = 'trace'; + ioHelper = ioHost.asHelper('drift'); + mockCfn = { + detectStackDrift: jest.fn(), + describeStackDriftDetectionStatus: jest.fn(), + describeStackResourceDrifts: jest.fn(), + }; + }); + + test('successfully detects drift and returns results', async () => { + // GIVEN + const stackName = 'test-stack'; + const driftDetectionId = 'drift-detection-id'; + const expectedDriftResults = { StackResourceDrifts: [], $metadata: {} }; + + mockCfn.detectStackDrift.mockResolvedValue({ StackDriftDetectionId: driftDetectionId }); + mockCfn.describeStackDriftDetectionStatus.mockResolvedValue({ + DetectionStatus: 'DETECTION_COMPLETE', + StackDriftStatus: 'IN_SYNC', + }); + mockCfn.describeStackResourceDrifts.mockResolvedValue(expectedDriftResults); + + // WHEN + const result = await detectStackDrift(mockCfn, ioHelper, stackName); + + // THEN + expect(mockCfn.detectStackDrift).toHaveBeenCalledWith({ StackName: stackName }); + expect(mockCfn.describeStackDriftDetectionStatus).toHaveBeenCalledWith({ + StackDriftDetectionId: driftDetectionId, + }); + expect(mockCfn.describeStackResourceDrifts).toHaveBeenCalledWith({ StackName: stackName }); + expect(result).toBe(expectedDriftResults); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('Detecting drift'), + level: 'trace', + })); + }); + + test('throws error when drift detection takes too long', async () => { + // GIVEN + const stackName = 'test-stack'; + const driftDetectionId = 'drift-detection-id'; + + mockCfn.detectStackDrift.mockResolvedValue({ StackDriftDetectionId: driftDetectionId }); + + // Mock the describeStackDriftDetectionStatus to always return DETECTION_IN_PROGRESS + let callCount = 0; + mockCfn.describeStackDriftDetectionStatus.mockImplementation(() => { + callCount++; + // After a few calls, simulate a timeout by returning a status that will trigger the timeout check + return Promise.resolve({ + DetectionStatus: 'DETECTION_IN_PROGRESS', + }); + }); + + // Mock Date.now to simulate timeout + const originalDateNow = Date.now; + const mockDateNow = jest.fn() + .mockReturnValueOnce(1000) // First call - start time + .mockReturnValue(999999); // Subsequent calls - after timeout + Date.now = mockDateNow; + + // WHEN & THEN + await expect(detectStackDrift(mockCfn, ioHelper, stackName)) + .rejects.toThrow(ToolkitError); + + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('Detecting drift'), + level: 'trace', + })); + + // Restore original Date.now + Date.now = originalDateNow; + }); + + test('sends periodic check-in notifications during long-running drift detection', async () => { + // GIVEN + const stackName = 'test-stack'; + const driftDetectionId = 'drift-detection-id'; + const expectedDriftResults = { StackResourceDrifts: [], $metadata: {} }; + + mockCfn.detectStackDrift.mockResolvedValue({ StackDriftDetectionId: driftDetectionId }); + + // Mock Date.now to simulate time progression + const originalDateNow = Date.now; + const mockDateNow = jest.fn(); + + const startTime = 1000; + const timeBetweenOutputs = 10_000; + + mockDateNow + .mockReturnValueOnce(startTime) // Initial call + .mockReturnValueOnce(startTime + 5000) // First check - before checkIn + .mockReturnValueOnce(startTime + timeBetweenOutputs + 1000) // Second check - after checkIn + .mockReturnValueOnce(startTime + timeBetweenOutputs + 5000) // Third check - before next checkIn + .mockReturnValueOnce(startTime + timeBetweenOutputs + 6000); // Fourth check - still before next checkIn + + Date.now = mockDateNow; + + // First three calls return IN_PROGRESS, fourth call returns COMPLETE + mockCfn.describeStackDriftDetectionStatus + .mockResolvedValueOnce({ DetectionStatus: 'DETECTION_IN_PROGRESS' }) + .mockResolvedValueOnce({ DetectionStatus: 'DETECTION_IN_PROGRESS' }) + .mockResolvedValueOnce({ DetectionStatus: 'DETECTION_IN_PROGRESS' }) + .mockResolvedValueOnce({ DetectionStatus: 'DETECTION_COMPLETE', StackDriftStatus: 'IN_SYNC' }); + + mockCfn.describeStackResourceDrifts.mockResolvedValue(expectedDriftResults); + + // WHEN + await detectStackDrift(mockCfn, ioHelper, stackName); + + // THEN + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('Detecting drift'), + level: 'trace', + })); + + // Restore original Date.now + Date.now = originalDateNow; + }, 15_000); + + test('throws error when detection status is DETECTION_FAILED', async () => { + // GIVEN + const stackName = 'test-stack'; + const driftDetectionId = 'drift-detection-id'; + const failureReason = 'Something went wrong'; + + mockCfn.detectStackDrift.mockResolvedValue({ StackDriftDetectionId: driftDetectionId }); + mockCfn.describeStackDriftDetectionStatus.mockResolvedValue({ + DetectionStatus: 'DETECTION_FAILED', + DetectionStatusReason: failureReason, + }); + + // WHEN & THEN + await expect(detectStackDrift(mockCfn, ioHelper, stackName)) + .rejects.toThrow(`Drift detection failed: ${failureReason}`); + + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('Detecting drift'), + level: 'trace', + })); + }); + + test('throws error when detection fails', async () => { + // GIVEN + const stackName = 'test-stack'; + const driftDetectionId = 'test-detection-id'; + const failureReason = 'Some failure reason'; + + mockCfn.detectStackDrift.mockResolvedValue({ + StackDriftDetectionId: driftDetectionId, + }); + + mockCfn.describeStackDriftDetectionStatus.mockResolvedValue({ + DetectionStatus: 'DETECTION_FAILED', + DetectionStatusReason: failureReason, + }); + + // WHEN & THEN + await expect(detectStackDrift(mockCfn, ioHelper, stackName)) + .rejects.toThrow(`Drift detection failed: ${failureReason}`); + + expect(ioHost.notifySpy).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('Detecting drift'), + level: 'trace', + })); + }); +}); + +describe('formatStackDrift', () => { + let mockNewTemplate: cxapi.CloudFormationStackArtifact; + + beforeEach(() => { + mockNewTemplate = { + template: { + Resources: { + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: 'BuckChuckets', + S3Key: 'some-key', + }, + Handler: 'index.handler', + Runtime: 'nodejs20.x', + Description: 'Some description', + }, + }, + }, + }, + templateFile: 'template.json', + stackName: 'test-stack', + findMetadataByType: () => [], + } as any; + }); + + test('detects drift', () => { + // GIVEN + const mockDriftedResources: DescribeStackResourceDriftsCommandOutput = { + StackResourceDrifts: [{ + StackId: 'some:stack:arn', + StackResourceDriftStatus: 'MODIFIED', + LogicalResourceId: 'GiveUpTheFunc', + PhysicalResourceId: 'gotta-have-that-func', + ResourceType: 'AWS::Lambda::Function', + PropertyDifferences: [{ + PropertyPath: '/Description', + ExpectedValue: 'Some description', + ActualValue: 'Tear the Roof Off the Sucker', + DifferenceType: 'NOT_EQUAL', + }], + Timestamp: new Date(2024, 5, 6, 9, 0, 0), + }], + $metadata: {}, + }; + + // WHEN + const formatter = new DriftFormatter({ + stack: mockNewTemplate, + resourceDrifts: mockDriftedResources.StackResourceDrifts!, + }); + const result = formatter.formatStackDrift(); + + // THEN + expect(result.numResourcesWithDrift).toBe(1); + const expectedStringsInOutput = [ + 'Modified Resources', + 'AWS::Lambda::Function', + 'GiveUpTheFunc', + 'Description', + 'Some description', + 'Tear the Roof Off the Sucker', + ]; + for (const expectedStringInOutput of expectedStringsInOutput) { + expect(result.modified).toContain(expectedStringInOutput); + } + expect(result.summary).toContain('1 resource has drifted'); + }); + + test('detects multiple drifts', () => { + // GIVEN + const mockDriftedResources: DescribeStackResourceDriftsCommandOutput = { + StackResourceDrifts: [{ + StackId: 'some:stack:arn', + StackResourceDriftStatus: 'MODIFIED', + LogicalResourceId: 'MyVpc', + PhysicalResourceId: 'MyVpc', + ResourceType: 'AWS::EC2::VPC', + PropertyDifferences: [{ + PropertyPath: '/CidrBlock', + ExpectedValue: '10.0.0.0/16', + ActualValue: '10.0.0.1/16', + DifferenceType: 'NOT_EQUAL', + }], + Timestamp: new Date(2024, 5, 3, 13, 0, 0), + }, + { + StackId: 'some:stack:arn', + StackResourceDriftStatus: 'DELETED', + LogicalResourceId: 'SomeRoute', + PhysicalResourceId: 'SomeRoute', + ResourceType: 'AWS::EC2::Route', + Timestamp: new Date(2024, 11, 24, 19, 0, 0), + }], + $metadata: {}, + }; + + // WHEN + const formatter = new DriftFormatter({ + stack: mockNewTemplate, + resourceDrifts: mockDriftedResources.StackResourceDrifts!, + }); + const result = formatter.formatStackDrift(); + + // THEN + expect(result.numResourcesWithDrift).toBe(2); + const expectedStringsInOutput = [ + 'Modified Resources', + 'AWS::EC2::VPC', + 'MyVpc', + 'CidrBlock', + '10.0.0.0/16', + '10.0.0.1/16', + ]; + for (const expectedStringInOutput of expectedStringsInOutput) { + expect(result.modified).toContain(expectedStringInOutput); + } + expect(result.deleted).toContain('AWS::EC2::Route'); + expect(result.deleted).toContain('SomeRoute'); + expect(result.summary).toContain('2 resources have drifted'); + }); + + test('no drift detected', () => { + // WHEN + const formatter = new DriftFormatter({ + stack: mockNewTemplate, + resourceDrifts: [], + }); + const result = formatter.formatStackDrift(); + + // THEN + expect(result.numResourcesWithDrift).toBe(0); + expect(result.summary).toContain('No drift detected'); + }); + + test('formatting with verbose should show unchecked resources', () => { + // GIVEN + mockNewTemplate = { // we want additional resources to see what was unchecked + template: { + Resources: { + SomeID: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: 'MyBucket', + S3Key: 'MyKey', + }, + Handler: 'index.handler', + Runtime: 'nodejs20.x', + Description: 'Abra', + }, + }, + AnotherID: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: 'MyOtherBucket', + S3Key: 'MyOtherKey', + }, + Handler: 'index.handler', + Runtime: 'nodejs20.x', + Description: 'Kadabra', + }, + }, + OneMoreID: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: 'YetAnotherBucket', + S3Key: 'YetAnotherKey', + }, + Handler: 'index.handler', + Runtime: 'nodejs20.x', + Description: 'Alakazam', + }, + }, + }, + }, + templateFile: 'template.json', + stackName: 'test-stack', + findMetadataByType: () => [], + } as any; + + const mockDriftedResources: DescribeStackResourceDriftsCommandOutput = { + StackResourceDrifts: [{ + StackId: 'some:stack:arn', + StackResourceDriftStatus: 'MODIFIED', + LogicalResourceId: 'SomeID', + ResourceType: 'AWS::Lambda::Function', + PropertyDifferences: [{ + PropertyPath: '/Description', + ExpectedValue: 'Understand Understand', + ActualValue: 'The Concept of Love', + DifferenceType: 'NOT_EQUAL', + }], + Timestamp: new Date(2025, 10, 10, 0, 0, 0), + }, + { + StackId: 'some:stack:arn', + StackResourceDriftStatus: 'IN_SYNC', + LogicalResourceId: 'OneMoreID', + ResourceType: 'AWS::Lambda::Function', + Timestamp: new Date(2025, 10, 10, 0, 0, 0), + }], + $metadata: {}, + }; + + // WHEN + const formatter = new DriftFormatter({ + stack: mockNewTemplate, + resourceDrifts: mockDriftedResources.StackResourceDrifts!, + }); + const result = formatter.formatStackDrift(); + + // THEN + expect(result.numResourcesWithDrift).toBe(1); + expect(result.summary).toContain('1 resource has drifted'); + + expect(result.unchanged).toContain('Resources In Sync'); + expect(result.unchecked).toContain('Unchecked Resources'); + }); + + test('formatting with different drift statuses', () => { + // GIVEN + const mockDriftedResources: StackResourceDrift[] = [ + { + StackId: 'some:stack:arn', + StackResourceDriftStatus: 'MODIFIED', + LogicalResourceId: 'Resource1', + PhysicalResourceId: 'physical-id-1', + ResourceType: 'AWS::S3::Bucket', + PropertyDifferences: [{ + PropertyPath: '/BucketName', + ExpectedValue: 'expected-name', + ActualValue: 'actual-name', + DifferenceType: 'NOT_EQUAL', + }], + Timestamp: new Date(Date.now()), + }, + { + StackId: 'some:stack:arn', + StackResourceDriftStatus: 'DELETED', + LogicalResourceId: 'Resource2', + PhysicalResourceId: 'physical-id-2', + ResourceType: 'AWS::IAM::Role', + Timestamp: new Date(Date.now()), + }, + { + StackId: 'some:stack:arn', + StackResourceDriftStatus: 'IN_SYNC', + LogicalResourceId: 'Resource3', + PhysicalResourceId: 'physical-id-3', + ResourceType: 'AWS::Lambda::Function', + Timestamp: new Date(Date.now()), + }, + { + StackId: 'some:stack:arn', + StackResourceDriftStatus: 'NOT_CHECKED', + LogicalResourceId: 'Resource4', + PhysicalResourceId: 'physical-id-4', + ResourceType: 'AWS::DynamoDB::Table', + Timestamp: new Date(Date.now()), + }, + ]; + + // WHEN + const formatter = new DriftFormatter({ + stack: mockNewTemplate, + resourceDrifts: mockDriftedResources, + }); + const result = formatter.formatStackDrift(); + + // THEN + expect(result.numResourcesWithDrift).toBe(2); // Only MODIFIED and DELETED count as drift + expect(result.modified).toContain('Modified Resources'); + expect(result.modified).toContain('AWS::S3::Bucket'); + expect(result.modified).toContain('Resource1'); + expect(result.deleted).toContain('Deleted Resources'); + expect(result.deleted).toContain('AWS::IAM::Role'); + expect(result.deleted).toContain('Resource2'); + expect(result.summary).toContain('2 resources have drifted'); + }); +}); diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index 6ee919717..7d76e2aee 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -30,6 +30,7 @@ The AWS CDK Toolkit provides the `cdk` command-line interface that can be used t | [`cdk acknowledge`](#cdk-acknowledge) | Acknowledge (and hide) a notice by issue number | | [`cdk notices`](#cdk-notices) | List all relevant notices for the application | | [`cdk refactor`](#cdk-refactor) | Moves resources between stacks or within the same stack | +| [`cdk drift`](#cdk-drift) | Detect drifts in the given CloudFormation stack(s) | - [Bundling](#bundling) - [MFA Support](#mfa-support) @@ -1191,6 +1192,29 @@ If you want to undo a refactor, you can use the `--revert` option in conjunction with the `--mapping-file` option. It will apply the mapping in reverse order (source becomes destination and vice versa). +### `cdk drift` + +Checks if there is any drift in your stack or stacks. If you need the command +to return a non-zero if any differences are found, you need to use the `--fail` +command line option. + +```console +$ # Detect drift against the currently-deployed stack +$ cdk drift + +$ # Detect drift against a specific stack +$ cdk drift MyStackName +``` + +Note that there are some resources that do not support drift detection. You can +see which of these resources were left unchecked with the `--verbose` command line +option. + +```console +$ # Detect drift against the currently-deployed stack with the verbose flag enabled +$ cdk drift --verbose +``` + ## Notices CDK Notices are important messages regarding security vulnerabilities, regressions, and usage of unsupported diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 2f6cc663f..1505b5f89 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -664,6 +664,21 @@ export class CdkToolkit { }); } + /** + * Detect infrastructure drift for the given stack(s) + */ + public async drift(options: DriftOptions): Promise { + const driftResults = await this.toolkit.drift(this.props.cloudExecutable, { + stacks: { + patterns: options.selector.patterns, + strategy: options.selector.patterns.length > 0 ? StackSelectionStrategy.PATTERN_MATCH : StackSelectionStrategy.ALL_STACKS, + }, + }); + + const totalDrifts = Object.values(driftResults).reduce((total, current) => total + (current.numResourcesWithDrift ?? 0), 0); + return totalDrifts > 0 && options.fail ? 1 : 0; + } + /** * Roll back the given stack or stacks. */ @@ -1990,6 +2005,23 @@ export interface RefactorOptions { revert?: boolean; } +/** + * Options for the drift command + */ +export interface DriftOptions { + /** + * Criteria for selecting stacks to detect drift on + */ + readonly selector: StackSelector; + + /** + * Whether to fail with exit code 1 if drift is detected + * + * @default false + */ + readonly fail?: boolean; +} + function buildParameterMap( parameters: | { diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index d91946072..3ea32e677 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -339,6 +339,16 @@ export async function makeConfig(): Promise { 'import-existing-resources': { type: 'boolean', desc: 'Whether or not the change set imports resources that already exist', default: false }, }, }, + drift: { + description: 'Detect drifts in the given CloudFormation stack(s)', + arg: { + name: 'STACKS', + variadic: true, + }, + options: { + fail: { type: 'boolean', desc: 'Fail with exit code 1 if drift is detected' }, + }, + }, metadata: { description: 'Returns all metadata associated with this stack', arg: { diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 7638689d7..57a9a3518 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -262,6 +262,13 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { desc: 'Whether or not the change set imports resources that already exist', }), ) + .command('drift [STACKS..]', 'Detect drifts in the given CloudFormation stack(s)', (yargs: Argv) => + yargs.option('fail', { + default: undefined, + type: 'boolean', + desc: 'Fail with exit code 1 if drift is detected', + }), + ) .command('metadata [STACK]', 'Returns all metadata associated with this stack') .command(['acknowledge [ID]', 'ack [ID]'], 'Acknowledge a notice so that it does not show up anymore') .command('notices', 'Returns a list of relevant notices', (yargs: Argv) => diff --git a/packages/aws-cdk/lib/cli/user-configuration.ts b/packages/aws-cdk/lib/cli/user-configuration.ts index 4068432cd..823353c25 100644 --- a/packages/aws-cdk/lib/cli/user-configuration.ts +++ b/packages/aws-cdk/lib/cli/user-configuration.ts @@ -37,6 +37,7 @@ export enum Command { DOC = 'doc', DOCTOR = 'doctor', REFACTOR = 'refactor', + DRIFT = 'drift', } const BUNDLING_COMMANDS = [ diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 43fe756af..0aa41a901 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -75,6 +75,11 @@ export interface UserInput { */ readonly diff?: DiffOptions; + /** + * Detect drifts in the given CloudFormation stack(s) + */ + readonly drift?: DriftOptions; + /** * Returns all metadata associated with this stack */ @@ -1165,6 +1170,25 @@ export interface DiffOptions { readonly STACKS?: Array; } +/** + * Detect drifts in the given CloudFormation stack(s) + * + * @struct + */ +export interface DriftOptions { + /** + * Fail with exit code 1 if drift is detected + * + * @default - undefined + */ + readonly fail?: boolean; + + /** + * Positional argument for drift + */ + readonly STACKS?: Array; +} + /** * Returns all metadata associated with this stack * diff --git a/packages/aws-cdk/test/cli/cli-arguments.test.ts b/packages/aws-cdk/test/cli/cli-arguments.test.ts index 0dca67f31..b2b8a7e35 100644 --- a/packages/aws-cdk/test/cli/cli-arguments.test.ts +++ b/packages/aws-cdk/test/cli/cli-arguments.test.ts @@ -127,6 +127,7 @@ describe('config', () => { deploy: expect.anything(), destroy: expect.anything(), diff: expect.anything(), + drift: expect.anything(), init: expect.anything(), metadata: expect.anything(), migrate: expect.anything(), diff --git a/packages/aws-cdk/test/commands/drift.test.ts b/packages/aws-cdk/test/commands/drift.test.ts new file mode 100644 index 000000000..5c881f9a1 --- /dev/null +++ b/packages/aws-cdk/test/commands/drift.test.ts @@ -0,0 +1,121 @@ +import type { DriftResult, FormattedDrift } from '@aws-cdk/toolkit-lib'; +import { Deployments } from '../../lib/api'; +import { CdkToolkit } from '../../lib/cli/cdk-toolkit'; +import { CliIoHost } from '../../lib/cli/io-host'; +import { instanceMockFrom, MockCloudExecutable } from '../_helpers'; + +describe('drift', () => { + let cloudExecutable: MockCloudExecutable; + let cloudFormation: jest.Mocked; + let toolkit: CdkToolkit; + let ioHost = CliIoHost.instance(); + let notifySpy: jest.SpyInstance>; + + const stack1Output: FormattedDrift = { + modified: ` +Modified Resources +[~] AWS::Lambda::Function HelloWorldFunction HelloWorldFunctionB2AB6E79 + └─ [~] /Description + ├─ [-] A simple hello world Lambda function + └─ [+] A simple, drifted hello world Lambda function +`, + }; + const stack2Output: FormattedDrift = {}; + + beforeEach(() => { + notifySpy = jest.spyOn(ioHost, 'notify'); + notifySpy.mockClear(); + + cloudExecutable = new MockCloudExecutable({ + stacks: [ + { + stackName: 'Stack1', + template: { + Resources: { + HelloWorldFunction: { Type: 'AWS::Lambda::Function' }, + }, + }, + }, + { + stackName: 'Stack2', + template: { + Resources: { + HelloWorldFunction: { Type: 'AWS::Lambda::Function' }, + }, + }, + }, + ], + }, undefined, ioHost); + + cloudFormation = instanceMockFrom(Deployments); + + const mockSdk = { + cloudFormation: () => ({ + detectStackDrift: jest.fn(), + describeStackDriftDetectionStatus: jest.fn(), + describeStackResourceDrifts: jest.fn(), + }), + }; + + const mockSdkProvider = { + forEnvironment: jest.fn().mockResolvedValue({ sdk: mockSdk }), + }; + + toolkit = new CdkToolkit({ + cloudExecutable, + // ioHost, + deployments: cloudFormation, + configuration: cloudExecutable.configuration, + sdkProvider: mockSdkProvider as any, + }); + + // Mock the toolkit.drift method from toolkit-lib + jest.spyOn((toolkit as any).toolkit, 'drift').mockImplementation(async (_, options: any) => { + if (options.stacks.patterns?.includes('Stack1')) { + return { + Stack1: { + numResourcesWithDrift: 1, + numResourcesUnchecked: 0, + formattedDrift: stack1Output, + }, + + // formattedDrift: stack1Output, + } satisfies { [name: string]: DriftResult }; + } else { + return { + Stack2: { + numResourcesWithDrift: 0, + numResourcesUnchecked: 0, + formattedDrift: stack2Output, + }, + } satisfies { [name: string]: DriftResult }; + } + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('exits with code 1 when drift is detected and fail flag is set', async () => { + // WHEN + const exitCode = await toolkit.drift({ + selector: { patterns: ['Stack1'] }, + fail: true, + }); + + // THEN + expect(exitCode).toBe(1); + }); + + test('exits with code 0 when no drift is detected and fail flag is set', async () => { + // WHEN + const exitCode = await toolkit.drift({ + selector: { patterns: ['Stack2'] }, + fail: true, + }); + + // THEN + expect(exitCode).toBe(0); + }); +});