@friggframework/devtools 2.0.0--canary.474.86c5119.0 → 2.0.0--canary.474.898a56c.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/infrastructure/domains/health/application/use-cases/__tests__/mismatch-analyzer-method-name.test.js +167 -0
- package/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js +762 -0
- package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.js +154 -1
- package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.js +20 -5
- package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js +3 -3
- package/infrastructure/domains/health/docs/ACME-DEV-DRIFT-ANALYSIS.md +267 -0
- package/infrastructure/domains/health/docs/BUILD-VS-DEPLOYED-TEMPLATE-ANALYSIS.md +324 -0
- package/infrastructure/domains/health/docs/ORPHAN-DETECTION-ANALYSIS.md +386 -0
- package/infrastructure/domains/health/docs/SPEC-CLEANUP-COMMAND.md +1419 -0
- package/infrastructure/domains/health/docs/TDD-IMPLEMENTATION-SUMMARY.md +391 -0
- package/infrastructure/domains/health/docs/TEMPLATE-COMPARISON-IMPLEMENTATION.md +551 -0
- package/infrastructure/domains/health/domain/services/__tests__/health-score-percentage-based.test.js +380 -0
- package/infrastructure/domains/health/domain/services/__tests__/logical-id-mapper.test.js +645 -0
- package/infrastructure/domains/health/domain/services/__tests__/template-parser.test.js +496 -0
- package/infrastructure/domains/health/domain/services/health-score-calculator.js +174 -91
- package/infrastructure/domains/health/domain/services/health-score-calculator.test.js +332 -228
- package/infrastructure/domains/health/domain/services/logical-id-mapper.js +330 -0
- package/infrastructure/domains/health/domain/services/template-parser.js +245 -0
- package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-cfn-tagged.test.js +312 -0
- package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-multi-stack.test.js +367 -0
- package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-relationship-analysis.test.js +432 -0
- package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.js +108 -14
- package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js +69 -12
- package/package.json +6 -6
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TDD Test for Orphan Detection with Relationship Analysis
|
|
3
|
+
*
|
|
4
|
+
* PROBLEM: When multiple orphaned resources of the same type are detected,
|
|
5
|
+
* we need to help users identify which ones are actually relevant to import.
|
|
6
|
+
*
|
|
7
|
+
* Real-world example from acme-integrations-dev:
|
|
8
|
+
* - 3 orphaned VPCs detected (vpc-0eadd96976d29ede7, vpc-0e2351eac99adcb83, vpc-020a0365610c05f0b)
|
|
9
|
+
* - 10 orphaned subnets detected
|
|
10
|
+
* - 3 orphaned security groups detected
|
|
11
|
+
* - 16 Lambda functions with VPC drift (all reference same VPC/subnets/SGs)
|
|
12
|
+
*
|
|
13
|
+
* ACTUAL AWS REALITY (verified 2025-10-27):
|
|
14
|
+
* - Lambda functions use DEFAULT VPC: vpc-01f21101d4ed6db59 (172.31.0.0/16, no Frigg tags)
|
|
15
|
+
* - Lambda subnets: subnet-020d32e3ca398a041, subnet-0c186318804aba790 (in default VPC)
|
|
16
|
+
* - Lambda security group: sg-0aca40438d17344c4 (in default VPC)
|
|
17
|
+
* - 3 orphaned VPCs are ALL unused (10.0.0.0/16, all have Frigg CFN tags but not in stack)
|
|
18
|
+
* - CloudFormation stack has ZERO VPCs (stack doesn't manage VPC)
|
|
19
|
+
*
|
|
20
|
+
* SOLUTION: Analyze relationships between drifted resources and orphaned resources
|
|
21
|
+
* to identify which orphaned resources are actually being referenced.
|
|
22
|
+
*
|
|
23
|
+
* Key Insight: If 16 Lambda functions are all drifted to use:
|
|
24
|
+
* - VPC: vpc-01f21101d4ed6db59 (default VPC, no tags)
|
|
25
|
+
* - SecurityGroup: sg-0aca40438d17344c4
|
|
26
|
+
* - Subnets: subnet-020d32e3ca398a041, subnet-0c186318804aba790
|
|
27
|
+
*
|
|
28
|
+
* But we detect 3 orphaned VPCs with Frigg tags:
|
|
29
|
+
* - vpc-0eadd96976d29ede7 (10.0.0.0/16)
|
|
30
|
+
* - vpc-0e2351eac99adcb83 (10.0.0.0/16)
|
|
31
|
+
* - vpc-020a0365610c05f0b (10.0.0.0/16)
|
|
32
|
+
*
|
|
33
|
+
* Then NONE of these orphaned VPCs are actually being used! They're old unused
|
|
34
|
+
* resources from previous deployments that should be cleaned up, not imported.
|
|
35
|
+
*
|
|
36
|
+
* RELATIONSHIP ANALYSIS:
|
|
37
|
+
* 1. Extract referenced resource IDs from drift issues
|
|
38
|
+
* - Lambda VpcConfig.SecurityGroupIds → extract SG IDs
|
|
39
|
+
* - Lambda VpcConfig.SubnetIds → extract subnet IDs
|
|
40
|
+
* - Subnets reference VPCs
|
|
41
|
+
* - Security groups reference VPCs
|
|
42
|
+
*
|
|
43
|
+
* 2. Group orphaned resources by type and count
|
|
44
|
+
*
|
|
45
|
+
* 3. For each orphaned resource:
|
|
46
|
+
* - Check if it's referenced by any drifted resource
|
|
47
|
+
* - If yes, mark as "actively used" (high priority to import)
|
|
48
|
+
* - If no, mark as "unused orphan" (likely old/irrelevant)
|
|
49
|
+
*
|
|
50
|
+
* 4. Add relationship metadata to orphaned resources:
|
|
51
|
+
* - referencedBy: [list of resources that reference this orphan]
|
|
52
|
+
* - relatedOrphans: [other orphans in same VPC/group]
|
|
53
|
+
*
|
|
54
|
+
* 5. When multiple resources of same type exist, show warning:
|
|
55
|
+
* "Multiple VPCs detected. Review relationships before importing."
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
const AWSResourceDetector = require('../aws-resource-detector');
|
|
59
|
+
const StackIdentifier = require('../../../domain/value-objects/stack-identifier');
|
|
60
|
+
const Issue = require('../../../domain/entities/issue');
|
|
61
|
+
const PropertyMismatch = require('../../../domain/entities/property-mismatch');
|
|
62
|
+
const PropertyMutability = require('../../../domain/value-objects/property-mutability');
|
|
63
|
+
|
|
64
|
+
// Mock AWS SDK
|
|
65
|
+
jest.mock('@aws-sdk/client-ec2', () => ({
|
|
66
|
+
EC2Client: jest.fn(),
|
|
67
|
+
DescribeVpcsCommand: jest.fn(),
|
|
68
|
+
DescribeSubnetsCommand: jest.fn(),
|
|
69
|
+
DescribeSecurityGroupsCommand: jest.fn(),
|
|
70
|
+
DescribeRouteTablesCommand: jest.fn(),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
jest.mock('@aws-sdk/client-rds', () => ({
|
|
74
|
+
RDSClient: jest.fn(),
|
|
75
|
+
DescribeDBClustersCommand: jest.fn(),
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
jest.mock('@aws-sdk/client-kms', () => ({
|
|
79
|
+
KMSClient: jest.fn(),
|
|
80
|
+
ListKeysCommand: jest.fn(),
|
|
81
|
+
DescribeKeyCommand: jest.fn(),
|
|
82
|
+
ListAliasesCommand: jest.fn(),
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
describe('Orphan Detection with Relationship Analysis (TDD)', () => {
|
|
86
|
+
let detector;
|
|
87
|
+
let mockEC2Send;
|
|
88
|
+
let mockRDSSend;
|
|
89
|
+
let mockKMSSend;
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
jest.clearAllMocks();
|
|
93
|
+
|
|
94
|
+
// Mock EC2 client
|
|
95
|
+
mockEC2Send = jest.fn();
|
|
96
|
+
const { EC2Client } = require('@aws-sdk/client-ec2');
|
|
97
|
+
EC2Client.mockImplementation(() => ({ send: mockEC2Send }));
|
|
98
|
+
|
|
99
|
+
// Mock RDS client
|
|
100
|
+
mockRDSSend = jest.fn().mockResolvedValue({ DBClusters: [] });
|
|
101
|
+
const { RDSClient } = require('@aws-sdk/client-rds');
|
|
102
|
+
RDSClient.mockImplementation(() => ({ send: mockRDSSend }));
|
|
103
|
+
|
|
104
|
+
// Mock KMS client
|
|
105
|
+
mockKMSSend = jest.fn().mockResolvedValue({ Keys: [] });
|
|
106
|
+
const { KMSClient } = require('@aws-sdk/client-kms');
|
|
107
|
+
KMSClient.mockImplementation(() => ({ send: mockKMSSend }));
|
|
108
|
+
|
|
109
|
+
detector = new AWSResourceDetector({ region: 'us-east-1' });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('Real-world scenario: acme-integrations-dev multiple VPCs', () => {
|
|
113
|
+
test('should analyze relationships between drifted Lambdas and orphaned VPC resources', async () => {
|
|
114
|
+
const stackIdentifier = new StackIdentifier({
|
|
115
|
+
stackName: 'acme-integrations-dev',
|
|
116
|
+
region: 'us-east-1',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Stack has 16 Lambda functions (all with VPC drift)
|
|
120
|
+
const stackResources = [
|
|
121
|
+
{
|
|
122
|
+
logicalId: 'AttioFunction',
|
|
123
|
+
physicalId: 'acme-integrations-dev-attio',
|
|
124
|
+
resourceType: 'AWS::Lambda::Function',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
logicalId: 'AuthFunction',
|
|
128
|
+
physicalId: 'acme-integrations-dev-auth',
|
|
129
|
+
resourceType: 'AWS::Lambda::Function',
|
|
130
|
+
},
|
|
131
|
+
// ... 14 more Lambda functions
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
// Mock EC2 responses - 3 VPCs, 10 subnets, 3 security groups
|
|
135
|
+
mockEC2Send.mockImplementation((command) => {
|
|
136
|
+
if (command.constructor.name === 'DescribeVpcsCommand') {
|
|
137
|
+
return Promise.resolve({
|
|
138
|
+
Vpcs: [
|
|
139
|
+
{
|
|
140
|
+
// VPC #1: Old VPC with Frigg tags but not in stack
|
|
141
|
+
VpcId: 'vpc-0eadd96976d29ede7',
|
|
142
|
+
CidrBlock: '10.0.0.0/16',
|
|
143
|
+
State: 'available',
|
|
144
|
+
Tags: [
|
|
145
|
+
{
|
|
146
|
+
Key: 'aws:cloudformation:stack-name',
|
|
147
|
+
Value: 'acme-integrations-dev',
|
|
148
|
+
},
|
|
149
|
+
{ Key: 'Name', Value: 'Old VPC' },
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
// VPC #2: Another old VPC
|
|
154
|
+
VpcId: 'vpc-0e2351eac99adcb83',
|
|
155
|
+
CidrBlock: '10.1.0.0/16',
|
|
156
|
+
State: 'available',
|
|
157
|
+
Tags: [
|
|
158
|
+
{
|
|
159
|
+
Key: 'aws:cloudformation:stack-name',
|
|
160
|
+
Value: 'acme-integrations-dev',
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
// VPC #3: Current VPC that Lambdas actually use
|
|
166
|
+
VpcId: 'vpc-020a0365610c05f0b',
|
|
167
|
+
CidrBlock: '10.2.0.0/16',
|
|
168
|
+
State: 'available',
|
|
169
|
+
Tags: [
|
|
170
|
+
{
|
|
171
|
+
Key: 'aws:cloudformation:stack-name',
|
|
172
|
+
Value: 'acme-integrations-dev',
|
|
173
|
+
},
|
|
174
|
+
{ Key: 'Name', Value: 'Current VPC' },
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (command.constructor.name === 'DescribeSubnetsCommand') {
|
|
182
|
+
return Promise.resolve({
|
|
183
|
+
Subnets: [
|
|
184
|
+
// Subnets in VPC #3 (current VPC) - these are the ones Lambdas reference
|
|
185
|
+
{
|
|
186
|
+
SubnetId: 'subnet-020d32e3ca398a041',
|
|
187
|
+
VpcId: 'vpc-020a0365610c05f0b',
|
|
188
|
+
CidrBlock: '10.2.1.0/24',
|
|
189
|
+
AvailabilityZone: 'us-east-1a',
|
|
190
|
+
State: 'available',
|
|
191
|
+
Tags: [
|
|
192
|
+
{
|
|
193
|
+
Key: 'aws:cloudformation:stack-name',
|
|
194
|
+
Value: 'acme-integrations-dev',
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
SubnetId: 'subnet-0c186318804aba790',
|
|
200
|
+
VpcId: 'vpc-020a0365610c05f0b',
|
|
201
|
+
CidrBlock: '10.2.2.0/24',
|
|
202
|
+
AvailabilityZone: 'us-east-1b',
|
|
203
|
+
State: 'available',
|
|
204
|
+
Tags: [
|
|
205
|
+
{
|
|
206
|
+
Key: 'aws:cloudformation:stack-name',
|
|
207
|
+
Value: 'acme-integrations-dev',
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
// Subnets in old VPCs (unused)
|
|
212
|
+
{
|
|
213
|
+
SubnetId: 'subnet-0ad31b5ee6814b8fa',
|
|
214
|
+
VpcId: 'vpc-0eadd96976d29ede7',
|
|
215
|
+
CidrBlock: '10.0.1.0/24',
|
|
216
|
+
AvailabilityZone: 'us-east-1a',
|
|
217
|
+
State: 'available',
|
|
218
|
+
Tags: [
|
|
219
|
+
{
|
|
220
|
+
Key: 'aws:cloudformation:stack-name',
|
|
221
|
+
Value: 'acme-integrations-dev',
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
// ... 7 more unused subnets
|
|
226
|
+
],
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (command.constructor.name === 'DescribeSecurityGroupsCommand') {
|
|
231
|
+
return Promise.resolve({
|
|
232
|
+
SecurityGroups: [
|
|
233
|
+
// SG in current VPC (unused orphan - Lambdas use different SG)
|
|
234
|
+
{
|
|
235
|
+
GroupId: 'sg-07c01370e830b6ad6',
|
|
236
|
+
GroupName: 'default',
|
|
237
|
+
VpcId: 'vpc-020a0365610c05f0b',
|
|
238
|
+
Tags: [
|
|
239
|
+
{
|
|
240
|
+
Key: 'aws:cloudformation:stack-name',
|
|
241
|
+
Value: 'acme-integrations-dev',
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
// SGs in old VPCs (unused)
|
|
246
|
+
{
|
|
247
|
+
GroupId: 'sg-03abddb7fb50aeaff',
|
|
248
|
+
GroupName: 'default',
|
|
249
|
+
VpcId: 'vpc-0eadd96976d29ede7',
|
|
250
|
+
Tags: [
|
|
251
|
+
{
|
|
252
|
+
Key: 'aws:cloudformation:stack-name',
|
|
253
|
+
Value: 'acme-integrations-dev',
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
GroupId: 'sg-027f44ad46727df93',
|
|
259
|
+
GroupName: 'default',
|
|
260
|
+
VpcId: 'vpc-0e2351eac99adcb83',
|
|
261
|
+
Tags: [
|
|
262
|
+
{
|
|
263
|
+
Key: 'aws:cloudformation:stack-name',
|
|
264
|
+
Value: 'acme-integrations-dev',
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return Promise.resolve({});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Drift issues: Lambda functions reference subnets in current VPC
|
|
276
|
+
const driftIssues = [
|
|
277
|
+
Issue.propertyMismatch({
|
|
278
|
+
resourceType: 'AWS::Lambda::Function',
|
|
279
|
+
resourceId: 'acme-integrations-dev-attio',
|
|
280
|
+
mismatch: new PropertyMismatch({
|
|
281
|
+
propertyPath: 'VpcConfig.SubnetIds',
|
|
282
|
+
expectedValue: 'subnet-00ab9e0502e66aac3,subnet-00d085a52937aaf91',
|
|
283
|
+
actualValue: 'subnet-020d32e3ca398a041,subnet-0c186318804aba790', // ← References current VPC subnets
|
|
284
|
+
mutability: PropertyMutability.MUTABLE,
|
|
285
|
+
}),
|
|
286
|
+
}),
|
|
287
|
+
Issue.propertyMismatch({
|
|
288
|
+
resourceType: 'AWS::Lambda::Function',
|
|
289
|
+
resourceId: 'acme-integrations-dev-auth',
|
|
290
|
+
mismatch: new PropertyMismatch({
|
|
291
|
+
propertyPath: 'VpcConfig.SubnetIds',
|
|
292
|
+
expectedValue: 'subnet-00ab9e0502e66aac3,subnet-00d085a52937aaf91',
|
|
293
|
+
actualValue: 'subnet-020d32e3ca398a041,subnet-0c186318804aba790', // ← Same subnets
|
|
294
|
+
mutability: PropertyMutability.MUTABLE,
|
|
295
|
+
}),
|
|
296
|
+
}),
|
|
297
|
+
// ... 14 more Lambda functions with same subnet drift
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
// Act
|
|
301
|
+
const orphans = await detector.findOrphanedResourcesWithRelationships({
|
|
302
|
+
stackIdentifier,
|
|
303
|
+
stackResources,
|
|
304
|
+
driftIssues,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Assert: Should identify relationship metadata
|
|
308
|
+
expect(orphans.length).toBeGreaterThan(0);
|
|
309
|
+
|
|
310
|
+
// Find the orphaned subnets that are actually referenced
|
|
311
|
+
const referencedSubnets = orphans.filter(
|
|
312
|
+
(o) =>
|
|
313
|
+
o.resourceType === 'AWS::EC2::Subnet' &&
|
|
314
|
+
(o.physicalId === 'subnet-020d32e3ca398a041' ||
|
|
315
|
+
o.physicalId === 'subnet-0c186318804aba790')
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// These subnets should be marked as "actively used"
|
|
319
|
+
for (const subnet of referencedSubnets) {
|
|
320
|
+
expect(subnet.metadata).toHaveProperty('referencedBy');
|
|
321
|
+
expect(subnet.metadata.referencedBy.length).toBeGreaterThan(0);
|
|
322
|
+
expect(subnet.metadata.isActivelyUsed).toBe(true);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Find the VPC that contains these subnets
|
|
326
|
+
const currentVpc = orphans.find((o) => o.physicalId === 'vpc-020a0365610c05f0b');
|
|
327
|
+
|
|
328
|
+
if (currentVpc) {
|
|
329
|
+
expect(currentVpc.metadata).toHaveProperty('containsReferencedResources');
|
|
330
|
+
expect(currentVpc.metadata.containsReferencedResources).toBe(true);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Old VPCs should NOT be marked as actively used
|
|
334
|
+
const oldVpc1 = orphans.find((o) => o.physicalId === 'vpc-0eadd96976d29ede7');
|
|
335
|
+
if (oldVpc1) {
|
|
336
|
+
expect(oldVpc1.metadata?.isActivelyUsed).toBeFalsy();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('should flag multiple resources of same type for manual review', async () => {
|
|
341
|
+
const stackIdentifier = new StackIdentifier({
|
|
342
|
+
stackName: 'acme-integrations-dev',
|
|
343
|
+
region: 'us-east-1',
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const stackResources = [
|
|
347
|
+
{
|
|
348
|
+
logicalId: 'MyLambda',
|
|
349
|
+
physicalId: 'my-lambda',
|
|
350
|
+
resourceType: 'AWS::Lambda::Function',
|
|
351
|
+
},
|
|
352
|
+
];
|
|
353
|
+
|
|
354
|
+
// Mock 3 orphaned VPCs
|
|
355
|
+
mockEC2Send.mockResolvedValue({
|
|
356
|
+
Vpcs: [
|
|
357
|
+
{
|
|
358
|
+
VpcId: 'vpc-1',
|
|
359
|
+
CidrBlock: '10.0.0.0/16',
|
|
360
|
+
State: 'available',
|
|
361
|
+
Tags: [{ Key: 'aws:cloudformation:stack-name', Value: 'acme-integrations-dev' }],
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
VpcId: 'vpc-2',
|
|
365
|
+
CidrBlock: '10.1.0.0/16',
|
|
366
|
+
State: 'available',
|
|
367
|
+
Tags: [{ Key: 'aws:cloudformation:stack-name', Value: 'acme-integrations-dev' }],
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
VpcId: 'vpc-3',
|
|
371
|
+
CidrBlock: '10.2.0.0/16',
|
|
372
|
+
State: 'available',
|
|
373
|
+
Tags: [{ Key: 'aws:cloudformation:stack-name', Value: 'acme-integrations-dev' }],
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const result = await detector.findOrphanedResourcesWithRelationships({
|
|
379
|
+
stackIdentifier,
|
|
380
|
+
stackResources,
|
|
381
|
+
driftIssues: [],
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Should include warning metadata
|
|
385
|
+
const summary = detector.analyzeOrphanSummary(result);
|
|
386
|
+
|
|
387
|
+
expect(summary.warnings).toContain(
|
|
388
|
+
'Multiple VPCs detected (3). Review relationships before importing.'
|
|
389
|
+
);
|
|
390
|
+
expect(summary.multipleResourceTypes).toContain('AWS::EC2::VPC');
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe('Helper method: extractReferencedResourceIds', () => {
|
|
395
|
+
test('should extract subnet IDs from VpcConfig.SubnetIds drift', () => {
|
|
396
|
+
const driftIssue = Issue.propertyMismatch({
|
|
397
|
+
resourceType: 'AWS::Lambda::Function',
|
|
398
|
+
resourceId: 'my-lambda',
|
|
399
|
+
mismatch: new PropertyMismatch({
|
|
400
|
+
propertyPath: 'VpcConfig.SubnetIds',
|
|
401
|
+
expectedValue: 'subnet-old-1,subnet-old-2',
|
|
402
|
+
actualValue: 'subnet-020d32e3ca398a041,subnet-0c186318804aba790',
|
|
403
|
+
mutability: PropertyMutability.MUTABLE,
|
|
404
|
+
}),
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const detector = new AWSResourceDetector({ region: 'us-east-1' });
|
|
408
|
+
const referenced = detector._extractReferencedResourceIds([driftIssue]);
|
|
409
|
+
|
|
410
|
+
expect(referenced.subnetIds).toContain('subnet-020d32e3ca398a041');
|
|
411
|
+
expect(referenced.subnetIds).toContain('subnet-0c186318804aba790');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('should extract security group IDs from VpcConfig.SecurityGroupIds drift', () => {
|
|
415
|
+
const driftIssue = Issue.propertyMismatch({
|
|
416
|
+
resourceType: 'AWS::Lambda::Function',
|
|
417
|
+
resourceId: 'my-lambda',
|
|
418
|
+
mismatch: new PropertyMismatch({
|
|
419
|
+
propertyPath: 'VpcConfig.SecurityGroupIds',
|
|
420
|
+
expectedValue: 'sg-old',
|
|
421
|
+
actualValue: 'sg-0aca40438d17344c4',
|
|
422
|
+
mutability: PropertyMutability.MUTABLE,
|
|
423
|
+
}),
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const detector = new AWSResourceDetector({ region: 'us-east-1' });
|
|
427
|
+
const referenced = detector._extractReferencedResourceIds([driftIssue]);
|
|
428
|
+
|
|
429
|
+
expect(referenced.securityGroupIds).toContain('sg-0aca40438d17344c4');
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
});
|
|
@@ -209,34 +209,127 @@ class AWSResourceDetector extends IResourceDetector {
|
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
/**
|
|
212
|
-
* Find orphaned resources
|
|
212
|
+
* Find orphaned resources for a specific stack
|
|
213
|
+
*
|
|
214
|
+
* Orphaned resources are resources that:
|
|
215
|
+
* 1. Have aws:cloudformation:stack-name tag matching target stack
|
|
216
|
+
* OR no CloudFormation tags but exist in region with stack resources
|
|
217
|
+
* 2. Physical ID is NOT in the actual CloudFormation stack resources
|
|
218
|
+
* 3. Are not default AWS resources (default VPC, AWS-managed KMS keys)
|
|
219
|
+
*
|
|
220
|
+
* NOTE: We DON'T trust CloudFormation tags alone. Resources can have
|
|
221
|
+
* CloudFormation tags but not actually be in the stack (manual tagging,
|
|
222
|
+
* failed imports, removed from stack but tags remain, etc.)
|
|
223
|
+
*
|
|
224
|
+
* Instead, we compare against the actual physical IDs from the stack.
|
|
225
|
+
*
|
|
226
|
+
* @param {Object} params
|
|
227
|
+
* @param {StackIdentifier} params.stackIdentifier - Target stack
|
|
228
|
+
* @param {Array} params.stackResources - Resources currently in stack template
|
|
229
|
+
* @returns {Promise<Array>} Orphaned resources
|
|
213
230
|
*/
|
|
214
|
-
async findOrphanedResources({
|
|
215
|
-
const types = resourceTypes.length > 0 ? resourceTypes : AWSResourceDetector.SUPPORTED_TYPES;
|
|
216
|
-
|
|
231
|
+
async findOrphanedResources({ stackIdentifier, stackResources }) {
|
|
217
232
|
const orphans = [];
|
|
218
233
|
|
|
219
|
-
|
|
220
|
-
|
|
234
|
+
// Build Set of physical IDs that are actually IN the CloudFormation stack
|
|
235
|
+
// This is the source of truth - not the tags!
|
|
236
|
+
const stackPhysicalIds = new Set(
|
|
237
|
+
stackResources.map((r) => r.physicalId).filter(Boolean)
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Check ALL supported resource types, not just types in stack
|
|
241
|
+
// Orphaned resources are by definition NOT in the stack, so we need
|
|
242
|
+
// to check all types that could potentially be orphaned
|
|
243
|
+
const typesToCheck = AWSResourceDetector.SUPPORTED_TYPES;
|
|
244
|
+
|
|
245
|
+
for (const resourceType of typesToCheck) {
|
|
246
|
+
const resources = await this.detectResources({
|
|
247
|
+
resourceType,
|
|
248
|
+
region: stackIdentifier.region,
|
|
249
|
+
});
|
|
221
250
|
|
|
222
251
|
for (const resource of resources) {
|
|
223
|
-
//
|
|
224
|
-
|
|
252
|
+
// Rule 1: Check if resource claims to be in this stack
|
|
253
|
+
const cfnStackTag = resource.tags?.['aws:cloudformation:stack-name'];
|
|
254
|
+
|
|
255
|
+
// Skip resources from different stacks
|
|
256
|
+
if (cfnStackTag && cfnStackTag !== stackIdentifier.stackName) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Rule 2: If resource has CloudFormation tag for THIS stack,
|
|
261
|
+
// check if it's actually IN the stack by physical ID
|
|
262
|
+
if (cfnStackTag === stackIdentifier.stackName) {
|
|
263
|
+
// Has CloudFormation tag - check if actually in stack
|
|
264
|
+
if (!stackPhysicalIds.has(resource.physicalId)) {
|
|
265
|
+
// Has tag but NOT in stack = ORPHAN!
|
|
266
|
+
// This is the bug we're fixing
|
|
267
|
+
orphans.push({
|
|
268
|
+
...resource,
|
|
269
|
+
isOrphaned: true,
|
|
270
|
+
reason: `Resource ${resource.physicalId} has CloudFormation tag for stack ${stackIdentifier.stackName} but is not actually managed by the stack.`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
// If it IS in stack, skip it (not orphaned)
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Rule 3: Filter out default AWS resources (no CloudFormation tag)
|
|
278
|
+
if (this._isDefaultAWSResource(resource)) {
|
|
225
279
|
continue;
|
|
226
280
|
}
|
|
227
281
|
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
282
|
+
// No CloudFormation tag - check for frigg:stack tag as fallback
|
|
283
|
+
const friggStackTag = resource.tags?.['frigg:stack'];
|
|
284
|
+
if (friggStackTag === stackIdentifier.stackName) {
|
|
285
|
+
// Has frigg tag but no CloudFormation tag and not in stack = orphan
|
|
286
|
+
if (!stackPhysicalIds.has(resource.physicalId)) {
|
|
287
|
+
orphans.push({
|
|
288
|
+
...resource,
|
|
289
|
+
isOrphaned: true,
|
|
290
|
+
reason: `Resource ${resource.physicalId} has frigg:stack tag but is not managed by CloudFormation stack ${stackIdentifier.stackName}.`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
234
294
|
}
|
|
235
295
|
}
|
|
236
296
|
|
|
237
297
|
return orphans;
|
|
238
298
|
}
|
|
239
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Check if resource is a default AWS resource that should be ignored
|
|
302
|
+
* @private
|
|
303
|
+
*/
|
|
304
|
+
_isDefaultAWSResource(resource) {
|
|
305
|
+
// Default VPC (172.31.0.0/16 CIDR block)
|
|
306
|
+
if (
|
|
307
|
+
resource.resourceType === 'AWS::EC2::VPC' &&
|
|
308
|
+
(resource.properties?.IsDefault === true ||
|
|
309
|
+
resource.properties?.CidrBlock === '172.31.0.0/16')
|
|
310
|
+
) {
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// AWS-managed KMS keys (KeyManager === 'AWS')
|
|
315
|
+
if (
|
|
316
|
+
resource.resourceType === 'AWS::KMS::Key' &&
|
|
317
|
+
resource.properties?.KeyManager === 'AWS'
|
|
318
|
+
) {
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Default security groups (GroupName === 'default')
|
|
323
|
+
if (
|
|
324
|
+
resource.resourceType === 'AWS::EC2::SecurityGroup' &&
|
|
325
|
+
resource.properties?.GroupName === 'default'
|
|
326
|
+
) {
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
240
333
|
// ========================================
|
|
241
334
|
// Private Resource Detection Methods
|
|
242
335
|
// ========================================
|
|
@@ -262,6 +355,7 @@ class AWSResourceDetector extends IResourceDetector {
|
|
|
262
355
|
VpcId: vpc.VpcId,
|
|
263
356
|
CidrBlock: vpc.CidrBlock,
|
|
264
357
|
State: vpc.State,
|
|
358
|
+
IsDefault: vpc.IsDefault,
|
|
265
359
|
EnableDnsHostnames: vpc.EnableDnsHostnames,
|
|
266
360
|
EnableDnsSupport: vpc.EnableDnsSupport,
|
|
267
361
|
},
|