@friggframework/devtools 2.0.0--canary.474.898a56c.0 → 2.0.0--canary.474.a794ea3.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/database/migration-builder.js +199 -1
- package/infrastructure/domains/database/migration-builder.test.js +73 -0
- package/infrastructure/domains/health/application/use-cases/__tests__/execute-resource-import-use-case.test.js +679 -0
- package/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js +397 -29
- package/infrastructure/domains/health/application/use-cases/execute-resource-import-use-case.js +221 -0
- package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.js +6 -0
- package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.js +162 -9
- package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.js +19 -1
- package/infrastructure/domains/health/domain/entities/issue.js +50 -1
- package/infrastructure/domains/health/domain/entities/issue.test.js +111 -0
- package/infrastructure/domains/health/domain/services/__tests__/import-progress-monitor.test.js +971 -0
- package/infrastructure/domains/health/domain/services/__tests__/import-template-generator.test.js +1150 -0
- package/infrastructure/domains/health/domain/services/__tests__/logical-id-mapper.test.js +55 -28
- package/infrastructure/domains/health/domain/services/__tests__/update-progress-monitor.test.js +419 -0
- package/infrastructure/domains/health/domain/services/import-progress-monitor.js +195 -0
- package/infrastructure/domains/health/domain/services/import-template-generator.js +435 -0
- package/infrastructure/domains/health/domain/services/logical-id-mapper.js +21 -6
- package/infrastructure/domains/health/domain/services/property-mutability-config.js +382 -0
- package/infrastructure/domains/health/domain/services/update-progress-monitor.js +192 -0
- package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.js +407 -20
- package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.test.js +698 -26
- package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.js +392 -1
- package/package.json +6 -6
package/infrastructure/domains/health/application/use-cases/execute-resource-import-use-case.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExecuteResourceImportUseCase - Execute CloudFormation Resource Import
|
|
3
|
+
*
|
|
4
|
+
* Application Layer - Use Case
|
|
5
|
+
*
|
|
6
|
+
* Business logic for executing CloudFormation import operations for orphaned resources.
|
|
7
|
+
* Orchestrates the complete import workflow including template generation, change set
|
|
8
|
+
* creation, execution monitoring, and verification.
|
|
9
|
+
*
|
|
10
|
+
* Responsibilities:
|
|
11
|
+
* - Generate import template from resources and build template
|
|
12
|
+
* - Create and execute CloudFormation import change set
|
|
13
|
+
* - Monitor import progress with resource-level updates
|
|
14
|
+
* - Verify imported resources are present in stack
|
|
15
|
+
* - Report detailed progress and results
|
|
16
|
+
*
|
|
17
|
+
* Based on: SPEC-IMPORT-EXECUTION.md (lines 712-867)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
class ExecuteResourceImportUseCase {
|
|
21
|
+
/**
|
|
22
|
+
* Create use case with required dependencies
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} params
|
|
25
|
+
* @param {Object} params.importTemplateGenerator - Generates import templates
|
|
26
|
+
* @param {Object} params.importProgressMonitor - Monitors import operations
|
|
27
|
+
* @param {Object} params.cloudFormationRepository - CloudFormation operations
|
|
28
|
+
* @param {Object} params.stackRepository - Stack template operations
|
|
29
|
+
*/
|
|
30
|
+
constructor({ importTemplateGenerator, importProgressMonitor, cloudFormationRepository, stackRepository }) {
|
|
31
|
+
if (!importTemplateGenerator) {
|
|
32
|
+
throw new Error('importTemplateGenerator is required');
|
|
33
|
+
}
|
|
34
|
+
if (!importProgressMonitor) {
|
|
35
|
+
throw new Error('importProgressMonitor is required');
|
|
36
|
+
}
|
|
37
|
+
if (!cloudFormationRepository) {
|
|
38
|
+
throw new Error('cloudFormationRepository is required');
|
|
39
|
+
}
|
|
40
|
+
if (!stackRepository) {
|
|
41
|
+
throw new Error('stackRepository is required');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.templateGenerator = importTemplateGenerator;
|
|
45
|
+
this.progressMonitor = importProgressMonitor;
|
|
46
|
+
this.cfRepo = cloudFormationRepository;
|
|
47
|
+
this.stackRepo = stackRepository;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Execute complete CloudFormation import workflow
|
|
52
|
+
*
|
|
53
|
+
* Orchestrates the full import process:
|
|
54
|
+
* 1. Generate import template
|
|
55
|
+
* 2. Create CloudFormation change set
|
|
56
|
+
* 3. Wait for change set to be ready
|
|
57
|
+
* 4. Execute change set
|
|
58
|
+
* 5. Monitor import progress
|
|
59
|
+
* 6. Verify imported resources
|
|
60
|
+
*
|
|
61
|
+
* @param {Object} params
|
|
62
|
+
* @param {Object} params.stackIdentifier - Stack name and region
|
|
63
|
+
* @param {Array<Object>} params.resourcesToImport - Resources to import
|
|
64
|
+
* @param {string} params.buildTemplatePath - Path to build template
|
|
65
|
+
* @param {Function} [params.onProgress] - Progress callback
|
|
66
|
+
* @returns {Promise<Object>} Import result with verification details
|
|
67
|
+
*/
|
|
68
|
+
async execute({ stackIdentifier, resourcesToImport, buildTemplatePath, onProgress }) {
|
|
69
|
+
try {
|
|
70
|
+
// Step 1: Generate import template
|
|
71
|
+
if (onProgress) {
|
|
72
|
+
onProgress({ step: 'generate_template', status: 'in_progress' });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { template, resourceIdentifiers } = await this.templateGenerator.generateImportTemplate({
|
|
76
|
+
resourcesToImport,
|
|
77
|
+
buildTemplatePath,
|
|
78
|
+
stackIdentifier,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (onProgress) {
|
|
82
|
+
onProgress({ step: 'generate_template', status: 'complete' });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Step 2: Create CloudFormation change set
|
|
86
|
+
if (onProgress) {
|
|
87
|
+
onProgress({ step: 'create_change_set', status: 'in_progress' });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const changeSetName = `import-orphaned-resources-${Date.now()}`;
|
|
91
|
+
const changeSet = await this.cfRepo.createChangeSet({
|
|
92
|
+
stackIdentifier,
|
|
93
|
+
changeSetName,
|
|
94
|
+
changeSetType: 'IMPORT',
|
|
95
|
+
template,
|
|
96
|
+
resourcesToImport: resourceIdentifiers,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (onProgress) {
|
|
100
|
+
onProgress({
|
|
101
|
+
step: 'create_change_set',
|
|
102
|
+
status: 'complete',
|
|
103
|
+
changeSetName,
|
|
104
|
+
changeSetId: changeSet.Id,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Step 3: Wait for change set to be ready
|
|
109
|
+
if (onProgress) {
|
|
110
|
+
onProgress({ step: 'wait_change_set', status: 'in_progress' });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await this.cfRepo.waitForChangeSet({ stackIdentifier, changeSetName });
|
|
114
|
+
|
|
115
|
+
if (onProgress) {
|
|
116
|
+
onProgress({ step: 'wait_change_set', status: 'complete' });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Step 4: Execute change set
|
|
120
|
+
if (onProgress) {
|
|
121
|
+
onProgress({ step: 'execute_import', status: 'in_progress' });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await this.cfRepo.executeChangeSet({ stackIdentifier, changeSetName });
|
|
125
|
+
|
|
126
|
+
// Step 5: Monitor import progress
|
|
127
|
+
const resourceLogicalIds = resourcesToImport.map((r) => r.logicalId);
|
|
128
|
+
const importResult = await this.progressMonitor.monitorImport({
|
|
129
|
+
stackIdentifier,
|
|
130
|
+
resourceLogicalIds,
|
|
131
|
+
onProgress: (progress) => {
|
|
132
|
+
if (onProgress) {
|
|
133
|
+
onProgress({
|
|
134
|
+
step: 'execute_import',
|
|
135
|
+
status: 'in_progress',
|
|
136
|
+
resourceProgress: progress,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (onProgress) {
|
|
143
|
+
onProgress({ step: 'execute_import', status: 'complete' });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Step 6: Verify imported resources
|
|
147
|
+
if (onProgress) {
|
|
148
|
+
onProgress({ step: 'verify', status: 'in_progress' });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const verification = await this._verifyImportedResources({
|
|
152
|
+
stackIdentifier,
|
|
153
|
+
resourceLogicalIds,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (onProgress) {
|
|
157
|
+
onProgress({ step: 'verify', status: 'complete' });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Return success result with verification details
|
|
161
|
+
return {
|
|
162
|
+
success: true,
|
|
163
|
+
importedCount: importResult.importedCount,
|
|
164
|
+
failedCount: importResult.failedCount,
|
|
165
|
+
changeSetName,
|
|
166
|
+
stackStatus: verification.stackStatus,
|
|
167
|
+
verifiedResources: verification.resources,
|
|
168
|
+
};
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// Return error with step information
|
|
171
|
+
return {
|
|
172
|
+
success: false,
|
|
173
|
+
error: error.message,
|
|
174
|
+
step: error.step || 'unknown',
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Verify that imported resources are present in the CloudFormation stack
|
|
181
|
+
*
|
|
182
|
+
* Checks that each logical ID corresponds to an actual resource in the stack
|
|
183
|
+
* and retrieves physical IDs and resource types for verification.
|
|
184
|
+
*
|
|
185
|
+
* @param {Object} params
|
|
186
|
+
* @param {Object} params.stackIdentifier - Stack name and region
|
|
187
|
+
* @param {Array<string>} params.resourceLogicalIds - Logical IDs to verify
|
|
188
|
+
* @returns {Promise<Object>} Verification result with resource details
|
|
189
|
+
* @private
|
|
190
|
+
*/
|
|
191
|
+
async _verifyImportedResources({ stackIdentifier, resourceLogicalIds }) {
|
|
192
|
+
// Get all resources from the stack
|
|
193
|
+
const stackResources = await this.cfRepo.getStackResources(stackIdentifier);
|
|
194
|
+
|
|
195
|
+
// Map each logical ID to its verification status
|
|
196
|
+
const verifiedResources = resourceLogicalIds.map((logicalId) => {
|
|
197
|
+
const resource = stackResources.find((r) => r.LogicalResourceId === logicalId);
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
logicalId,
|
|
201
|
+
verified: !!resource,
|
|
202
|
+
physicalId: resource?.PhysicalResourceId,
|
|
203
|
+
resourceType: resource?.ResourceType,
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Check if all resources were verified
|
|
208
|
+
const allVerified = verifiedResources.every((r) => r.verified);
|
|
209
|
+
|
|
210
|
+
// Get current stack status
|
|
211
|
+
const stackStatus = await this.cfRepo.getStackStatus(stackIdentifier);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
allVerified,
|
|
215
|
+
resources: verifiedResources,
|
|
216
|
+
stackStatus,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = ExecuteResourceImportUseCase;
|
package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.js
CHANGED
|
@@ -67,6 +67,8 @@ class ReconcilePropertiesUseCase {
|
|
|
67
67
|
* @param {Object} params
|
|
68
68
|
* @param {StackIdentifier} params.stackIdentifier - Stack identifier
|
|
69
69
|
* @param {string} params.logicalId - Logical resource ID
|
|
70
|
+
* @param {string} [params.physicalId] - Physical resource ID (required for resource mode)
|
|
71
|
+
* @param {string} [params.resourceType] - Resource type (required for resource mode)
|
|
70
72
|
* @param {PropertyMismatch[]} params.mismatches - Property mismatches to reconcile
|
|
71
73
|
* @param {string} [params.mode='template'] - Reconciliation mode
|
|
72
74
|
* @returns {Promise<Object>} Batch reconciliation result
|
|
@@ -74,6 +76,8 @@ class ReconcilePropertiesUseCase {
|
|
|
74
76
|
async reconcileMultipleProperties({
|
|
75
77
|
stackIdentifier,
|
|
76
78
|
logicalId,
|
|
79
|
+
physicalId,
|
|
80
|
+
resourceType,
|
|
77
81
|
mismatches,
|
|
78
82
|
mode = 'template',
|
|
79
83
|
}) {
|
|
@@ -107,6 +111,8 @@ class ReconcilePropertiesUseCase {
|
|
|
107
111
|
const batchResult = await this.propertyReconciler.reconcileMultipleProperties({
|
|
108
112
|
stackIdentifier,
|
|
109
113
|
logicalId,
|
|
114
|
+
physicalId,
|
|
115
|
+
resourceType,
|
|
110
116
|
mismatches: reconcilableProperties,
|
|
111
117
|
mode,
|
|
112
118
|
});
|
|
@@ -281,10 +281,7 @@ class RepairViaImportUseCase {
|
|
|
281
281
|
deployedTemplate,
|
|
282
282
|
});
|
|
283
283
|
|
|
284
|
-
// 5.
|
|
285
|
-
const multiResourceWarnings = this._checkForMultipleResources(mappings);
|
|
286
|
-
|
|
287
|
-
// 6. Filter out unmapped resources and prepare for import
|
|
284
|
+
// 5. Filter out unmapped resources
|
|
288
285
|
const mappedResources = mappings.filter((m) => m.logicalId !== null);
|
|
289
286
|
const unmappedResources = mappings.filter((m) => m.logicalId === null);
|
|
290
287
|
|
|
@@ -297,20 +294,31 @@ class RepairViaImportUseCase {
|
|
|
297
294
|
};
|
|
298
295
|
}
|
|
299
296
|
|
|
300
|
-
//
|
|
301
|
-
const
|
|
297
|
+
// 6. Deduplicate: Select ONE resource per logical ID based on deployed template
|
|
298
|
+
const { selectedResources, duplicates } = this._deduplicateResourcesByLogicalId(
|
|
299
|
+
mappedResources,
|
|
300
|
+
deployedTemplate
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// 7. Check for warnings
|
|
304
|
+
const multiResourceWarnings = this._checkForMultipleResources(duplicates);
|
|
305
|
+
|
|
306
|
+
// 8. Generate import-resources.json format using SELECTED resources
|
|
307
|
+
const resourcesToImport = selectedResources.map((mapping) => ({
|
|
302
308
|
ResourceType: mapping.resourceType,
|
|
303
309
|
LogicalResourceId: mapping.logicalId,
|
|
304
310
|
ResourceIdentifier: this._getResourceIdentifier(mapping),
|
|
305
311
|
}));
|
|
306
312
|
|
|
307
|
-
//
|
|
313
|
+
// 9. Return result with deduplication info
|
|
308
314
|
return {
|
|
309
315
|
success: true,
|
|
310
|
-
mappedCount:
|
|
316
|
+
mappedCount: selectedResources.length,
|
|
311
317
|
unmappedCount: unmappedResources.length,
|
|
312
|
-
|
|
318
|
+
duplicatesRemoved: duplicates.length,
|
|
319
|
+
mappings: selectedResources,
|
|
313
320
|
unmappedResources,
|
|
321
|
+
duplicates, // Resources that were filtered out
|
|
314
322
|
resourcesToImport,
|
|
315
323
|
warnings: multiResourceWarnings,
|
|
316
324
|
buildTemplatePath,
|
|
@@ -318,6 +326,151 @@ class RepairViaImportUseCase {
|
|
|
318
326
|
};
|
|
319
327
|
}
|
|
320
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Deduplicate resources: Select ONE resource per logical ID
|
|
331
|
+
* When multiple resources have the same logical ID, pick the one that's
|
|
332
|
+
* actually referenced in the deployed template.
|
|
333
|
+
*
|
|
334
|
+
* @param {Array} mappedResources - Resources with logical IDs
|
|
335
|
+
* @param {Object} deployedTemplate - Deployed CloudFormation template
|
|
336
|
+
* @returns {Object} { selectedResources, duplicates }
|
|
337
|
+
* @private
|
|
338
|
+
*/
|
|
339
|
+
_deduplicateResourcesByLogicalId(mappedResources, deployedTemplate) {
|
|
340
|
+
// Group resources by logical ID
|
|
341
|
+
const byLogicalId = {};
|
|
342
|
+
mappedResources.forEach((resource) => {
|
|
343
|
+
if (!byLogicalId[resource.logicalId]) {
|
|
344
|
+
byLogicalId[resource.logicalId] = [];
|
|
345
|
+
}
|
|
346
|
+
byLogicalId[resource.logicalId].push(resource);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Extract all physical IDs referenced in deployed template
|
|
350
|
+
const referencedIds = this._extractReferencedIdsFromTemplate(deployedTemplate);
|
|
351
|
+
|
|
352
|
+
const selectedResources = [];
|
|
353
|
+
const duplicates = [];
|
|
354
|
+
|
|
355
|
+
// For each logical ID, select ONE resource
|
|
356
|
+
Object.entries(byLogicalId).forEach(([logicalId, resources]) => {
|
|
357
|
+
if (resources.length === 1) {
|
|
358
|
+
// Only one resource - select it
|
|
359
|
+
selectedResources.push(resources[0]);
|
|
360
|
+
} else {
|
|
361
|
+
// Multiple resources - pick the one in deployed template
|
|
362
|
+
let selected = null;
|
|
363
|
+
|
|
364
|
+
// Try to find resource that's actually referenced
|
|
365
|
+
for (const resource of resources) {
|
|
366
|
+
if (this._isResourceReferenced(resource, referencedIds)) {
|
|
367
|
+
selected = resource;
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Fallback: If none are referenced, pick first one
|
|
373
|
+
if (!selected) {
|
|
374
|
+
selected = resources[0];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
selectedResources.push(selected);
|
|
378
|
+
|
|
379
|
+
// Mark others as duplicates
|
|
380
|
+
resources.forEach((r) => {
|
|
381
|
+
if (r.physicalId !== selected.physicalId) {
|
|
382
|
+
duplicates.push(r);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
return { selectedResources, duplicates };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Extract all physical resource IDs referenced in deployed template
|
|
393
|
+
* Looks for hardcoded IDs in Lambda VPC configs, security group rules, etc.
|
|
394
|
+
* @private
|
|
395
|
+
*/
|
|
396
|
+
_extractReferencedIdsFromTemplate(template) {
|
|
397
|
+
const referenced = {
|
|
398
|
+
vpcIds: new Set(),
|
|
399
|
+
subnetIds: new Set(),
|
|
400
|
+
securityGroupIds: new Set(),
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
if (!template || !template.resources) {
|
|
404
|
+
return referenced;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Traverse all resources in template
|
|
408
|
+
Object.values(template.resources).forEach((resource) => {
|
|
409
|
+
// Lambda VPC config contains hardcoded IDs
|
|
410
|
+
if (
|
|
411
|
+
resource.Type === 'AWS::Lambda::Function' &&
|
|
412
|
+
resource.Properties?.VpcConfig
|
|
413
|
+
) {
|
|
414
|
+
const { SubnetIds, SecurityGroupIds } = resource.Properties.VpcConfig;
|
|
415
|
+
|
|
416
|
+
if (SubnetIds) {
|
|
417
|
+
SubnetIds.forEach((id) => {
|
|
418
|
+
if (typeof id === 'string' && id.startsWith('subnet-')) {
|
|
419
|
+
referenced.subnetIds.add(id);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (SecurityGroupIds) {
|
|
425
|
+
SecurityGroupIds.forEach((id) => {
|
|
426
|
+
if (typeof id === 'string' && id.startsWith('sg-')) {
|
|
427
|
+
referenced.securityGroupIds.add(id);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Security group rules may reference other security groups
|
|
434
|
+
if (resource.Type === 'AWS::EC2::SecurityGroupIngress' ||
|
|
435
|
+
resource.Type === 'AWS::EC2::SecurityGroupEgress') {
|
|
436
|
+
const groupId = resource.Properties?.GroupId;
|
|
437
|
+
const sourceSecurityGroupId = resource.Properties?.SourceSecurityGroupId;
|
|
438
|
+
|
|
439
|
+
if (typeof groupId === 'string' && groupId.startsWith('sg-')) {
|
|
440
|
+
referenced.securityGroupIds.add(groupId);
|
|
441
|
+
}
|
|
442
|
+
if (typeof sourceSecurityGroupId === 'string' && sourceSecurityGroupId.startsWith('sg-')) {
|
|
443
|
+
referenced.securityGroupIds.add(sourceSecurityGroupId);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
return referenced;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Check if a resource is referenced in the deployed template
|
|
453
|
+
* @private
|
|
454
|
+
*/
|
|
455
|
+
_isResourceReferenced(resource, referencedIds) {
|
|
456
|
+
const { resourceType, physicalId } = resource;
|
|
457
|
+
|
|
458
|
+
if (resourceType === 'AWS::EC2::VPC') {
|
|
459
|
+
return referencedIds.vpcIds.has(physicalId);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (resourceType === 'AWS::EC2::Subnet') {
|
|
463
|
+
return referencedIds.subnetIds.has(physicalId);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (resourceType === 'AWS::EC2::SecurityGroup') {
|
|
467
|
+
return referencedIds.securityGroupIds.has(physicalId);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// For other resource types, we can't determine
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
|
|
321
474
|
/**
|
|
322
475
|
* Check for multiple resources of same type
|
|
323
476
|
* Returns warnings when user needs to manually select
|
|
@@ -19,6 +19,7 @@ const StackHealthReport = require('../../domain/entities/stack-health-report');
|
|
|
19
19
|
const Resource = require('../../domain/entities/resource');
|
|
20
20
|
const Issue = require('../../domain/entities/issue');
|
|
21
21
|
const ResourceState = require('../../domain/value-objects/resource-state');
|
|
22
|
+
const { getPropertyMutability } = require('../../domain/services/property-mutability-config');
|
|
22
23
|
|
|
23
24
|
class RunHealthCheckUseCase {
|
|
24
25
|
/**
|
|
@@ -112,10 +113,21 @@ class RunHealthCheckUseCase {
|
|
|
112
113
|
resourceDrift.propertyDifferences &&
|
|
113
114
|
resourceDrift.propertyDifferences.length > 0
|
|
114
115
|
) {
|
|
116
|
+
// Build property mutability map for this resource type
|
|
117
|
+
// AWS drift detection returns property paths, we need to provide mutability for each
|
|
118
|
+
const propertyMutabilityMap = {};
|
|
119
|
+
for (const propDiff of resourceDrift.propertyDifferences) {
|
|
120
|
+
const propertyPath = propDiff.PropertyPath.replace(/^\//, ''); // Remove leading slash
|
|
121
|
+
propertyMutabilityMap[propertyPath] = getPropertyMutability(
|
|
122
|
+
stackResource.resourceType,
|
|
123
|
+
propertyPath
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
115
127
|
const propertyMismatches = this.mismatchAnalyzer.analyze({
|
|
116
128
|
expected: resourceDrift.expectedProperties,
|
|
117
129
|
actual: resourceDrift.actualProperties,
|
|
118
|
-
propertyMutability:
|
|
130
|
+
propertyMutability: propertyMutabilityMap,
|
|
119
131
|
ignoreProperties: [],
|
|
120
132
|
});
|
|
121
133
|
|
|
@@ -156,11 +168,17 @@ class RunHealthCheckUseCase {
|
|
|
156
168
|
|
|
157
169
|
for (const orphan of orphanedResources) {
|
|
158
170
|
// Create resource entity for orphan
|
|
171
|
+
// IMPORTANT: Include both properties AND tags for template comparison
|
|
172
|
+
// Tags are stored in properties.tags for logical ID mapping
|
|
159
173
|
const orphanResource = new Resource({
|
|
160
174
|
logicalId: null, // No logical ID (not in template)
|
|
161
175
|
physicalId: orphan.physicalId,
|
|
162
176
|
resourceType: orphan.resourceType,
|
|
163
177
|
state: ResourceState.ORPHANED,
|
|
178
|
+
properties: {
|
|
179
|
+
...(orphan.properties || {}), // Include AWS properties
|
|
180
|
+
tags: orphan.tags || {}, // Include tags for logical ID matching
|
|
181
|
+
},
|
|
164
182
|
});
|
|
165
183
|
|
|
166
184
|
resources.push(orphanResource);
|
|
@@ -212,6 +212,51 @@ class Issue {
|
|
|
212
212
|
});
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Format a value for display in issue descriptions
|
|
217
|
+
* Handles arrays, objects, and primitive types
|
|
218
|
+
*
|
|
219
|
+
* @private
|
|
220
|
+
* @param {*} value - Value to format
|
|
221
|
+
* @returns {string} Formatted value
|
|
222
|
+
*/
|
|
223
|
+
static _formatValue(value) {
|
|
224
|
+
if (value === null || value === undefined) {
|
|
225
|
+
return String(value);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Handle arrays
|
|
229
|
+
if (Array.isArray(value)) {
|
|
230
|
+
// For arrays of objects (like Tags), show count and first few items
|
|
231
|
+
if (value.length > 0 && typeof value[0] === 'object') {
|
|
232
|
+
if (value.length <= 3) {
|
|
233
|
+
return JSON.stringify(value);
|
|
234
|
+
}
|
|
235
|
+
// Show first 2 items + count for long arrays
|
|
236
|
+
const preview = value.slice(0, 2);
|
|
237
|
+
return `${JSON.stringify(preview).slice(0, -1)}, ... (${value.length} total)]`;
|
|
238
|
+
}
|
|
239
|
+
// For simple arrays, stringify
|
|
240
|
+
return JSON.stringify(value);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Handle objects
|
|
244
|
+
if (typeof value === 'object') {
|
|
245
|
+
const keys = Object.keys(value);
|
|
246
|
+
if (keys.length === 0) {
|
|
247
|
+
return '{}';
|
|
248
|
+
}
|
|
249
|
+
if (keys.length <= 3) {
|
|
250
|
+
return JSON.stringify(value);
|
|
251
|
+
}
|
|
252
|
+
// For large objects, show keys count
|
|
253
|
+
return `{${keys.slice(0, 3).join(', ')}, ... (${keys.length} keys total)}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Primitives
|
|
257
|
+
return String(value);
|
|
258
|
+
}
|
|
259
|
+
|
|
215
260
|
/**
|
|
216
261
|
* Create a property mismatch issue
|
|
217
262
|
*
|
|
@@ -228,7 +273,11 @@ class Issue {
|
|
|
228
273
|
|
|
229
274
|
const canAutoFix = mismatch.canAutoFix();
|
|
230
275
|
|
|
231
|
-
|
|
276
|
+
// Format expected and actual values for display
|
|
277
|
+
const formattedExpected = Issue._formatValue(mismatch.expectedValue);
|
|
278
|
+
const formattedActual = Issue._formatValue(mismatch.actualValue);
|
|
279
|
+
|
|
280
|
+
const description = `Property mismatch: ${mismatch.propertyPath} (expected: ${formattedExpected}, actual: ${formattedActual})`;
|
|
232
281
|
|
|
233
282
|
const resolution = canAutoFix
|
|
234
283
|
? 'Can be auto-fixed using frigg repair --reconcile'
|
|
@@ -414,4 +414,115 @@ describe('Issue', () => {
|
|
|
414
414
|
expect(issue.canAutoFix).toBe(false); // Can't auto-fix immutable
|
|
415
415
|
});
|
|
416
416
|
});
|
|
417
|
+
|
|
418
|
+
describe('_formatValue', () => {
|
|
419
|
+
it('should format primitive values', () => {
|
|
420
|
+
expect(Issue._formatValue('test')).toBe('test');
|
|
421
|
+
expect(Issue._formatValue(123)).toBe('123');
|
|
422
|
+
expect(Issue._formatValue(true)).toBe('true');
|
|
423
|
+
expect(Issue._formatValue(null)).toBe('null');
|
|
424
|
+
expect(Issue._formatValue(undefined)).toBe('undefined');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should format simple arrays', () => {
|
|
428
|
+
expect(Issue._formatValue(['a', 'b', 'c'])).toBe('["a","b","c"]');
|
|
429
|
+
expect(Issue._formatValue([1, 2, 3])).toBe('[1,2,3]');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('should format arrays of objects (like Tags)', () => {
|
|
433
|
+
const tags = [
|
|
434
|
+
{ Key: 'Name', Value: 'test' },
|
|
435
|
+
{ Key: 'Environment', Value: 'prod' },
|
|
436
|
+
];
|
|
437
|
+
const result = Issue._formatValue(tags);
|
|
438
|
+
expect(result).toContain('Key');
|
|
439
|
+
expect(result).toContain('Name');
|
|
440
|
+
expect(result).toContain('test');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('should truncate long arrays of objects', () => {
|
|
444
|
+
const manyTags = [
|
|
445
|
+
{ Key: 'Tag1', Value: 'val1' },
|
|
446
|
+
{ Key: 'Tag2', Value: 'val2' },
|
|
447
|
+
{ Key: 'Tag3', Value: 'val3' },
|
|
448
|
+
{ Key: 'Tag4', Value: 'val4' },
|
|
449
|
+
];
|
|
450
|
+
const result = Issue._formatValue(manyTags);
|
|
451
|
+
expect(result).toContain('4 total');
|
|
452
|
+
expect(result).toContain('...');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('should format small objects', () => {
|
|
456
|
+
const obj = { a: 1, b: 2 };
|
|
457
|
+
expect(Issue._formatValue(obj)).toBe('{"a":1,"b":2}');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('should truncate large objects', () => {
|
|
461
|
+
const largeObj = { a: 1, b: 2, c: 3, d: 4, e: 5 };
|
|
462
|
+
const result = Issue._formatValue(largeObj);
|
|
463
|
+
expect(result).toContain('5 keys total');
|
|
464
|
+
expect(result).toContain('...');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('should format empty collections', () => {
|
|
468
|
+
expect(Issue._formatValue([])).toBe('[]');
|
|
469
|
+
expect(Issue._formatValue({})).toBe('{}');
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
describe('propertyMismatch with formatted values', () => {
|
|
474
|
+
it('should format Tags arrays correctly in description', () => {
|
|
475
|
+
const mismatch = new PropertyMismatch({
|
|
476
|
+
propertyPath: 'Properties.Tags',
|
|
477
|
+
expectedValue: [
|
|
478
|
+
{ Key: 'Name', Value: 'test' },
|
|
479
|
+
{ Key: 'Environment', Value: 'prod' },
|
|
480
|
+
],
|
|
481
|
+
actualValue: [
|
|
482
|
+
{ Key: 'Name', Value: 'test' },
|
|
483
|
+
{ Key: 'Environment', Value: 'prod' },
|
|
484
|
+
{ Key: 'ManagedBy', Value: 'Frigg' },
|
|
485
|
+
],
|
|
486
|
+
mutability: PropertyMutability.MUTABLE,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const issue = Issue.propertyMismatch({
|
|
490
|
+
resourceType: 'AWS::EC2::Subnet',
|
|
491
|
+
resourceId: 'subnet-123',
|
|
492
|
+
mismatch,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Should not contain [object Object]
|
|
496
|
+
expect(issue.description).not.toContain('[object Object]');
|
|
497
|
+
// Should contain JSON representation
|
|
498
|
+
expect(issue.description).toContain('Key');
|
|
499
|
+
expect(issue.description).toContain('Name');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('should handle complex nested objects', () => {
|
|
503
|
+
const mismatch = new PropertyMismatch({
|
|
504
|
+
propertyPath: 'Properties.VpcConfig',
|
|
505
|
+
expectedValue: {
|
|
506
|
+
SubnetIds: ['subnet-1', 'subnet-2'],
|
|
507
|
+
SecurityGroupIds: ['sg-1'],
|
|
508
|
+
},
|
|
509
|
+
actualValue: {
|
|
510
|
+
SubnetIds: ['subnet-3', 'subnet-4'],
|
|
511
|
+
SecurityGroupIds: ['sg-2'],
|
|
512
|
+
},
|
|
513
|
+
mutability: PropertyMutability.MUTABLE,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const issue = Issue.propertyMismatch({
|
|
517
|
+
resourceType: 'AWS::Lambda::Function',
|
|
518
|
+
resourceId: 'my-function',
|
|
519
|
+
mismatch,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// Should not contain [object Object]
|
|
523
|
+
expect(issue.description).not.toContain('[object Object]');
|
|
524
|
+
// Should contain structured representation
|
|
525
|
+
expect(issue.description).toContain('SubnetIds');
|
|
526
|
+
});
|
|
527
|
+
});
|
|
417
528
|
});
|