@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
@@ -209,34 +209,127 @@ class AWSResourceDetector extends IResourceDetector {
209
209
  }
210
210
 
211
211
  /**
212
- * Find orphaned resources (exist in cloud but not in any stack)
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({ region, resourceTypes = [], excludePhysicalIds = [] }) {
215
- const types = resourceTypes.length > 0 ? resourceTypes : AWSResourceDetector.SUPPORTED_TYPES;
216
-
231
+ async findOrphanedResources({ stackIdentifier, stackResources }) {
217
232
  const orphans = [];
218
233
 
219
- for (const resourceType of types) {
220
- const resources = await this.detectResources({ resourceType, region });
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
- // Exclude specified physical IDs
224
- if (excludePhysicalIds.includes(resource.physicalId)) {
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
- // Mark as orphaned (in real implementation, would check CloudFormation stacks)
229
- orphans.push({
230
- ...resource,
231
- isOrphaned: true,
232
- reason: `Resource ${resource.physicalId} exists in cloud but is not managed by CloudFormation`,
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
  },
@@ -439,23 +439,53 @@ describe('AWSResourceDetector', () => {
439
439
  });
440
440
 
441
441
  describe('findOrphanedResources', () => {
442
- it('should find orphaned RDS DBCluster', async () => {
442
+ const StackIdentifier = require('../../domain/value-objects/stack-identifier');
443
+
444
+ it('should find orphaned RDS DBCluster with frigg:stack tag', async () => {
445
+ const stackIdentifier = new StackIdentifier({
446
+ stackName: 'my-app-prod',
447
+ region: 'us-east-1',
448
+ });
449
+
450
+ const stackResources = [
451
+ {
452
+ logicalId: 'MyDBCluster',
453
+ physicalId: 'managed-cluster',
454
+ resourceType: 'AWS::RDS::DBCluster',
455
+ },
456
+ ];
457
+
443
458
  mockRDSSend.mockResolvedValue({
444
459
  DBClusters: [
445
460
  {
461
+ // Managed by CloudFormation - not orphaned
462
+ DBClusterIdentifier: 'managed-cluster',
463
+ DBClusterArn: 'arn:aws:rds:us-east-1:123456789012:cluster:managed-cluster',
464
+ Engine: 'aurora-postgresql',
465
+ Status: 'available',
466
+ ClusterCreateTime: new Date('2024-01-01T00:00:00Z'),
467
+ TagList: [
468
+ { Key: 'aws:cloudformation:stack-name', Value: 'my-app-prod' },
469
+ { Key: 'frigg:stack', Value: 'my-app-prod' },
470
+ ],
471
+ },
472
+ {
473
+ // Has frigg:stack tag but no CloudFormation tag - orphaned
446
474
  DBClusterIdentifier: 'orphan-cluster',
447
475
  DBClusterArn: 'arn:aws:rds:us-east-1:123456789012:cluster:orphan-cluster',
448
476
  Engine: 'aurora-postgresql',
449
477
  Status: 'available',
450
478
  ClusterCreateTime: new Date('2024-01-01T00:00:00Z'),
451
- TagList: [],
479
+ TagList: [
480
+ { Key: 'frigg:stack', Value: 'my-app-prod' },
481
+ ],
452
482
  },
453
483
  ],
454
484
  });
455
485
 
456
486
  const orphans = await detector.findOrphanedResources({
457
- region: 'us-east-1',
458
- resourceTypes: ['AWS::RDS::DBCluster'],
487
+ stackIdentifier,
488
+ stackResources,
459
489
  });
460
490
 
461
491
  expect(orphans).toHaveLength(1);
@@ -464,22 +494,49 @@ describe('AWSResourceDetector', () => {
464
494
  expect(orphans[0].reason).toContain('not managed by CloudFormation');
465
495
  });
466
496
 
467
- it('should exclude specified physical IDs', async () => {
497
+ it('should not return resources managed by CloudFormation', async () => {
498
+ const stackIdentifier = new StackIdentifier({
499
+ stackName: 'my-app-prod',
500
+ region: 'us-east-1',
501
+ });
502
+
503
+ const stackResources = [
504
+ {
505
+ logicalId: 'MyVPC',
506
+ physicalId: 'vpc-123',
507
+ resourceType: 'AWS::EC2::VPC',
508
+ },
509
+ ];
510
+
468
511
  mockEC2Send.mockResolvedValue({
469
512
  Vpcs: [
470
- { VpcId: 'vpc-123', CidrBlock: '10.0.0.0/16', State: 'available', Tags: [] },
471
- { VpcId: 'vpc-456', CidrBlock: '10.1.0.0/16', State: 'available', Tags: [] },
513
+ {
514
+ VpcId: 'vpc-123',
515
+ CidrBlock: '10.0.0.0/16',
516
+ State: 'available',
517
+ Tags: [
518
+ { Key: 'aws:cloudformation:stack-name', Value: 'my-app-prod' },
519
+ { Key: 'frigg:stack', Value: 'my-app-prod' },
520
+ ],
521
+ },
522
+ {
523
+ VpcId: 'vpc-456',
524
+ CidrBlock: '10.1.0.0/16',
525
+ State: 'available',
526
+ Tags: [
527
+ { Key: 'aws:cloudformation:stack-name', Value: 'other-stack' },
528
+ ],
529
+ },
472
530
  ],
473
531
  });
474
532
 
475
533
  const orphans = await detector.findOrphanedResources({
476
- region: 'us-east-1',
477
- resourceTypes: ['AWS::EC2::VPC'],
478
- excludePhysicalIds: ['vpc-123'],
534
+ stackIdentifier,
535
+ stackResources,
479
536
  });
480
537
 
481
- expect(orphans).toHaveLength(1);
482
- expect(orphans[0].physicalId).toBe('vpc-456');
538
+ // Should not find any orphans - both VPCs are CloudFormation-managed
539
+ expect(orphans).toEqual([]);
483
540
  });
484
541
  });
485
542
 
@@ -21,7 +21,14 @@ let CloudFormationClient,
21
21
  GetTemplateCommand,
22
22
  DetectStackDriftCommand,
23
23
  DescribeStackDriftDetectionStatusCommand,
24
- DescribeStackResourceDriftsCommand;
24
+ DescribeStackResourceDriftsCommand,
25
+ CreateChangeSetCommand,
26
+ DescribeChangeSetCommand,
27
+ ExecuteChangeSetCommand,
28
+ DescribeStackEventsCommand;
29
+
30
+ // Lazy-loaded AWS SDK S3 client for large template uploads
31
+ let S3Client, PutObjectCommand;
25
32
 
26
33
  /**
27
34
  * Lazy load CloudFormation SDK
@@ -39,6 +46,21 @@ function loadCloudFormation() {
39
46
  DescribeStackDriftDetectionStatusCommand =
40
47
  cfModule.DescribeStackDriftDetectionStatusCommand;
41
48
  DescribeStackResourceDriftsCommand = cfModule.DescribeStackResourceDriftsCommand;
49
+ CreateChangeSetCommand = cfModule.CreateChangeSetCommand;
50
+ DescribeChangeSetCommand = cfModule.DescribeChangeSetCommand;
51
+ ExecuteChangeSetCommand = cfModule.ExecuteChangeSetCommand;
52
+ DescribeStackEventsCommand = cfModule.DescribeStackEventsCommand;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Lazy load S3 SDK for template uploads
58
+ */
59
+ function loadS3() {
60
+ if (!S3Client) {
61
+ const s3Module = require('@aws-sdk/client-s3');
62
+ S3Client = s3Module.S3Client;
63
+ PutObjectCommand = s3Module.PutObjectCommand;
42
64
  }
43
65
  }
44
66
 
@@ -53,6 +75,9 @@ class AWSStackRepository extends IStackRepository {
53
75
  super();
54
76
  this.region = config.region || process.env.AWS_REGION || 'us-east-1';
55
77
  this.client = null;
78
+ this.s3Client = null;
79
+ // S3 bucket for large template uploads (defaults to serverless deployment bucket)
80
+ this.templateBucket = config.templateBucket || null;
56
81
  }
57
82
 
58
83
  /**
@@ -67,6 +92,91 @@ class AWSStackRepository extends IStackRepository {
67
92
  return this.client;
68
93
  }
69
94
 
95
+ /**
96
+ * Get or create S3 client
97
+ * @private
98
+ */
99
+ _getS3Client() {
100
+ if (!this.s3Client) {
101
+ loadS3();
102
+ this.s3Client = new S3Client({ region: this.region });
103
+ }
104
+ return this.s3Client;
105
+ }
106
+
107
+ /**
108
+ * Upload CloudFormation template to S3 (public API)
109
+ *
110
+ * Exposed as public method for use by other adapters that need to upload
111
+ * templates to S3 before calling UpdateStack/CreateChangeSet.
112
+ *
113
+ * @param {Object} params - Upload parameters
114
+ * @param {string} params.stackName - Stack name for S3 key prefix
115
+ * @param {string} params.templateBody - CloudFormation template JSON string
116
+ * @returns {Promise<string>} S3 URL for uploaded template
117
+ */
118
+ async uploadTemplate({ stackName, templateBody }) {
119
+ return await this._uploadTemplateToS3({ stackName, templateBody });
120
+ }
121
+
122
+ /**
123
+ * Upload template to S3 for large templates (> 51,200 bytes) - Internal implementation
124
+ *
125
+ * @param {Object} params - Upload parameters
126
+ * @param {string} params.stackName - Stack name for S3 key prefix
127
+ * @param {string} params.templateBody - CloudFormation template JSON string
128
+ * @returns {Promise<string>} S3 URL for uploaded template
129
+ * @private
130
+ */
131
+ async _uploadTemplateToS3({ stackName, templateBody }) {
132
+ const s3Client = this._getS3Client();
133
+
134
+ // Get serverless deployment bucket from stack if not configured
135
+ if (!this.templateBucket) {
136
+ try {
137
+ const stacks = await this._getClient().send(
138
+ new DescribeStacksCommand({ StackName: stackName })
139
+ );
140
+
141
+ // Look for ServerlessDeploymentBucket output
142
+ const bucket = stacks.Stacks[0]?.Outputs?.find(
143
+ o => o.OutputKey === 'ServerlessDeploymentBucketName'
144
+ )?.OutputValue;
145
+
146
+ if (bucket) {
147
+ this.templateBucket = bucket;
148
+ } else {
149
+ throw new Error('No S3 bucket configured for template uploads. Set templateBucket in config or ensure ServerlessDeploymentBucket exists.');
150
+ }
151
+ } catch (error) {
152
+ throw new Error(`Failed to find S3 bucket for template upload: ${error.message}`);
153
+ }
154
+ }
155
+
156
+ // Generate S3 key with timestamp
157
+ const timestamp = Date.now();
158
+ const key = `cloudformation-templates/${stackName}/import-template-${timestamp}.json`;
159
+
160
+ // Upload to S3
161
+ const putCommand = new PutObjectCommand({
162
+ Bucket: this.templateBucket,
163
+ Key: key,
164
+ Body: templateBody,
165
+ ContentType: 'application/json',
166
+ });
167
+
168
+ await s3Client.send(putCommand);
169
+
170
+ // Return S3 URL
171
+ const s3Url = `https://${this.templateBucket}.s3.${this.region}.amazonaws.com/${key}`;
172
+
173
+ if (process.env.DEBUG_IMPORT_TEMPLATE === 'true') {
174
+ console.log(`[DEBUG] Template uploaded to S3: ${s3Url}`);
175
+ }
176
+
177
+ return s3Url;
178
+ }
179
+
70
180
  /**
71
181
  * Get stack information by identifier
72
182
  */
@@ -290,6 +400,287 @@ class AWSStackRepository extends IStackRepository {
290
400
  };
291
401
  }
292
402
 
403
+ // ========================================
404
+ // CloudFormation Change Set Operations
405
+ // ========================================
406
+
407
+ /**
408
+ * Create CloudFormation change set for import or update
409
+ *
410
+ * @param {Object} params - Change set parameters
411
+ * @param {StackIdentifier} params.stackIdentifier - Stack identifier
412
+ * @param {string} params.changeSetName - Name for the change set
413
+ * @param {string} params.changeSetType - Type of change set ('IMPORT', 'UPDATE', or 'CREATE')
414
+ * @param {Object} params.template - CloudFormation template object
415
+ * @param {Array} [params.resourcesToImport] - Resources to import (required for IMPORT type)
416
+ * @returns {Promise<Object>} Change set result with Id and StackId
417
+ * @throws {Error} If change set creation fails
418
+ */
419
+ async createChangeSet({ stackIdentifier, changeSetName, changeSetType, template, resourcesToImport }) {
420
+ const client = this._getClient();
421
+
422
+ // Ensure template is an object (not already stringified)
423
+ const templateObj = typeof template === 'string' ? JSON.parse(template) : template;
424
+
425
+ // Validate template structure
426
+ if (!templateObj || typeof templateObj !== 'object') {
427
+ throw new Error(`Invalid template: expected object, got ${typeof templateObj}`);
428
+ }
429
+
430
+ if (!templateObj.Resources || typeof templateObj.Resources !== 'object') {
431
+ throw new Error(`Invalid template: missing or invalid Resources section`);
432
+ }
433
+
434
+ // DEBUG: Log template structure if debug enabled
435
+ if (process.env.DEBUG_IMPORT_TEMPLATE === 'true') {
436
+ console.log('[DEBUG] Template structure validation:');
437
+ console.log(' - Top-level keys:', Object.keys(templateObj));
438
+ console.log(' - Resources count:', Object.keys(templateObj.Resources).length);
439
+ console.log(' - Resource types:', {
440
+ ...Object.entries(templateObj.Resources).reduce((acc, [id, def]) => {
441
+ acc[id] = def.Type;
442
+ return acc;
443
+ }, {})
444
+ });
445
+
446
+ if (resourcesToImport) {
447
+ console.log(' - ResourcesToImport:', resourcesToImport.length);
448
+ resourcesToImport.forEach((r, i) => {
449
+ console.log(` ${i + 1}. ${r.LogicalResourceId} (${r.ResourceType})`);
450
+ });
451
+ }
452
+ }
453
+
454
+ const templateBody = JSON.stringify(templateObj, null, 2);
455
+ const templateSize = templateBody.length;
456
+
457
+ // CloudFormation inline template size limit (51,200 bytes)
458
+ const TEMPLATE_SIZE_LIMIT = 51200;
459
+ const useS3 = templateSize > TEMPLATE_SIZE_LIMIT;
460
+
461
+ if (process.env.DEBUG_IMPORT_TEMPLATE === 'true') {
462
+ console.log(`[DEBUG] Template size: ${templateSize} bytes (limit: ${TEMPLATE_SIZE_LIMIT})`);
463
+ console.log(`[DEBUG] Using ${useS3 ? 'S3' : 'inline'} template delivery`);
464
+ }
465
+
466
+ const params = {
467
+ StackName: stackIdentifier.stackName,
468
+ ChangeSetName: changeSetName,
469
+ ChangeSetType: changeSetType,
470
+ // Add IAM capabilities for templates with IAM resources
471
+ Capabilities: ['CAPABILITY_NAMED_IAM'],
472
+ };
473
+
474
+ // Use S3 for large templates, inline for small templates
475
+ if (useS3) {
476
+ const templateUrl = await this._uploadTemplateToS3({
477
+ stackName: stackIdentifier.stackName,
478
+ templateBody,
479
+ });
480
+ params.TemplateURL = templateUrl;
481
+ } else {
482
+ params.TemplateBody = templateBody;
483
+ }
484
+
485
+ // Add resources to import for IMPORT change sets
486
+ if (changeSetType === 'IMPORT') {
487
+ if (!resourcesToImport || resourcesToImport.length === 0) {
488
+ throw new Error('resourcesToImport is required for IMPORT change set type');
489
+ }
490
+ params.ResourcesToImport = resourcesToImport;
491
+ }
492
+
493
+ try {
494
+ const command = new CreateChangeSetCommand(params);
495
+ const response = await client.send(command);
496
+
497
+ return {
498
+ Id: response.Id,
499
+ StackId: response.StackId,
500
+ templateUrl: useS3 ? params.TemplateURL : undefined,
501
+ };
502
+ } catch (error) {
503
+ // Add more context to the error
504
+ if (error.message?.includes('is not expected')) {
505
+ throw new Error(`CloudFormation template format error: ${error.message}. Template size: ${templateSize} bytes, Resources to import: ${resourcesToImport?.length || 0}. Delivery method: ${useS3 ? 'S3' : 'inline'}. First 200 chars: ${templateBody.substring(0, 200)}`);
506
+ }
507
+ throw error;
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Wait for change set to be ready (CREATE_COMPLETE status)
513
+ *
514
+ * @param {Object} params - Wait parameters
515
+ * @param {StackIdentifier} params.stackIdentifier - Stack identifier
516
+ * @param {string} params.changeSetName - Name of the change set
517
+ * @param {number} [params.maxAttempts=60] - Maximum polling attempts
518
+ * @param {number} [params.delayMs=2000] - Delay between polling attempts in milliseconds
519
+ * @returns {Promise<Object>} Change set details when ready
520
+ * @throws {Error} If change set creation fails or times out
521
+ */
522
+ async waitForChangeSet({ stackIdentifier, changeSetName, maxAttempts = 60, delayMs = 2000 }) {
523
+ const client = this._getClient();
524
+
525
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
526
+ const command = new DescribeChangeSetCommand({
527
+ StackName: stackIdentifier.stackName,
528
+ ChangeSetName: changeSetName,
529
+ });
530
+
531
+ const response = await client.send(command);
532
+
533
+ // Check for completion or failure
534
+ // AWS requires BOTH Status and ExecutionStatus checks for import change sets
535
+ if (response.Status === 'CREATE_COMPLETE') {
536
+ // Also verify ExecutionStatus is AVAILABLE (not UNAVAILABLE, EXECUTE_IN_PROGRESS, etc.)
537
+ if (response.ExecutionStatus === 'AVAILABLE') {
538
+ return response;
539
+ } else if (response.ExecutionStatus === 'UNAVAILABLE') {
540
+ throw new Error(
541
+ `Change set cannot be executed: ${response.StatusReason || 'ExecutionStatus is UNAVAILABLE'}`
542
+ );
543
+ }
544
+ // If ExecutionStatus is EXECUTE_IN_PROGRESS or EXECUTE_COMPLETE, continue waiting
545
+ }
546
+
547
+ if (response.Status === 'FAILED') {
548
+ throw new Error(
549
+ `Change set creation failed: ${response.StatusReason || 'Unknown reason'}`
550
+ );
551
+ }
552
+
553
+ // Wait before next attempt
554
+ await this._sleep(delayMs);
555
+ }
556
+
557
+ throw new Error(
558
+ `Change set creation timed out after ${maxAttempts} attempts (${(maxAttempts * delayMs) / 1000}s)`
559
+ );
560
+ }
561
+
562
+ /**
563
+ * Execute a change set
564
+ *
565
+ * @param {Object} params - Execution parameters
566
+ * @param {StackIdentifier} params.stackIdentifier - Stack identifier
567
+ * @param {string} params.changeSetName - Name of the change set to execute
568
+ * @returns {Promise<Object>} Execution result (empty object on success)
569
+ * @throws {Error} If change set execution fails
570
+ */
571
+ async executeChangeSet({ stackIdentifier, changeSetName }) {
572
+ const client = this._getClient();
573
+
574
+ const command = new ExecuteChangeSetCommand({
575
+ StackName: stackIdentifier.stackName,
576
+ ChangeSetName: changeSetName,
577
+ });
578
+
579
+ const response = await client.send(command);
580
+ return response;
581
+ }
582
+
583
+ /**
584
+ * Get stack events since a specific timestamp
585
+ *
586
+ * @param {Object} params - Event query parameters
587
+ * @param {StackIdentifier} params.stackIdentifier - Stack identifier
588
+ * @param {Date} params.since - Timestamp to filter events from
589
+ * @returns {Promise<Array>} Array of stack events sorted chronologically
590
+ * @returns {Promise<Array<Object>>} Array of events with properties:
591
+ * - EventId: string
592
+ * - StackName: string
593
+ * - LogicalResourceId: string
594
+ * - PhysicalResourceId: string
595
+ * - ResourceType: string
596
+ * - Timestamp: Date
597
+ * - ResourceStatus: string
598
+ * - ResourceStatusReason: string
599
+ */
600
+ async getStackEvents({ stackIdentifier, since }) {
601
+ const client = this._getClient();
602
+ const events = [];
603
+ let nextToken = null;
604
+
605
+ do {
606
+ const command = new DescribeStackEventsCommand({
607
+ StackName: stackIdentifier.stackName,
608
+ NextToken: nextToken,
609
+ });
610
+
611
+ const response = await client.send(command);
612
+
613
+ if (response.StackEvents) {
614
+ // Filter events after the 'since' timestamp
615
+ const filteredEvents = response.StackEvents.filter(
616
+ (event) => event.Timestamp > since
617
+ );
618
+ events.push(...filteredEvents);
619
+
620
+ // Stop pagination if we've reached events before 'since'
621
+ if (filteredEvents.length < response.StackEvents.length) {
622
+ break;
623
+ }
624
+ }
625
+
626
+ nextToken = response.NextToken;
627
+ } while (nextToken);
628
+
629
+ // Sort chronologically (oldest first)
630
+ return events.sort((a, b) => a.Timestamp - b.Timestamp);
631
+ }
632
+
633
+ /**
634
+ * Get current stack status
635
+ *
636
+ * @param {StackIdentifier} identifier - Stack identifier
637
+ * @returns {Promise<string>} Stack status (e.g., 'CREATE_COMPLETE', 'UPDATE_IN_PROGRESS')
638
+ * @throws {Error} If stack does not exist
639
+ */
640
+ async getStackStatus(identifier) {
641
+ const client = this._getClient();
642
+
643
+ const command = new DescribeStacksCommand({
644
+ StackName: identifier.stackName,
645
+ });
646
+
647
+ const response = await client.send(command);
648
+
649
+ if (!response.Stacks || response.Stacks.length === 0) {
650
+ throw new Error(
651
+ `Stack ${identifier.stackName} does not exist in region ${this.region}`
652
+ );
653
+ }
654
+
655
+ return response.Stacks[0].StackStatus;
656
+ }
657
+
658
+ /**
659
+ * Get stack resources with detailed information
660
+ *
661
+ * @param {StackIdentifier} identifier - Stack identifier
662
+ * @returns {Promise<Array>} Array of stack resources with full details
663
+ * @returns {Promise<Array<Object>>} Array of resources with properties:
664
+ * - LogicalResourceId: string
665
+ * - PhysicalResourceId: string
666
+ * - ResourceType: string
667
+ * - ResourceStatus: string
668
+ * - Timestamp: Date
669
+ * - DriftInformation: Object (if available)
670
+ * @throws {Error} If stack does not exist
671
+ */
672
+ async getStackResources(identifier) {
673
+ const client = this._getClient();
674
+
675
+ const command = new DescribeStackResourcesCommand({
676
+ StackName: identifier.stackName,
677
+ });
678
+
679
+ const response = await client.send(command);
680
+
681
+ return response.StackResources || [];
682
+ }
683
+
293
684
  // ========================================
294
685
  // Private Helper Methods
295
686
  // ========================================