Skip to content

Commit aec389c

Browse files
iankhougithub-actionsmrgrain
authored
feat(cli): count resources/stacks with UNKNOWN drift status as unchecked (#747)
Fixes #740 The `cdk drift` action lists `UNKNOWN` for resources/stacks when CloudFormation returns that value (effective August 14). This follows a new change in the CloudFormation API. Previously, `UNKNOWN` was not available, and stacks/resources that fit such a description would have been reported as unchanged. Brought line coverage for `@aws-cdk/toolkit-lib/lib/api/drift` to 100%. <img width="887" height="316" alt="Screenshot 2025-09-01 at 02 59 27" src="https://github.com/user-attachments/assets/dd91a69d-7961-497b-910c-66af72f04d32" /> - [X] `UNKNOWN` support - [X] Unit Test --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --------- Signed-off-by: github-actions <github-actions@github.com> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: Momo Kornher <kornherm@amazon.co.uk>
1 parent 0e27c14 commit aec389c

File tree

8 files changed

+647
-56
lines changed

8 files changed

+647
-56
lines changed

.github/workflows/bootstrap-template-protection.yml

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/@aws-cdk/toolkit-lib/lib/actions/drift/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export interface FormattedDrift {
2222
readonly unchanged?: string;
2323

2424
/**
25-
* Resources that were not checked for drift
25+
* Resources that were not checked for drift or have an UNKNOWN drift status
2626
*/
2727
readonly unchecked?: string;
2828

packages/@aws-cdk/toolkit-lib/lib/api/drift/drift-formatter.ts

Lines changed: 31 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ interface DriftFormatterOutput {
4141
readonly unchanged?: string;
4242

4343
/**
44-
* Resources that were not checked for drift
44+
* Resources that were not checked for drift or have an UNKNOWN drift status
4545
*/
4646
readonly unchecked?: string;
4747

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

101-
// we are only interested in actual drifts and always ignore the metadata resource
101+
// we are only interested in actual drifts (and ignore the metadata resource)
102102
const actualDrifts = this.resourceDriftResults.filter(d =>
103-
d.StackResourceDriftStatus === 'MODIFIED' ||
104-
d.StackResourceDriftStatus === 'DELETED' ||
105-
d.ResourceType === 'AWS::CDK::Metadata',
106-
);
103+
(d.StackResourceDriftStatus === 'MODIFIED' || d.StackResourceDriftStatus === 'DELETED')
104+
&& d.ResourceType !== 'AWS::CDK::Metadata');
107105

108106
// must output the stack name if there are drifts
109107
const stackHeader = format(`Stack ${chalk.bold(this.stackName)}\n`);
@@ -114,6 +112,7 @@ export class DriftFormatter {
114112
numResourcesWithDrift: 0,
115113
numResourcesUnchecked: this.allStackResources.size - this.resourceDriftResults.length,
116114
stackHeader,
115+
unchecked: formatterOutput.unchecked,
117116
summary: finalResult,
118117
};
119118
}
@@ -140,11 +139,8 @@ export class DriftFormatter {
140139
}
141140

142141
/**
143-
* Renders stack drift information to the given stream
142+
* Renders stack drift information
144143
*
145-
* @param driftResults - The stack resource drifts from CloudFormation
146-
* @param allStackResources - A map of all stack resources
147-
* @param verbose - Whether to output more verbose text (include undrifted resources)
148144
* @param logicalToPathMap - A map from logical ID to construct path
149145
*/
150146
private formatStackDriftChanges(
@@ -167,35 +163,35 @@ export class DriftFormatter {
167163

168164
for (const drift of unchangedResources) {
169165
if (!drift.LogicalResourceId || !drift.ResourceType) continue;
170-
unchanged += `${CONTEXT} ${this.formatValue(drift.ResourceType, chalk.cyan)} ${this.formatLogicalId(logicalToPathMap, drift.LogicalResourceId)}\n`;
166+
unchanged += `${CONTEXT} ${chalk.cyan(drift.ResourceType)} ${this.formatLogicalId(logicalToPathMap, drift.LogicalResourceId)}\n`;
171167
}
172168
unchanged += this.printSectionFooter();
173169
}
174170

175-
// Process all unchecked resources
176-
if (this.allStackResources) {
177-
const uncheckedResources = Array.from(this.allStackResources.keys()).filter((logicalId) => {
178-
return !drifts.find((drift) => drift.LogicalResourceId === logicalId);
179-
});
180-
if (uncheckedResources.length > 0) {
181-
unchecked = this.printSectionHeader('Unchecked Resources');
182-
for (const logicalId of uncheckedResources) {
183-
const resourceType = this.allStackResources.get(logicalId);
184-
unchecked += `${CONTEXT} ${this.formatValue(resourceType, chalk.cyan)} ${this.formatLogicalId(logicalToPathMap, logicalId)}\n`;
185-
}
186-
unchecked += this.printSectionFooter();
171+
// Process all unchecked and unknown resources
172+
const uncheckedResources = Array.from(this.allStackResources.keys()).filter((logicalId) => {
173+
const drift = drifts.find((d) => d.LogicalResourceId === logicalId);
174+
return !drift || drift.StackResourceDriftStatus === StackResourceDriftStatus.UNKNOWN;
175+
});
176+
if (uncheckedResources.length > 0) {
177+
unchecked = this.printSectionHeader('Unchecked Resources');
178+
for (const logicalId of uncheckedResources) {
179+
const resourceType = this.allStackResources.get(logicalId);
180+
unchecked += `${CONTEXT} ${chalk.cyan(resourceType)} ${this.formatLogicalId(logicalToPathMap, logicalId)}\n`;
187181
}
182+
unchecked += this.printSectionFooter();
188183
}
189184

190-
// Process modified resources
191-
const modifiedResources = drifts.filter(d => d.StackResourceDriftStatus === StackResourceDriftStatus.MODIFIED);
185+
// Process modified resources (exclude AWS::CDK::Metadata)
186+
const modifiedResources = drifts.filter(d =>
187+
d.StackResourceDriftStatus === StackResourceDriftStatus.MODIFIED
188+
&& d.ResourceType !== 'AWS::CDK::Metadata');
192189
if (modifiedResources.length > 0) {
193190
modified = this.printSectionHeader('Modified Resources');
194191

195192
for (const drift of modifiedResources) {
196193
if (!drift.LogicalResourceId || !drift.ResourceType) continue;
197-
if (modified === undefined) modified = '';
198-
modified += `${UPDATE} ${this.formatValue(drift.ResourceType, chalk.cyan)} ${this.formatLogicalId(logicalToPathMap, drift.LogicalResourceId)}\n`;
194+
modified += `${UPDATE} ${chalk.cyan(drift.ResourceType)} ${this.formatLogicalId(logicalToPathMap, drift.LogicalResourceId)}\n`;
199195
if (drift.PropertyDifferences) {
200196
const propDiffs = drift.PropertyDifferences;
201197
for (let i = 0; i < propDiffs.length; i++) {
@@ -209,13 +205,15 @@ export class DriftFormatter {
209205
modified += this.printSectionFooter();
210206
}
211207

212-
// Process deleted resources
213-
const deletedResources = drifts.filter(d => d.StackResourceDriftStatus === StackResourceDriftStatus.DELETED);
208+
// Process deleted resources (exclude AWS::CDK::Metadata)
209+
const deletedResources = drifts.filter(d =>
210+
d.StackResourceDriftStatus === StackResourceDriftStatus.DELETED
211+
&& d.ResourceType !== 'AWS::CDK::Metadata');
214212
if (deletedResources.length > 0) {
215213
deleted = this.printSectionHeader('Deleted Resources');
216214
for (const drift of deletedResources) {
217215
if (!drift.LogicalResourceId || !drift.ResourceType) continue;
218-
deleted += `${REMOVAL} ${this.formatValue(drift.ResourceType, chalk.cyan)} ${this.formatLogicalId(logicalToPathMap, drift.LogicalResourceId)}\n`;
216+
deleted += `${REMOVAL} ${chalk.cyan(drift.ResourceType)} ${this.formatLogicalId(logicalToPathMap, drift.LogicalResourceId)}\n`;
219217
}
220218
deleted += this.printSectionFooter();
221219
}
@@ -250,16 +248,6 @@ export class DriftFormatter {
250248
return `${normalizedPath} ${chalk.gray(logicalId)}`;
251249
}
252250

253-
private formatValue(value: any, colorFn: (str: string) => string): string {
254-
if (value == null) {
255-
return '';
256-
}
257-
if (typeof value === 'string') {
258-
return colorFn(value);
259-
}
260-
return colorFn(JSON.stringify(value));
261-
}
262-
263251
private printSectionHeader(title: string): string {
264252
return `${chalk.underline(chalk.bold(title))}\n`;
265253
}
@@ -268,16 +256,16 @@ export class DriftFormatter {
268256
return '\n';
269257
}
270258

271-
private formatTreeDiff(propertyPath: string, difference: Difference<any>, isLast: boolean): string {
259+
private formatTreeDiff(propertyPath: string, difference: Difference<string>, isLast: boolean): string {
272260
let result = format(' %s─ %s %s\n', isLast ? '└' : '├',
273261
difference.isAddition ? ADDITION :
274262
difference.isRemoval ? REMOVAL :
275263
UPDATE,
276264
propertyPath,
277265
);
278266
if (difference.isUpdate) {
279-
result += format(' ├─ %s %s\n', REMOVAL, this.formatValue(difference.oldValue, chalk.red));
280-
result += format(' └─ %s %s\n', ADDITION, this.formatValue(difference.newValue, chalk.green));
267+
result += format(' ├─ %s %s\n', REMOVAL, chalk.red(difference.oldValue));
268+
result += format(' └─ %s %s\n', ADDITION, chalk.green(difference.newValue));
281269
}
282270
return result;
283271
}

packages/@aws-cdk/toolkit-lib/lib/api/drift/drift.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { format } from 'util';
1+
import { format } from 'node:util';
22
import type { DescribeStackDriftDetectionStatusCommandOutput, DescribeStackResourceDriftsCommandOutput } from '@aws-sdk/client-cloudformation';
33
import { ToolkitError } from '../../toolkit/toolkit-error';
4+
import { formatReason } from '../../util/string-manipulation';
45
import type { ICloudFormationClient } from '../aws-auth/private';
56
import type { IoHelper } from '../io/private';
67

@@ -29,20 +30,34 @@ export async function detectStackDrift(
2930
// Wait for drift detection to complete
3031
const driftStatus = await waitForDriftDetection(cfn, ioHelper, driftDetection.StackDriftDetectionId!);
3132

32-
if (!driftStatus) {
33-
throw new ToolkitError('Drift detection took too long to complete. Aborting');
34-
}
35-
36-
if (driftStatus?.DetectionStatus === 'DETECTION_FAILED') {
37-
throw new ToolkitError(
38-
`Failed to detect drift: ${driftStatus.DetectionStatusReason || 'No reason provided'}`,
33+
// Handle UNKNOWN stack drift status
34+
if (driftStatus?.StackDriftStatus === 'UNKNOWN') {
35+
await ioHelper.defaults.trace(
36+
'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' +
37+
`Reason: ${formatReason(driftStatus.DetectionStatusReason)}`,
3938
);
4039
}
4140

42-
// Get the drift results
43-
return cfn.describeStackResourceDrifts({
41+
// Get the drift results, including resources with UNKNOWN status
42+
const driftResults = await cfn.describeStackResourceDrifts({
4443
StackName: stackName,
4544
});
45+
46+
// Log warning for any resources with UNKNOWN status
47+
const unknownResources = driftResults.StackResourceDrifts?.filter(
48+
drift => drift.StackResourceDriftStatus === 'UNKNOWN',
49+
);
50+
51+
if (unknownResources && unknownResources.length > 0) {
52+
await ioHelper.defaults.trace(
53+
'Some resources have UNKNOWN drift status. This may be due to insufficient permissions or throttling:\n' +
54+
unknownResources.map(r =>
55+
` - ${r.LogicalResourceId}: ${formatReason(r.DriftStatusReason)}`,
56+
).join('\n'),
57+
);
58+
}
59+
60+
return driftResults;
4661
}
4762

4863
/**
@@ -69,7 +84,7 @@ async function waitForDriftDetection(
6984
}
7085

7186
if (response.DetectionStatus === 'DETECTION_FAILED') {
72-
throw new ToolkitError(`Drift detection failed: ${response.DetectionStatusReason}`);
87+
throw new ToolkitError(`Drift detection failed: ${formatReason(response.DetectionStatusReason)}`);
7388
}
7489

7590
if (Date.now() > deadline) {

packages/@aws-cdk/toolkit-lib/lib/util/string-manipulation.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,11 @@ function millisecondsToSeconds(num: number): number {
4242
export function lowerCaseFirstCharacter(str: string): string {
4343
return str.length > 0 ? `${str[0].toLowerCase()}${str.slice(1)}` : str;
4444
}
45+
46+
/**
47+
* Returns the provided reason or a default fallback message if the reason is undefined, null, or empty.
48+
* This is commonly used for AWS API responses that may not always provide a reason field.
49+
*/
50+
export function formatReason(reason: string | undefined | null, fallback: string = 'No reason provided'): string {
51+
return reason?.trim() || fallback;
52+
}

0 commit comments

Comments
 (0)