@friggframework/devtools 2.0.0--canary.474.86c5119.0 → 2.0.0--canary.474.6a0bba7.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.
Files changed (32) hide show
  1. package/infrastructure/domains/database/migration-builder.js +199 -1
  2. package/infrastructure/domains/database/migration-builder.test.js +73 -0
  3. package/infrastructure/domains/health/application/use-cases/__tests__/mismatch-analyzer-method-name.test.js +167 -0
  4. package/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js +1130 -0
  5. package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.js +6 -0
  6. package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.js +307 -1
  7. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.js +38 -5
  8. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js +3 -3
  9. package/infrastructure/domains/health/docs/ACME-DEV-DRIFT-ANALYSIS.md +267 -0
  10. package/infrastructure/domains/health/docs/BUILD-VS-DEPLOYED-TEMPLATE-ANALYSIS.md +324 -0
  11. package/infrastructure/domains/health/docs/ORPHAN-DETECTION-ANALYSIS.md +386 -0
  12. package/infrastructure/domains/health/docs/SPEC-CLEANUP-COMMAND.md +1419 -0
  13. package/infrastructure/domains/health/docs/TDD-IMPLEMENTATION-SUMMARY.md +391 -0
  14. package/infrastructure/domains/health/docs/TEMPLATE-COMPARISON-IMPLEMENTATION.md +551 -0
  15. package/infrastructure/domains/health/domain/entities/issue.js +50 -1
  16. package/infrastructure/domains/health/domain/entities/issue.test.js +111 -0
  17. package/infrastructure/domains/health/domain/services/__tests__/health-score-percentage-based.test.js +380 -0
  18. package/infrastructure/domains/health/domain/services/__tests__/logical-id-mapper.test.js +672 -0
  19. package/infrastructure/domains/health/domain/services/__tests__/template-parser.test.js +496 -0
  20. package/infrastructure/domains/health/domain/services/health-score-calculator.js +174 -91
  21. package/infrastructure/domains/health/domain/services/health-score-calculator.test.js +332 -228
  22. package/infrastructure/domains/health/domain/services/logical-id-mapper.js +345 -0
  23. package/infrastructure/domains/health/domain/services/template-parser.js +245 -0
  24. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-cfn-tagged.test.js +312 -0
  25. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-multi-stack.test.js +367 -0
  26. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-relationship-analysis.test.js +432 -0
  27. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.js +407 -20
  28. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.test.js +698 -26
  29. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.js +108 -14
  30. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js +69 -12
  31. package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.js +392 -1
  32. package/package.json +6 -6
@@ -14,6 +14,7 @@ const IPropertyReconciler = require('../../application/ports/IPropertyReconciler
14
14
  // Lazy-loaded AWS SDK clients
15
15
  let CloudFormationClient, UpdateStackCommand, GetTemplateCommand;
16
16
  let EC2Client, ModifyVpcAttributeCommand;
17
+ let LambdaClient, UpdateFunctionConfigurationCommand;
17
18
 
18
19
  /**
19
20
  * Lazy load CloudFormation SDK
@@ -38,6 +39,17 @@ function loadEC2() {
38
39
  }
39
40
  }
40
41
 
42
+ /**
43
+ * Lazy load Lambda SDK
44
+ */
45
+ function loadLambda() {
46
+ if (!LambdaClient) {
47
+ const lambdaModule = require('@aws-sdk/client-lambda');
48
+ LambdaClient = lambdaModule.LambdaClient;
49
+ UpdateFunctionConfigurationCommand = lambdaModule.UpdateFunctionConfigurationCommand;
50
+ }
51
+ }
52
+
41
53
  class AWSPropertyReconciler extends IPropertyReconciler {
42
54
  /**
43
55
  * Resource types that support reconciliation
@@ -80,6 +92,16 @@ class AWSPropertyReconciler extends IPropertyReconciler {
80
92
  recommendedMode: 'template',
81
93
  limitations: ['Key policy changes must be done via CloudFormation'],
82
94
  },
95
+ 'AWS::Lambda::Function': {
96
+ templateUpdate: true,
97
+ resourceUpdate: true,
98
+ recommendedMode: 'template',
99
+ limitations: [
100
+ 'VpcConfig changes may take several minutes to propagate',
101
+ 'Code updates are handled separately via UpdateFunctionCode',
102
+ 'Environment variable changes may cause brief invocation errors during update',
103
+ ],
104
+ },
83
105
  };
84
106
 
85
107
  /**
@@ -87,12 +109,15 @@ class AWSPropertyReconciler extends IPropertyReconciler {
87
109
  *
88
110
  * @param {Object} [config={}]
89
111
  * @param {string} [config.region] - AWS region (defaults to AWS_REGION env var)
112
+ * @param {Object} [config.cloudFormationRepository] - CloudFormation repository for monitoring
90
113
  */
91
114
  constructor(config = {}) {
92
115
  super();
93
116
  this.region = config.region || process.env.AWS_REGION || 'us-east-1';
94
117
  this.cfClient = null;
95
118
  this.ec2Client = null;
119
+ this.lambdaClient = null;
120
+ this.cfRepo = config.cloudFormationRepository || null;
96
121
  }
97
122
 
98
123
  /**
@@ -119,6 +144,18 @@ class AWSPropertyReconciler extends IPropertyReconciler {
119
144
  return this.ec2Client;
120
145
  }
121
146
 
147
+ /**
148
+ * Get or create Lambda client
149
+ * @private
150
+ */
151
+ _getLambdaClient() {
152
+ if (!this.lambdaClient) {
153
+ loadLambda();
154
+ this.lambdaClient = new LambdaClient({ region: this.region });
155
+ }
156
+ return this.lambdaClient;
157
+ }
158
+
122
159
  /**
123
160
  * Check if a property mismatch can be auto-fixed
124
161
  */
@@ -154,48 +191,186 @@ class AWSPropertyReconciler extends IPropertyReconciler {
154
191
 
155
192
  /**
156
193
  * Reconcile multiple property mismatches for a resource
194
+ *
195
+ * IMPORTANT: Batches all property updates into a SINGLE UpdateStack call
196
+ * to avoid "stack is already updating" errors from CloudFormation.
197
+ *
198
+ * MONITORING: After calling UpdateStack, monitors the stack until UPDATE_COMPLETE
199
+ * or UPDATE_FAILED to ensure the update actually succeeded.
157
200
  */
158
201
  async reconcileMultipleProperties({
159
202
  stackIdentifier,
160
203
  logicalId,
204
+ physicalId,
205
+ resourceType,
161
206
  mismatches,
162
207
  mode = 'template',
208
+ progressMonitor = null, // Optional UpdateProgressMonitor for async tracking
163
209
  }) {
210
+ // Route to appropriate reconciliation method based on mode
211
+ if (mode === 'resource') {
212
+ return await this._reconcileMultiplePropertiesViaResource({
213
+ stackIdentifier,
214
+ logicalId,
215
+ physicalId,
216
+ resourceType,
217
+ mismatches,
218
+ });
219
+ }
220
+
221
+ // Template mode (original implementation)
164
222
  const results = [];
165
223
  let reconciledCount = 0;
166
224
  let failedCount = 0;
167
225
 
168
- for (const mismatch of mismatches) {
169
- try {
170
- const result = await this.reconcileProperty({
171
- stackIdentifier,
172
- logicalId,
173
- mismatch,
174
- mode,
175
- });
226
+ try {
227
+ const client = this._getCFClient();
176
228
 
177
- results.push(result);
178
- if (result.success) {
229
+ // 1. Get current template ONCE
230
+ const getTemplateCommand = new GetTemplateCommand({
231
+ StackName: stackIdentifier.stackName,
232
+ TemplateStage: 'Original',
233
+ });
234
+
235
+ const templateResponse = await client.send(getTemplateCommand);
236
+ const template = JSON.parse(templateResponse.TemplateBody);
237
+
238
+ // 2. Apply ALL property changes to the template
239
+ for (const mismatch of mismatches) {
240
+ try {
241
+ // Navigate to the property in the template
242
+ // AWS drift detection returns paths without 'Properties.' prefix (e.g., 'VpcConfig.SubnetIds')
243
+ // But CloudFormation templates have 'Properties' section, so we need to navigate there
244
+ const pathParts = mismatch.propertyPath.split('.');
245
+ let current = template.Resources[logicalId];
246
+
247
+ // Ensure Properties section exists
248
+ if (!current.Properties) {
249
+ current.Properties = {};
250
+ }
251
+
252
+ // Start navigation at Properties level
253
+ current = current.Properties;
254
+
255
+ // Create nested objects if they don't exist
256
+ for (let i = 0; i < pathParts.length - 1; i++) {
257
+ if (!current[pathParts[i]]) {
258
+ current[pathParts[i]] = {};
259
+ }
260
+ current = current[pathParts[i]];
261
+ }
262
+
263
+ // Update the property value
264
+ const lastPart = pathParts[pathParts.length - 1];
265
+ current[lastPart] = mismatch.actualValue;
266
+
267
+ // Track as pending (will be confirmed by monitor)
268
+ results.push({
269
+ success: true,
270
+ mode: 'template',
271
+ propertyPath: mismatch.propertyPath,
272
+ oldValue: mismatch.expectedValue,
273
+ newValue: mismatch.actualValue,
274
+ message: 'Property updated in template',
275
+ });
179
276
  reconciledCount++;
180
- } else {
277
+ } catch (error) {
278
+ // Track as failed
279
+ results.push({
280
+ success: false,
281
+ mode: 'template',
282
+ propertyPath: mismatch.propertyPath,
283
+ message: `Failed to update property: ${error.message}`,
284
+ });
181
285
  failedCount++;
182
286
  }
183
- } catch (error) {
184
- results.push({
185
- success: false,
186
- mode,
187
- propertyPath: mismatch.propertyPath,
188
- message: error.message,
189
- });
190
- failedCount++;
191
287
  }
288
+
289
+ // 3. If any properties were updated, call UpdateStack ONCE with all changes
290
+ if (reconciledCount > 0) {
291
+ const templateBody = JSON.stringify(template);
292
+ const templateSize = templateBody.length;
293
+ const TEMPLATE_SIZE_LIMIT = 51200; // CloudFormation inline template limit
294
+
295
+ // Use S3 for large templates, inline for small templates
296
+ const updateParams = {
297
+ StackName: stackIdentifier.stackName,
298
+ };
299
+
300
+ if (templateSize > TEMPLATE_SIZE_LIMIT && this.cfRepo) {
301
+ // Upload template to S3 and use TemplateURL
302
+ const templateUrl = await this.cfRepo.uploadTemplate({
303
+ stackName: stackIdentifier.stackName,
304
+ templateBody,
305
+ });
306
+ updateParams.TemplateURL = templateUrl;
307
+ } else {
308
+ // Use inline template body
309
+ updateParams.TemplateBody = templateBody;
310
+ }
311
+
312
+ // Add capabilities required for IAM resources
313
+ updateParams.Capabilities = ['CAPABILITY_NAMED_IAM'];
314
+
315
+ const updateCommand = new UpdateStackCommand(updateParams);
316
+ await client.send(updateCommand);
317
+
318
+ // 4. Monitor UpdateStack operation if CloudFormation repository available
319
+ if (this.cfRepo) {
320
+ const { UpdateProgressMonitor } = require('../../domain/services/update-progress-monitor');
321
+ const monitor = new UpdateProgressMonitor({
322
+ cloudFormationRepository: this.cfRepo,
323
+ });
324
+
325
+ const monitorResult = await monitor.monitorUpdate({
326
+ stackIdentifier,
327
+ resourceLogicalIds: [logicalId],
328
+ onProgress: (progress) => {
329
+ // Progress callback for UI updates (optional)
330
+ if (progress.status === 'FAILED') {
331
+ console.log(` ⚠ ${progress.logicalId}: Update failed - ${progress.reason}`);
332
+ }
333
+ },
334
+ });
335
+
336
+ // If monitoring detected failures, update results
337
+ if (!monitorResult.success) {
338
+ reconciledCount = 0;
339
+ failedCount = mismatches.length;
340
+ results.forEach(r => {
341
+ r.success = false;
342
+ r.message = 'CloudFormation update failed';
343
+ });
344
+
345
+ return {
346
+ reconciledCount,
347
+ failedCount,
348
+ results,
349
+ message: `Update failed: ${monitorResult.failedResources.map(f => f.reason).join(', ')}`,
350
+ };
351
+ }
352
+ }
353
+ }
354
+ } catch (error) {
355
+ // If UpdateStack fails, mark all as failed
356
+ return {
357
+ reconciledCount: 0,
358
+ failedCount: mismatches.length,
359
+ results: mismatches.map(m => ({
360
+ success: false,
361
+ mode: 'template',
362
+ propertyPath: m.propertyPath,
363
+ message: `UpdateStack failed: ${error.message}`,
364
+ })),
365
+ message: `UpdateStack failed: ${error.message}`,
366
+ };
192
367
  }
193
368
 
194
369
  return {
195
370
  reconciledCount,
196
371
  failedCount,
197
372
  results,
198
- message: `Reconciled ${reconciledCount} of ${mismatches.length} properties`,
373
+ message: `Reconciled ${reconciledCount} of ${mismatches.length} properties in single UpdateStack call`,
199
374
  };
200
375
  }
201
376
 
@@ -261,6 +436,7 @@ class AWSPropertyReconciler extends IPropertyReconciler {
261
436
  const updateCommand = new UpdateStackCommand({
262
437
  StackName: stackIdentifier.stackName,
263
438
  TemplateBody: JSON.stringify(template),
439
+ Capabilities: ['CAPABILITY_NAMED_IAM'],
264
440
  });
265
441
 
266
442
  const updateResponse = await client.send(updateCommand);
@@ -392,6 +568,217 @@ class AWSPropertyReconciler extends IPropertyReconciler {
392
568
  updatedAt: new Date(),
393
569
  };
394
570
  }
571
+
572
+ /**
573
+ * Reconcile multiple properties via resource update (resource mode)
574
+ *
575
+ * Domain Service - Hexagonal Architecture
576
+ * Coordinates AWS API calls to update cloud resources directly
577
+ *
578
+ * @private
579
+ */
580
+ async _reconcileMultiplePropertiesViaResource({
581
+ stackIdentifier,
582
+ logicalId,
583
+ physicalId,
584
+ resourceType,
585
+ mismatches,
586
+ }) {
587
+ // Validate resource type supports resource mode
588
+ if (resourceType !== 'AWS::Lambda::Function') {
589
+ throw new Error(`Resource mode reconciliation not supported for ${resourceType}`);
590
+ }
591
+
592
+ const results = [];
593
+ let reconciledCount = 0;
594
+ let failedCount = 0;
595
+ let skippedCount = 0;
596
+
597
+ try {
598
+ // Separate mutable from immutable properties
599
+ const mutableMismatches = [];
600
+ const mutableIndexMap = new Map(); // Track original index for mutable properties
601
+
602
+ for (let i = 0; i < mismatches.length; i++) {
603
+ const mismatch = mismatches[i];
604
+ if (mismatch.requiresReplacement()) {
605
+ skippedCount++;
606
+ } else {
607
+ mutableIndexMap.set(mismatch, i);
608
+ mutableMismatches.push(mismatch);
609
+ }
610
+ }
611
+
612
+ // If no mutable properties, return early with all marked as skipped
613
+ if (mutableMismatches.length === 0) {
614
+ const skippedResults = mismatches.map(m => ({
615
+ success: false,
616
+ mode: 'resource',
617
+ propertyPath: m.propertyPath,
618
+ message: `Skipped: Property is immutable and cannot be updated without replacement`,
619
+ }));
620
+
621
+ return {
622
+ reconciledCount: 0,
623
+ failedCount: 0,
624
+ skippedCount,
625
+ results: skippedResults,
626
+ message: `All ${mismatches.length} properties are immutable and cannot be reconciled in resource mode`,
627
+ };
628
+ }
629
+
630
+ // Route to resource-specific updater
631
+ let lambdaResults = [];
632
+ if (resourceType === 'AWS::Lambda::Function') {
633
+ const lambdaResult = await this._updateLambdaFunction({
634
+ physicalId,
635
+ mismatches: mutableMismatches,
636
+ });
637
+
638
+ reconciledCount = lambdaResult.reconciledCount;
639
+ failedCount = lambdaResult.failedCount;
640
+ lambdaResults = lambdaResult.results;
641
+ }
642
+
643
+ // Build results array in original input order
644
+ for (let i = 0; i < mismatches.length; i++) {
645
+ const mismatch = mismatches[i];
646
+ if (mismatch.requiresReplacement()) {
647
+ // Immutable - add skip result
648
+ results.push({
649
+ success: false,
650
+ mode: 'resource',
651
+ propertyPath: mismatch.propertyPath,
652
+ message: `Skipped: Property is immutable and cannot be updated without replacement`,
653
+ });
654
+ } else {
655
+ // Mutable - find corresponding result from Lambda update
656
+ const lambdaResult = lambdaResults.find(r => r.propertyPath === mismatch.propertyPath);
657
+ if (lambdaResult) {
658
+ results.push(lambdaResult);
659
+ }
660
+ }
661
+ }
662
+ } catch (error) {
663
+ // If update fails, mark all as failed
664
+ return {
665
+ reconciledCount: 0,
666
+ failedCount: mismatches.length,
667
+ skippedCount: 0,
668
+ results: mismatches.map(m => ({
669
+ success: false,
670
+ mode: 'resource',
671
+ propertyPath: m.propertyPath,
672
+ message: `Failed to update Lambda: ${error.message}`,
673
+ })),
674
+ message: `Failed to update Lambda: ${error.message}`,
675
+ };
676
+ }
677
+
678
+ // Determine appropriate message based on outcome
679
+ let message;
680
+ if (failedCount > 0 && reconciledCount === 0) {
681
+ message = `Failed to update Lambda: ${results.find(r => !r.success)?.message || 'Unknown error'}`;
682
+ } else if (failedCount > 0) {
683
+ message = `Partially updated Lambda VpcConfig (${reconciledCount} succeeded, ${failedCount} failed)`;
684
+ } else {
685
+ message = `Lambda VpcConfig updated via UpdateFunctionConfiguration (${reconciledCount} properties reconciled)`;
686
+ }
687
+
688
+ return {
689
+ reconciledCount,
690
+ failedCount,
691
+ skippedCount,
692
+ results,
693
+ message,
694
+ };
695
+ }
696
+
697
+ /**
698
+ * Update Lambda function configuration via AWS Lambda API
699
+ *
700
+ * Infrastructure Adapter
701
+ * Translates domain mismatches to AWS Lambda UpdateFunctionConfiguration call
702
+ *
703
+ * @private
704
+ */
705
+ async _updateLambdaFunction({ physicalId, mismatches }) {
706
+ const client = this._getLambdaClient();
707
+ const results = [];
708
+ let reconciledCount = 0;
709
+ let failedCount = 0;
710
+
711
+ try {
712
+ // Build VpcConfig update from mismatches
713
+ const vpcConfigUpdate = {};
714
+ const vpcConfigMismatches = mismatches.filter(m =>
715
+ m.propertyPath.startsWith('VpcConfig')
716
+ );
717
+
718
+ for (const mismatch of vpcConfigMismatches) {
719
+ // Property path format: "VpcConfig.SubnetIds" or "VpcConfig.SecurityGroupIds"
720
+ const parts = mismatch.propertyPath.split('.');
721
+ if (parts.length === 2 && parts[0] === 'VpcConfig') {
722
+ vpcConfigUpdate[parts[1]] = mismatch.expectedValue;
723
+ }
724
+ }
725
+
726
+ // If we have VpcConfig updates, call UpdateFunctionConfiguration
727
+ if (Object.keys(vpcConfigUpdate).length > 0) {
728
+ const command = new UpdateFunctionConfigurationCommand({
729
+ FunctionName: physicalId,
730
+ VpcConfig: vpcConfigUpdate,
731
+ });
732
+
733
+ await client.send(command);
734
+
735
+ // Mark all VpcConfig properties as successful
736
+ for (const mismatch of vpcConfigMismatches) {
737
+ results.push({
738
+ success: true,
739
+ mode: 'resource',
740
+ propertyPath: mismatch.propertyPath,
741
+ oldValue: mismatch.actualValue,
742
+ newValue: mismatch.expectedValue,
743
+ message: 'Lambda VpcConfig updated successfully',
744
+ });
745
+ reconciledCount++;
746
+ }
747
+ }
748
+
749
+ // Handle non-VpcConfig properties (currently unsupported)
750
+ const otherMismatches = mismatches.filter(m =>
751
+ !m.propertyPath.startsWith('VpcConfig')
752
+ );
753
+ for (const mismatch of otherMismatches) {
754
+ results.push({
755
+ success: false,
756
+ mode: 'resource',
757
+ propertyPath: mismatch.propertyPath,
758
+ message: `Property ${mismatch.propertyPath} updates not yet supported in resource mode`,
759
+ });
760
+ failedCount++;
761
+ }
762
+ } catch (error) {
763
+ // If Lambda API call fails, mark all as failed
764
+ for (const mismatch of mismatches) {
765
+ results.push({
766
+ success: false,
767
+ mode: 'resource',
768
+ propertyPath: mismatch.propertyPath,
769
+ message: `Lambda update failed: ${error.message}`,
770
+ });
771
+ failedCount++;
772
+ }
773
+ reconciledCount = 0;
774
+ }
775
+
776
+ return {
777
+ reconciledCount,
778
+ failedCount,
779
+ results,
780
+ };
781
+ }
395
782
  }
396
783
 
397
784
  module.exports = AWSPropertyReconciler;