Skip to content

Commit 8366503

Browse files
9paceiliapolo
andauthored
feat(gen2-migration): cfn drift detection (#14303)
* chore: scaffolding migration * chore: scaffolding migration * feat: cfn drift detection base * fix: find root stack from meta instead of team-provider-info * fix: refactor to cli package * feat: implement nested stack traversal and pretty tree formatting * fix: handle cfn drift check failure with UNKNOWN status * chore: refactored redundant drift-formatter code * feat: services structure refactor and recurse correctly * feat: arn parsing and add drift-formatter to services * refactor: consolidate formatting to refactored drift-formatter * chore: remove eroneous refactor.ts file * chore: remove unecessary comment from old code --------- Co-authored-by: Eli Polonsky <epolon@amazon.com>
1 parent 661d116 commit 8366503

10 files changed

Lines changed: 1384 additions & 8 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@
161161
},
162162
"packageManager": "yarn@3.5.0",
163163
"resolutions": {
164+
"@aws-sdk/client-cloudformation": "^3.624.0",
164165
"aws-sdk": "^2.1464.0",
165166
"cross-fetch": "^2.2.6",
166167
"fast-xml-parser": "^4.4.1",
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
/**
2+
* Core drift detection logic for CloudFormation stacks
3+
*/
4+
5+
import {
6+
CloudFormationClient,
7+
DetectStackDriftCommand,
8+
DescribeStackDriftDetectionStatusCommand,
9+
DescribeStackResourceDriftsCommand,
10+
DescribeStackResourcesCommand,
11+
type DescribeStackResourceDriftsCommandOutput,
12+
type DescribeStackDriftDetectionStatusCommandOutput,
13+
type StackResourceDrift,
14+
} from '@aws-sdk/client-cloudformation';
15+
import { AmplifyError } from '@aws-amplify/amplify-cli-core';
16+
import { getAmplifyLogger } from '@aws-amplify/amplify-cli-logger';
17+
18+
const logger = getAmplifyLogger();
19+
20+
/**
21+
* Combined drift results including nested stacks
22+
*/
23+
export interface CombinedDriftResults {
24+
/**
25+
* Drift results for the root stack
26+
*/
27+
rootStackDrifts: DescribeStackResourceDriftsCommandOutput;
28+
29+
/**
30+
* Drift results for nested stacks, keyed by logical resource ID
31+
*/
32+
nestedStackDrifts: Map<string, DescribeStackResourceDriftsCommandOutput>;
33+
34+
/**
35+
* Map of logical resource IDs to physical resource IDs for nested stacks
36+
*/
37+
nestedStackPhysicalIds: Map<string, string>;
38+
}
39+
40+
/**
41+
* Detect drift for a CloudFormation stack and wait for the detection to complete
42+
*
43+
* @param cfn - CloudFormation client
44+
* @param stackName - the name of the stack to check for drift
45+
* @param print - printer for user feedback
46+
* @returns the CloudFormation description of the drift detection results
47+
*/
48+
export async function detectStackDrift(
49+
cfn: CloudFormationClient,
50+
stackName: string,
51+
print?: { info: (msg: string) => void; debug: (msg: string) => void; warning: (msg: string) => void },
52+
): Promise<DescribeStackResourceDriftsCommandOutput> {
53+
// Start drift detection
54+
logger.logInfo({ message: `detectStackDrift: ${stackName}` });
55+
const driftDetection = await cfn.send(
56+
new DetectStackDriftCommand({
57+
StackName: stackName,
58+
}),
59+
);
60+
61+
if (print?.debug) {
62+
print.debug(`Detecting drift with ID ${driftDetection.StackDriftDetectionId} for stack ${stackName}...`);
63+
}
64+
65+
// Wait for drift detection to complete
66+
const driftStatus = await waitForDriftDetection(cfn, driftDetection.StackDriftDetectionId!, print);
67+
68+
// Handle UNKNOWN stack drift status
69+
if (driftStatus?.StackDriftStatus === 'UNKNOWN') {
70+
const reason = formatReason(driftStatus.DetectionStatusReason);
71+
if (print?.debug) {
72+
print.debug(
73+
'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' +
74+
`Reason: ${reason}`,
75+
);
76+
}
77+
}
78+
79+
// Get the drift results, including resources with UNKNOWN status
80+
const driftResults = await cfn.send(
81+
new DescribeStackResourceDriftsCommand({
82+
StackName: stackName,
83+
}),
84+
);
85+
86+
// Log info for resources with NOT_CHECKED status (expected behavior)
87+
const notCheckedResources = driftResults.StackResourceDrifts?.filter((drift) => drift.StackResourceDriftStatus === 'NOT_CHECKED');
88+
89+
if (notCheckedResources && notCheckedResources.length > 0 && print?.debug) {
90+
print.debug(
91+
'Some resources were not checked for drift (resource type does not support drift detection):\n' +
92+
notCheckedResources.map((r) => ` - ${r.LogicalResourceId} (${r.ResourceType})`).join('\n'),
93+
);
94+
}
95+
96+
// Log warning for resources with UNKNOWN status (actual problems)
97+
const unknownResources = driftResults.StackResourceDrifts?.filter((drift) => drift.StackResourceDriftStatus === 'UNKNOWN');
98+
99+
if (unknownResources && unknownResources.length > 0 && print?.debug) {
100+
print.debug(
101+
'WARNING: Drift detection failed for some resources. This may be due to insufficient permissions or throttling:\n' +
102+
unknownResources.map((r) => ` - ${r.LogicalResourceId} (${r.ResourceType})`).join('\n'),
103+
);
104+
}
105+
106+
logger.logInfo({ message: `detectStackDrift.complete: ${stackName}, ${driftResults.StackResourceDrifts?.length} resources` });
107+
return driftResults;
108+
}
109+
110+
/**
111+
* Wait for a drift detection operation to complete
112+
* Based on CDK's polling strategy: 5-minute timeout, 2-second polling interval, 10-second user feedback
113+
*/
114+
async function waitForDriftDetection(
115+
cfn: CloudFormationClient,
116+
driftDetectionId: string,
117+
print?: { info: (msg: string) => void },
118+
): Promise<DescribeStackDriftDetectionStatusCommandOutput | undefined> {
119+
const maxWaitForDrift = 300_000; // 5 minutes max
120+
const timeBetweenOutputs = 10_000; // User feedback every 10 seconds
121+
const timeBetweenApiCalls = 2_000; // API calls every 2 seconds for rate limiting
122+
123+
const deadline = Date.now() + maxWaitForDrift;
124+
let checkIn = Date.now() + timeBetweenOutputs;
125+
126+
// eslint-disable-next-line no-constant-condition
127+
while (true) {
128+
const response = await cfn.send(
129+
new DescribeStackDriftDetectionStatusCommand({
130+
StackDriftDetectionId: driftDetectionId,
131+
}),
132+
);
133+
134+
if (response.DetectionStatus === 'DETECTION_COMPLETE') {
135+
return response;
136+
}
137+
138+
if (response.DetectionStatus === 'DETECTION_FAILED') {
139+
throw new AmplifyError('CloudFormationTemplateError', {
140+
message: `Drift detection failed: ${formatReason(response.DetectionStatusReason)}`,
141+
resolution: 'Check CloudFormation console for more details or try again.',
142+
});
143+
}
144+
145+
if (Date.now() > deadline) {
146+
throw new AmplifyError('CloudFormationTemplateError', {
147+
message: `Drift detection timed out after ${maxWaitForDrift / 1000} seconds.`,
148+
resolution: 'The stack may be too large or AWS may be experiencing issues. Try again later.',
149+
});
150+
}
151+
152+
if (Date.now() > checkIn && print?.info) {
153+
print.info('Waiting for drift detection to complete...');
154+
checkIn = Date.now() + timeBetweenOutputs;
155+
}
156+
157+
// Wait between API calls to avoid rate limiting (CDK does this too)
158+
await new Promise((resolve) => setTimeout(resolve, timeBetweenApiCalls));
159+
}
160+
}
161+
162+
/**
163+
* Detect drift recursively for a stack and all its nested stacks
164+
*
165+
* This is necessary for Amplify because category stacks (auth, storage, etc.)
166+
* are deployed as nested stacks and are not separate artifacts.
167+
*
168+
* @param cfn - CloudFormation client
169+
* @param stackName - the name of the root stack to check for drift
170+
* @param print - printer for user feedback
171+
* @param level - current nesting level (for tracking)
172+
* @param parentPrefix - prefix for nested stack names (for display)
173+
* @returns combined drift results for root and all nested stacks
174+
*/
175+
export async function detectStackDriftRecursive(
176+
cfn: CloudFormationClient,
177+
stackName: string,
178+
print?: { info: (msg: string) => void; debug: (msg: string) => void; warning: (msg: string) => void },
179+
level = 0,
180+
parentPrefix = '',
181+
): Promise<CombinedDriftResults> {
182+
logger.logInfo({ message: `detectStackDriftRecursive: ${stackName} (level ${level})` });
183+
184+
// Detect drift on the current stack
185+
const currentStackDrifts = await detectStackDrift(cfn, stackName, print);
186+
187+
// Get all resources in the current stack to find nested stacks
188+
const stackResources = await cfn.send(
189+
new DescribeStackResourcesCommand({
190+
StackName: stackName,
191+
}),
192+
);
193+
194+
// Find all nested stacks in the current stack
195+
const nestedStacks = stackResources.StackResources?.filter((resource) => resource.ResourceType === 'AWS::CloudFormation::Stack') || [];
196+
197+
if (nestedStacks.length > 0 && print?.info) {
198+
print.info(`Found ${nestedStacks.length} nested stack(s)`);
199+
}
200+
201+
// Initialize results
202+
const nestedStackDrifts = new Map<string, DescribeStackResourceDriftsCommandOutput>();
203+
const nestedStackPhysicalIds = new Map<string, string>();
204+
205+
// Process each nested stack recursively
206+
for (const nestedStack of nestedStacks) {
207+
if (!nestedStack.LogicalResourceId || !nestedStack.PhysicalResourceId) {
208+
continue;
209+
}
210+
211+
// Skip if the nested stack has been deleted
212+
if (nestedStack.ResourceStatus?.includes('DELETE')) {
213+
if (print?.debug) {
214+
print.debug(`Skipping deleted nested stack: ${nestedStack.LogicalResourceId}`);
215+
}
216+
continue;
217+
}
218+
219+
try {
220+
// Show message for this nested stack (no indentation in the message itself)
221+
if (print?.info) {
222+
print.info(`Checking drift for nested stack: ${nestedStack.LogicalResourceId}`);
223+
}
224+
225+
// Extract stack name from PhysicalResourceId
226+
// Handle both ARN format and direct stack names
227+
let nestedStackName = nestedStack.PhysicalResourceId;
228+
229+
// ARN format: arn:aws:cloudformation:region:account:stack/stack-name/id
230+
if (nestedStackName.startsWith('arn:aws:cloudformation:')) {
231+
try {
232+
// Split by colon first to get the resource part
233+
const arnComponents = nestedStackName.split(':');
234+
if (arnComponents.length >= 6) {
235+
// The 6th component contains stack/stack-name/id
236+
const resourcePart = arnComponents[5];
237+
if (resourcePart && resourcePart.startsWith('stack/')) {
238+
// Extract stack name from stack/stack-name/id
239+
const stackParts = resourcePart.split('/');
240+
if (stackParts.length >= 2) {
241+
nestedStackName = stackParts[1];
242+
}
243+
}
244+
}
245+
} catch (e) {
246+
// If parsing fails, log and use the original value
247+
logger.logInfo({
248+
message: `Failed to parse ARN for nested stack ${nestedStack.LogicalResourceId}: ${nestedStackName}. Using original value.`,
249+
});
250+
}
251+
}
252+
253+
// Validate the extracted name
254+
if (!nestedStackName || nestedStackName === nestedStack.PhysicalResourceId) {
255+
logger.logInfo({
256+
message: `Could not extract stack name from PhysicalResourceId: ${nestedStack.PhysicalResourceId}`,
257+
});
258+
}
259+
260+
// Store the mapping
261+
nestedStackPhysicalIds.set(nestedStack.LogicalResourceId, nestedStackName);
262+
263+
// Recursively detect drift for this nested stack and all its children
264+
const nestedResults = await detectStackDriftRecursive(cfn, nestedStackName, print, level + 1, nestedStack.LogicalResourceId);
265+
266+
// Store the direct drift results for this nested stack
267+
nestedStackDrifts.set(nestedStack.LogicalResourceId, nestedResults.rootStackDrifts);
268+
269+
// Merge the nested stack's nested results into our results
270+
nestedResults.nestedStackDrifts.forEach((value, key) => {
271+
// Prefix the key with the parent stack's logical ID for clarity
272+
const fullKey = `${nestedStack.LogicalResourceId}/${key}`;
273+
nestedStackDrifts.set(fullKey, value);
274+
});
275+
276+
// Also merge the physical IDs
277+
nestedResults.nestedStackPhysicalIds.forEach((value, key) => {
278+
const fullKey = `${nestedStack.LogicalResourceId}/${key}`;
279+
nestedStackPhysicalIds.set(fullKey, value);
280+
});
281+
282+
logger.logInfo({
283+
message: `detectStackDriftRecursive.nestedStack: ${nestedStack.LogicalResourceId}, ${nestedResults.rootStackDrifts.StackResourceDrifts?.length} direct resources, ${nestedResults.nestedStackDrifts.size} sub-stacks`,
284+
});
285+
} catch (error: any) {
286+
// Log error but continue checking other nested stacks
287+
if (print?.warning) {
288+
print.warning(`Failed to check drift for nested stack ${nestedStack.LogicalResourceId}: ${error.message}`);
289+
}
290+
logger.logError({
291+
message: `detectStackDriftRecursive.nestedStack.error: ${nestedStack.LogicalResourceId}`,
292+
error: error,
293+
});
294+
}
295+
}
296+
297+
logger.logInfo({
298+
message: `detectStackDriftRecursive.complete: ${stackName} (level ${level}), ${nestedStackDrifts.size} total nested stacks`,
299+
});
300+
301+
return {
302+
rootStackDrifts: currentStackDrifts,
303+
nestedStackDrifts,
304+
nestedStackPhysicalIds,
305+
};
306+
}
307+
308+
/**
309+
* Format a reason string, handling undefined/null values
310+
*/
311+
function formatReason(reason: string | undefined): string {
312+
return reason || 'No reason provided';
313+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Drift detection module for Amplify CloudFormation stacks
3+
* Based on AWS CDK CLI drift detection implementation
4+
*/
5+
6+
export { detectStackDrift, detectStackDriftRecursive, type CombinedDriftResults } from './detect-stack-drift';
7+
export { DriftFormatter, type DriftDisplayFormat } from './services/drift-formatter';
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Amplify configuration service
3+
* Handles Amplify project configuration and validation
4+
*/
5+
6+
import { stateManager, pathManager, AmplifyError } from '@aws-amplify/amplify-cli-core';
7+
8+
/**
9+
* Service for Amplify project configuration
10+
*/
11+
export class AmplifyConfigService {
12+
/**
13+
* Validate this is an Amplify project
14+
*/
15+
public validateAmplifyProject(): void {
16+
try {
17+
const projectPath = pathManager.findProjectRoot();
18+
if (!projectPath) {
19+
throw new Error('Not an Amplify project');
20+
}
21+
} catch (error) {
22+
throw new AmplifyError('ProjectNotFoundError', {
23+
message: 'Not an Amplify project.',
24+
resolution: 'Run this command from an Amplify project directory.',
25+
});
26+
}
27+
}
28+
29+
/**
30+
* Get the root stack name from Amplify configuration
31+
*/
32+
public getRootStackName(): string {
33+
const projectPath = pathManager.findProjectRoot();
34+
const meta = stateManager.getMeta(projectPath);
35+
36+
const stackName = meta?.providers?.awscloudformation?.StackName;
37+
if (!stackName) {
38+
throw new AmplifyError('StackNotFoundError', {
39+
message: 'Stack information not found in amplify-meta.json.',
40+
resolution: 'Has the project been deployed? Run "amplify push" to deploy your project.',
41+
});
42+
}
43+
44+
return stackName;
45+
}
46+
47+
/**
48+
* Extract project name from stack name
49+
*/
50+
public extractProjectName(stackName: string): string {
51+
// Extract project name from stack name (e.g., "amplify-my-project-dev-123" -> "my-project")
52+
const match = stackName.match(/^amplify-([^-]+)-/);
53+
return match ? match[1] : stackName;
54+
}
55+
56+
/**
57+
* Extract category from logical ID
58+
*/
59+
public extractCategory(logicalId: string): string {
60+
const idLower = logicalId.toLowerCase();
61+
if (idLower.includes('auth')) return 'auth';
62+
if (idLower.includes('storage')) return 'storage';
63+
if (idLower.includes('function')) return 'function';
64+
if (idLower.includes('api')) return 'api';
65+
if (idLower.includes('hosting')) return 'hosting';
66+
if (idLower.includes('analytics')) return 'analytics';
67+
return 'other';
68+
}
69+
}

0 commit comments

Comments
 (0)