Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/bootstrap-template-protection.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/@aws-cdk/toolkit-lib/lib/actions/drift/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface FormattedDrift {
readonly unchanged?: string;

/**
* Resources that were not checked for drift
* Resources that were not checked for drift or have an UNKNOWN drift status
*/
readonly unchecked?: string;

Expand Down
74 changes: 31 additions & 43 deletions packages/@aws-cdk/toolkit-lib/lib/api/drift/drift-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ interface DriftFormatterOutput {
readonly unchanged?: string;

/**
* Resources that were not checked for drift
* Resources that were not checked for drift or have an UNKNOWN drift status
*/
readonly unchecked?: string;

Expand Down Expand Up @@ -98,12 +98,10 @@ export class DriftFormatter {
public formatStackDrift(): DriftFormatterOutput {
const formatterOutput = this.formatStackDriftChanges(this.buildLogicalToPathMap());

// we are only interested in actual drifts and always ignore the metadata resource
// we are only interested in actual drifts (and ignore the metadata resource)
const actualDrifts = this.resourceDriftResults.filter(d =>
d.StackResourceDriftStatus === 'MODIFIED' ||
d.StackResourceDriftStatus === 'DELETED' ||
d.ResourceType === 'AWS::CDK::Metadata',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line was dead code, removed as it was wrong. We do NOT want to include metadata changes, but this line, if it worked, WOULD include them.

But metadata results are filtered out before we get to this point, anyway, so we can just remove the code.

Copy link
Contributor

@mrgrain mrgrain Aug 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (blocking): Just to triple check:

  • Can you point me to where metadata is filtered out?
  • Do we have test for metadata being excluded?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad. It's filtered out on line 88, but this is a different variable. Good catch. I've added tests for this as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You still caught the incorrect filter though! Good job!

);
(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`);
Expand All @@ -114,6 +112,7 @@ export class DriftFormatter {
numResourcesWithDrift: 0,
numResourcesUnchecked: this.allStackResources.size - this.resourceDriftResults.length,
stackHeader,
unchecked: formatterOutput.unchecked,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Include unchecked resources even when the count of drifted resources is 0, for the user's awareness.

summary: finalResult,
};
}
Expand All @@ -140,11 +139,8 @@ export class DriftFormatter {
}

/**
* Renders stack drift information to the given stream
* Renders stack drift information
*
* @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)
Comment on lines -143 to -147
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed params that are not present in the function signature.

* @param logicalToPathMap - A map from logical ID to construct path
*/
private formatStackDriftChanges(
Expand All @@ -167,35 +163,35 @@ export class DriftFormatter {

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 += `${CONTEXT} ${chalk.cyan(drift.ResourceType)} ${this.formatLogicalId(logicalToPathMap, drift.LogicalResourceId)}\n`;
}
unchanged += this.printSectionFooter();
}

// Process all unchecked resources
if (this.allStackResources) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a JS Map, so always truthy. Removed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

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 all unchecked and unknown resources
const uncheckedResources = Array.from(this.allStackResources.keys()).filter((logicalId) => {
const drift = drifts.find((d) => d.LogicalResourceId === logicalId);
return !drift || drift.StackResourceDriftStatus === StackResourceDriftStatus.UNKNOWN;
});
if (uncheckedResources.length > 0) {
unchecked = this.printSectionHeader('Unchecked Resources');
for (const logicalId of uncheckedResources) {
const resourceType = this.allStackResources.get(logicalId);
unchecked += `${CONTEXT} ${chalk.cyan(resourceType)} ${this.formatLogicalId(logicalToPathMap, logicalId)}\n`;
}
unchecked += this.printSectionFooter();
}

// Process modified resources
const modifiedResources = drifts.filter(d => d.StackResourceDriftStatus === StackResourceDriftStatus.MODIFIED);
// Process modified resources (exclude AWS::CDK::Metadata)
const modifiedResources = drifts.filter(d =>
d.StackResourceDriftStatus === StackResourceDriftStatus.MODIFIED
&& d.ResourceType !== 'AWS::CDK::Metadata');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensure the count is correct, by excluding metadata resources here.

if (modifiedResources.length > 0) {
modified = this.printSectionHeader('Modified Resources');

for (const drift of modifiedResources) {
if (!drift.LogicalResourceId || !drift.ResourceType) continue;
if (modified === undefined) modified = '';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code. modified is defined a few lines above, printSectionHeader always returns a string.

modified += `${UPDATE} ${this.formatValue(drift.ResourceType, chalk.cyan)} ${this.formatLogicalId(logicalToPathMap, drift.LogicalResourceId)}\n`;
modified += `${UPDATE} ${chalk.cyan(drift.ResourceType)} ${this.formatLogicalId(logicalToPathMap, drift.LogicalResourceId)}\n`;
if (drift.PropertyDifferences) {
const propDiffs = drift.PropertyDifferences;
for (let i = 0; i < propDiffs.length; i++) {
Expand All @@ -209,13 +205,15 @@ export class DriftFormatter {
modified += this.printSectionFooter();
}

// Process deleted resources
const deletedResources = drifts.filter(d => d.StackResourceDriftStatus === StackResourceDriftStatus.DELETED);
// Process deleted resources (exclude AWS::CDK::Metadata)
const deletedResources = drifts.filter(d =>
d.StackResourceDriftStatus === StackResourceDriftStatus.DELETED
&& d.ResourceType !== 'AWS::CDK::Metadata');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensure the count is correct, by excluding metadata resources here.

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 += `${REMOVAL} ${chalk.cyan(drift.ResourceType)} ${this.formatLogicalId(logicalToPathMap, drift.LogicalResourceId)}\n`;
}
deleted += this.printSectionFooter();
}
Expand Down Expand Up @@ -250,16 +248,6 @@ export class DriftFormatter {
return `${normalizedPath} ${chalk.gray(logicalId)}`;
}

private formatValue(value: any, colorFn: (str: string) => string): string {
Copy link
Contributor Author

@iankhou iankhou Sep 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't actually need this. It was also never called on anything that isn't a 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`;
}
Expand All @@ -268,16 +256,16 @@ export class DriftFormatter {
return '\n';
}

private formatTreeDiff(propertyPath: string, difference: Difference<any>, isLast: boolean): string {
private formatTreeDiff(propertyPath: string, difference: Difference<string>, 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));
Comment on lines -279 to -280
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

difference.isUpdate checks that oldValue and newValue are both not undefined. If they're defined, they must be strings.

result += format(' ├─ %s %s\n', REMOVAL, chalk.red(difference.oldValue));
result += format(' └─ %s %s\n', ADDITION, chalk.green(difference.newValue));
}
return result;
}
Expand Down
37 changes: 26 additions & 11 deletions packages/@aws-cdk/toolkit-lib/lib/api/drift/drift.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { format } from 'util';
import { format } from 'node:util';
import type { DescribeStackDriftDetectionStatusCommandOutput, DescribeStackResourceDriftsCommandOutput } from '@aws-sdk/client-cloudformation';
import { ToolkitError } from '../../toolkit/toolkit-error';
import { formatReason } from '../../util/string-manipulation';
import type { ICloudFormationClient } from '../aws-auth/private';
import type { IoHelper } from '../io/private';

Expand Down Expand Up @@ -29,20 +30,34 @@ export async function detectStackDrift(
// 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'}`,
// Handle UNKNOWN stack drift status
if (driftStatus?.StackDriftStatus === 'UNKNOWN') {
await ioHelper.defaults.trace(
'Stack drift status is UNKNOWN. This may occur when CloudFormation is unable to detect drift for at least one resource and all other resources are IN_SYNC.\n' +
`Reason: ${formatReason(driftStatus.DetectionStatusReason)}`,
);
}

// Get the drift results
return cfn.describeStackResourceDrifts({
// Get the drift results, including resources with UNKNOWN status
const driftResults = await cfn.describeStackResourceDrifts({
StackName: stackName,
});

// Log warning for any resources with UNKNOWN status
const unknownResources = driftResults.StackResourceDrifts?.filter(
drift => drift.StackResourceDriftStatus === 'UNKNOWN',
);

if (unknownResources && unknownResources.length > 0) {
await ioHelper.defaults.trace(
'Some resources have UNKNOWN drift status. This may be due to insufficient permissions or throttling:\n' +
unknownResources.map(r =>
` - ${r.LogicalResourceId}: ${formatReason(r.DriftStatusReason)}`,
).join('\n'),
);
}

return driftResults;
}

/**
Expand All @@ -69,7 +84,7 @@ async function waitForDriftDetection(
}

if (response.DetectionStatus === 'DETECTION_FAILED') {
throw new ToolkitError(`Drift detection failed: ${response.DetectionStatusReason}`);
throw new ToolkitError(`Drift detection failed: ${formatReason(response.DetectionStatusReason)}`);
}

if (Date.now() > deadline) {
Expand Down
8 changes: 8 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/util/string-manipulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,11 @@ function millisecondsToSeconds(num: number): number {
export function lowerCaseFirstCharacter(str: string): string {
return str.length > 0 ? `${str[0].toLowerCase()}${str.slice(1)}` : str;
}

/**
* Returns the provided reason or a default fallback message if the reason is undefined, null, or empty.
* This is commonly used for AWS API responses that may not always provide a reason field.
*/
export function formatReason(reason: string | undefined | null, fallback: string = 'No reason provided'): string {
return reason?.trim() || fallback;
}
Loading
Loading