@friggframework/devtools 2.0.0-next.73 → 2.0.0-next.74

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.
@@ -977,7 +977,7 @@ class VpcBuilder extends InfrastructureBuilder {
977
977
  }
978
978
 
979
979
  // Ensure subnet associations
980
- this.ensureSubnetAssociations(appDefinition, {}, result);
980
+ this.ensureSubnetAssociations(appDefinition, discoveredResources, result);
981
981
 
982
982
  // Create endpoints
983
983
  if (endpointsToCreate.includes('s3')) {
@@ -162,8 +162,8 @@ class CloudFormationDiscovery {
162
162
  // Extract subnet IDs from route table associations
163
163
  const associations = routeTable.Associations || [];
164
164
  const subnetAssociations = associations.filter(a => a.SubnetId);
165
-
166
-
165
+ discovered.routeTableAssociationCount = subnetAssociations.length;
166
+
167
167
  if (subnetAssociations.length >= 1 && !discovered.privateSubnetId1) {
168
168
  discovered.privateSubnetId1 = subnetAssociations[0].SubnetId;
169
169
  console.log(` ✓ Extracted private subnet 1 from associations: ${subnetAssociations[0].SubnetId}`);
@@ -562,24 +562,33 @@ class CloudFormationDiscovery {
562
562
  .map(a => a.SubnetId);
563
563
 
564
564
  console.log(` Route table has ${associatedSubnetIds.length} associated subnets: ${associatedSubnetIds.join(', ')}`);
565
-
565
+ discovered.routeTableAssociationCount = associatedSubnetIds.length;
566
+
566
567
  // Use the associated subnets if available
567
568
  if (associatedSubnetIds.length >= 2) {
568
569
  discovered.privateSubnetId1 = associatedSubnetIds[0];
569
570
  discovered.privateSubnetId2 = associatedSubnetIds[1];
570
571
  console.log(` ✓ Extracted subnets from route table associations: ${discovered.privateSubnetId1}, ${discovered.privateSubnetId2}`);
571
572
  } else if (associatedSubnetIds.length === 1) {
572
- // Only 1 associated subnet, use another subnet from VPC as backup
573
+ // Only 1 associated subnet, use another private subnet from VPC as backup
573
574
  discovered.privateSubnetId1 = associatedSubnetIds[0];
574
- discovered.privateSubnetId2 = subnetsResponse.Subnets.find(s => s.SubnetId !== associatedSubnetIds[0])?.SubnetId;
575
+ const otherPrivateSubnet = subnetsResponse.Subnets.find(
576
+ s => s.SubnetId !== associatedSubnetIds[0] && !s.MapPublicIpOnLaunch
577
+ );
578
+ discovered.privateSubnetId2 = otherPrivateSubnet?.SubnetId;
575
579
  console.log(` ✓ Extracted subnets (1 from route table, 1 fallback): ${discovered.privateSubnetId1}, ${discovered.privateSubnetId2}`);
576
580
  } else if (subnetsResponse.Subnets.length >= 2) {
577
- // Edge case: route table Associations array is empty even when queried by ID
578
- // This can happen when associations exist in CloudFormation but AWS API doesn't return them
579
- // Fallback: Use first 2 subnets from VPC (all subnets in same VPC should work)
580
- discovered.privateSubnetId1 = subnetsResponse.Subnets[0].SubnetId;
581
- discovered.privateSubnetId2 = subnetsResponse.Subnets[1].SubnetId;
582
- console.log(` ✓ Using first 2 subnets from VPC (route table Associations empty): ${discovered.privateSubnetId1}, ${discovered.privateSubnetId2}`);
581
+ // Route table has 0 associations pick private subnets from VPC
582
+ const privateSubnets = subnetsResponse.Subnets.filter(s => !s.MapPublicIpOnLaunch);
583
+ if (privateSubnets.length >= 2) {
584
+ discovered.privateSubnetId1 = privateSubnets[0].SubnetId;
585
+ discovered.privateSubnetId2 = privateSubnets[1].SubnetId;
586
+ console.log(` ✓ Using private subnets from VPC (route table Associations empty): ${discovered.privateSubnetId1}, ${discovered.privateSubnetId2}`);
587
+ } else {
588
+ discovered.privateSubnetId1 = subnetsResponse.Subnets[0].SubnetId;
589
+ discovered.privateSubnetId2 = subnetsResponse.Subnets[1].SubnetId;
590
+ console.warn(' ⚠️ Could not identify private subnets by MapPublicIpOnLaunch, using first 2 VPC subnets');
591
+ }
583
592
  }
584
593
  }
585
594
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Tests for CloudFormation-based Resource Discovery
3
- *
3
+ *
4
4
  * Tests discovering resources from existing CloudFormation stacks
5
5
  * before falling back to direct AWS API discovery.
6
6
  */
@@ -28,7 +28,9 @@ describe('CloudFormationDiscovery', () => {
28
28
  const result = await cfDiscovery.discoverFromStack('test-stack');
29
29
 
30
30
  expect(result).toBeNull();
31
- expect(mockProvider.describeStack).toHaveBeenCalledWith('test-stack');
31
+ expect(mockProvider.describeStack).toHaveBeenCalledWith(
32
+ 'test-stack'
33
+ );
32
34
  });
33
35
 
34
36
  it('should extract VPC resources from stack outputs', async () => {
@@ -36,7 +38,10 @@ describe('CloudFormationDiscovery', () => {
36
38
  StackName: 'test-stack',
37
39
  Outputs: [
38
40
  { OutputKey: 'VpcId', OutputValue: 'vpc-123' },
39
- { OutputKey: 'PrivateSubnetIds', OutputValue: 'subnet-1,subnet-2' },
41
+ {
42
+ OutputKey: 'PrivateSubnetIds',
43
+ OutputValue: 'subnet-1,subnet-2',
44
+ },
40
45
  { OutputKey: 'PublicSubnetId', OutputValue: 'subnet-3' },
41
46
  { OutputKey: 'SecurityGroupId', OutputValue: 'sg-123' },
42
47
  ],
@@ -61,7 +66,10 @@ describe('CloudFormationDiscovery', () => {
61
66
  const mockStack = {
62
67
  StackName: 'test-stack',
63
68
  Outputs: [
64
- { OutputKey: 'KMS_KEY_ARN', OutputValue: 'arn:aws:kms:us-east-1:123456789:key/abc' },
69
+ {
70
+ OutputKey: 'KMS_KEY_ARN',
71
+ OutputValue: 'arn:aws:kms:us-east-1:123456789:key/abc',
72
+ },
65
73
  ],
66
74
  };
67
75
 
@@ -80,10 +88,26 @@ describe('CloudFormationDiscovery', () => {
80
88
  it('should extract VPC subnets from stack resources', async () => {
81
89
  const mockStack = { StackName: 'test-stack', Outputs: [] };
82
90
  const mockResources = [
83
- { LogicalResourceId: 'FriggPrivateSubnet1', PhysicalResourceId: 'subnet-priv-1', ResourceType: 'AWS::EC2::Subnet' },
84
- { LogicalResourceId: 'FriggPrivateSubnet2', PhysicalResourceId: 'subnet-priv-2', ResourceType: 'AWS::EC2::Subnet' },
85
- { LogicalResourceId: 'FriggPublicSubnet', PhysicalResourceId: 'subnet-pub-1', ResourceType: 'AWS::EC2::Subnet' },
86
- { LogicalResourceId: 'FriggPublicSubnet2', PhysicalResourceId: 'subnet-pub-2', ResourceType: 'AWS::EC2::Subnet' },
91
+ {
92
+ LogicalResourceId: 'FriggPrivateSubnet1',
93
+ PhysicalResourceId: 'subnet-priv-1',
94
+ ResourceType: 'AWS::EC2::Subnet',
95
+ },
96
+ {
97
+ LogicalResourceId: 'FriggPrivateSubnet2',
98
+ PhysicalResourceId: 'subnet-priv-2',
99
+ ResourceType: 'AWS::EC2::Subnet',
100
+ },
101
+ {
102
+ LogicalResourceId: 'FriggPublicSubnet',
103
+ PhysicalResourceId: 'subnet-pub-1',
104
+ ResourceType: 'AWS::EC2::Subnet',
105
+ },
106
+ {
107
+ LogicalResourceId: 'FriggPublicSubnet2',
108
+ PhysicalResourceId: 'subnet-pub-2',
109
+ ResourceType: 'AWS::EC2::Subnet',
110
+ },
87
111
  ];
88
112
 
89
113
  mockProvider.describeStack.mockResolvedValue(mockStack);
@@ -100,12 +124,36 @@ describe('CloudFormationDiscovery', () => {
100
124
  it('should extract route tables and VPC endpoints from stack resources', async () => {
101
125
  const mockStack = { StackName: 'test-stack', Outputs: [] };
102
126
  const mockResources = [
103
- { LogicalResourceId: 'FriggLambdaRouteTable', PhysicalResourceId: 'rtb-123', ResourceType: 'AWS::EC2::RouteTable' },
104
- { LogicalResourceId: 'FriggVPCEndpointSecurityGroup', PhysicalResourceId: 'sg-vpce-123', ResourceType: 'AWS::EC2::SecurityGroup' },
105
- { LogicalResourceId: 'FriggS3VPCEndpoint', PhysicalResourceId: 'vpce-s3-123', ResourceType: 'AWS::EC2::VPCEndpoint' },
106
- { LogicalResourceId: 'FriggDynamoDBVPCEndpoint', PhysicalResourceId: 'vpce-ddb-123', ResourceType: 'AWS::EC2::VPCEndpoint' },
107
- { LogicalResourceId: 'FriggKMSVPCEndpoint', PhysicalResourceId: 'vpce-kms-123', ResourceType: 'AWS::EC2::VPCEndpoint' },
108
- { LogicalResourceId: 'FriggSecretsManagerVPCEndpoint', PhysicalResourceId: 'vpce-sm-123', ResourceType: 'AWS::EC2::VPCEndpoint' },
127
+ {
128
+ LogicalResourceId: 'FriggLambdaRouteTable',
129
+ PhysicalResourceId: 'rtb-123',
130
+ ResourceType: 'AWS::EC2::RouteTable',
131
+ },
132
+ {
133
+ LogicalResourceId: 'FriggVPCEndpointSecurityGroup',
134
+ PhysicalResourceId: 'sg-vpce-123',
135
+ ResourceType: 'AWS::EC2::SecurityGroup',
136
+ },
137
+ {
138
+ LogicalResourceId: 'FriggS3VPCEndpoint',
139
+ PhysicalResourceId: 'vpce-s3-123',
140
+ ResourceType: 'AWS::EC2::VPCEndpoint',
141
+ },
142
+ {
143
+ LogicalResourceId: 'FriggDynamoDBVPCEndpoint',
144
+ PhysicalResourceId: 'vpce-ddb-123',
145
+ ResourceType: 'AWS::EC2::VPCEndpoint',
146
+ },
147
+ {
148
+ LogicalResourceId: 'FriggKMSVPCEndpoint',
149
+ PhysicalResourceId: 'vpce-kms-123',
150
+ ResourceType: 'AWS::EC2::VPCEndpoint',
151
+ },
152
+ {
153
+ LogicalResourceId: 'FriggSecretsManagerVPCEndpoint',
154
+ PhysicalResourceId: 'vpce-sm-123',
155
+ ResourceType: 'AWS::EC2::VPCEndpoint',
156
+ },
109
157
  ];
110
158
 
111
159
  mockProvider.describeStack.mockResolvedValue(mockStack);
@@ -224,7 +272,8 @@ describe('CloudFormationDiscovery', () => {
224
272
  const mockResources = [
225
273
  {
226
274
  LogicalResourceId: 'DbMigrationQueue',
227
- PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue',
275
+ PhysicalResourceId:
276
+ 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue',
228
277
  ResourceType: 'AWS::SQS::Queue',
229
278
  },
230
279
  ];
@@ -237,7 +286,8 @@ describe('CloudFormationDiscovery', () => {
237
286
  expect(result).toEqual({
238
287
  fromCloudFormationStack: true,
239
288
  stackName: 'test-stack',
240
- migrationQueueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue',
289
+ migrationQueueUrl:
290
+ 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue',
241
291
  existingLogicalIds: ['DbMigrationQueue'],
242
292
  });
243
293
  });
@@ -301,7 +351,10 @@ describe('CloudFormationDiscovery', () => {
301
351
  StackName: 'test-stack',
302
352
  Outputs: [
303
353
  { OutputKey: 'VpcId', OutputValue: 'vpc-123' },
304
- { OutputKey: 'KMS_KEY_ARN', OutputValue: 'arn:aws:kms:us-east-1:123456789:key/abc' },
354
+ {
355
+ OutputKey: 'KMS_KEY_ARN',
356
+ OutputValue: 'arn:aws:kms:us-east-1:123456789:key/abc',
357
+ },
305
358
  ],
306
359
  };
307
360
 
@@ -377,7 +430,9 @@ describe('CloudFormationDiscovery', () => {
377
430
 
378
431
  mockProvider.describeStack.mockResolvedValue(mockStack);
379
432
  mockProvider.listStackResources.mockResolvedValue(mockResources);
380
- mockProvider.getEC2Client = jest.fn().mockReturnValue(mockEC2Client);
433
+ mockProvider.getEC2Client = jest
434
+ .fn()
435
+ .mockReturnValue(mockEC2Client);
381
436
 
382
437
  // Mock security group query for VPC ID
383
438
  mockEC2Client.send.mockResolvedValueOnce({
@@ -392,7 +447,10 @@ describe('CloudFormationDiscovery', () => {
392
447
  MapPublicIpOnLaunch: false,
393
448
  Tags: [
394
449
  { Key: 'ManagedBy', Value: 'Frigg' },
395
- { Key: 'aws:cloudformation:logical-id', Value: 'FriggPrivateSubnet1' },
450
+ {
451
+ Key: 'aws:cloudformation:logical-id',
452
+ Value: 'FriggPrivateSubnet1',
453
+ },
396
454
  ],
397
455
  },
398
456
  {
@@ -400,7 +458,10 @@ describe('CloudFormationDiscovery', () => {
400
458
  MapPublicIpOnLaunch: false,
401
459
  Tags: [
402
460
  { Key: 'ManagedBy', Value: 'Frigg' },
403
- { Key: 'aws:cloudformation:logical-id', Value: 'FriggPrivateSubnet2' },
461
+ {
462
+ Key: 'aws:cloudformation:logical-id',
463
+ Value: 'FriggPrivateSubnet2',
464
+ },
404
465
  ],
405
466
  },
406
467
  ],
@@ -433,7 +494,9 @@ describe('CloudFormationDiscovery', () => {
433
494
 
434
495
  mockProvider.describeStack.mockResolvedValue(mockStack);
435
496
  mockProvider.listStackResources.mockResolvedValue(mockResources);
436
- mockProvider.getEC2Client = jest.fn().mockReturnValue(mockEC2Client);
497
+ mockProvider.getEC2Client = jest
498
+ .fn()
499
+ .mockReturnValue(mockEC2Client);
437
500
 
438
501
  // Mock security group query for VPC ID
439
502
  mockEC2Client.send.mockResolvedValueOnce({
@@ -441,7 +504,9 @@ describe('CloudFormationDiscovery', () => {
441
504
  });
442
505
 
443
506
  // Mock subnet query failure
444
- mockEC2Client.send.mockRejectedValueOnce(new Error('EC2 API Error'));
507
+ mockEC2Client.send.mockRejectedValueOnce(
508
+ new Error('EC2 API Error')
509
+ );
445
510
 
446
511
  const result = await cfDiscovery.discoverFromStack('test-stack');
447
512
 
@@ -472,9 +537,13 @@ describe('CloudFormationDiscovery', () => {
472
537
 
473
538
  const result = await cfDiscovery.discoverFromStack('test-stack');
474
539
 
475
- expect(result.defaultKmsKeyId).toBe('arn:aws:kms:us-east-1:123456789:key/abc-123');
540
+ expect(result.defaultKmsKeyId).toBe(
541
+ 'arn:aws:kms:us-east-1:123456789:key/abc-123'
542
+ );
476
543
  expect(result.kmsKeyAlias).toBe('alias/test-service-dev-frigg-kms');
477
- expect(mockProvider.describeKmsKey).toHaveBeenCalledWith('alias/test-service-dev-frigg-kms');
544
+ expect(mockProvider.describeKmsKey).toHaveBeenCalledWith(
545
+ 'alias/test-service-dev-frigg-kms'
546
+ );
478
547
  });
479
548
 
480
549
  it('should query AWS API for KMS alias when serviceName and stage are provided', async () => {
@@ -499,9 +568,13 @@ describe('CloudFormationDiscovery', () => {
499
568
 
500
569
  const result = await cfDiscovery.discoverFromStack('test-stack');
501
570
 
502
- expect(result.defaultKmsKeyId).toBe('arn:aws:kms:us-east-1:123456789:key/abc-123');
571
+ expect(result.defaultKmsKeyId).toBe(
572
+ 'arn:aws:kms:us-east-1:123456789:key/abc-123'
573
+ );
503
574
  expect(result.kmsKeyAlias).toBe('alias/test-service-dev-frigg-kms');
504
- expect(mockProvider.describeKmsKey).toHaveBeenCalledWith('alias/test-service-dev-frigg-kms');
575
+ expect(mockProvider.describeKmsKey).toHaveBeenCalledWith(
576
+ 'alias/test-service-dev-frigg-kms'
577
+ );
505
578
  });
506
579
 
507
580
  it('should handle KMS alias not found gracefully', async () => {
@@ -515,9 +588,11 @@ describe('CloudFormationDiscovery', () => {
515
588
  mockProvider.describeStack.mockResolvedValue(mockStack);
516
589
  mockProvider.listStackResources.mockResolvedValue(mockResources);
517
590
  mockProvider.region = 'us-east-1';
518
- mockProvider.describeKmsKey = jest.fn().mockRejectedValue(
519
- new Error('Alias/test-service-dev-frigg-kms is not found')
520
- );
591
+ mockProvider.describeKmsKey = jest
592
+ .fn()
593
+ .mockRejectedValue(
594
+ new Error('Alias/test-service-dev-frigg-kms is not found')
595
+ );
521
596
 
522
597
  cfDiscovery.serviceName = 'test-service';
523
598
  cfDiscovery.stage = 'dev';
@@ -537,7 +612,8 @@ describe('CloudFormationDiscovery', () => {
537
612
  const mockResources = [
538
613
  {
539
614
  LogicalResourceId: 'FriggKMSKey',
540
- PhysicalResourceId: 'arn:aws:kms:us-east-1:123456789:key/xyz-789',
615
+ PhysicalResourceId:
616
+ 'arn:aws:kms:us-east-1:123456789:key/xyz-789',
541
617
  ResourceType: 'AWS::KMS::Key',
542
618
  },
543
619
  ];
@@ -549,7 +625,9 @@ describe('CloudFormationDiscovery', () => {
549
625
  const result = await cfDiscovery.discoverFromStack('test-stack');
550
626
 
551
627
  // Should use the key from stack resources, not query for alias
552
- expect(result.defaultKmsKeyId).toBe('arn:aws:kms:us-east-1:123456789:key/xyz-789');
628
+ expect(result.defaultKmsKeyId).toBe(
629
+ 'arn:aws:kms:us-east-1:123456789:key/xyz-789'
630
+ );
553
631
  expect(mockProvider.describeKmsKey).not.toHaveBeenCalled();
554
632
  });
555
633
 
@@ -562,7 +640,8 @@ describe('CloudFormationDiscovery', () => {
562
640
  const mockResources = [
563
641
  {
564
642
  LogicalResourceId: 'FriggKMSKey',
565
- PhysicalResourceId: 'arn:aws:kms:us-east-1:123456789:key/xyz-789',
643
+ PhysicalResourceId:
644
+ 'arn:aws:kms:us-east-1:123456789:key/xyz-789',
566
645
  ResourceType: 'AWS::KMS::Key',
567
646
  },
568
647
  {
@@ -581,9 +660,13 @@ describe('CloudFormationDiscovery', () => {
581
660
 
582
661
  const result = await cfDiscovery.discoverFromStack('test-stack');
583
662
 
584
- expect(result.defaultKmsKeyId).toBe('arn:aws:kms:us-east-1:123456789:key/xyz-789');
663
+ expect(result.defaultKmsKeyId).toBe(
664
+ 'arn:aws:kms:us-east-1:123456789:key/xyz-789'
665
+ );
585
666
  expect(result.kmsKeyAlias).toBe('alias/test-service-dev-frigg-kms');
586
- expect(mockProvider.describeKmsKey).toHaveBeenCalledWith('alias/test-service-dev-frigg-kms');
667
+ expect(mockProvider.describeKmsKey).toHaveBeenCalledWith(
668
+ 'alias/test-service-dev-frigg-kms'
669
+ );
587
670
  });
588
671
  });
589
672
 
@@ -638,7 +721,9 @@ describe('CloudFormationDiscovery', () => {
638
721
  mockProvider.describeStack.mockResolvedValue(mockStack);
639
722
  mockProvider.listStackResources.mockResolvedValue(mockResources);
640
723
 
641
- const result = await cfDiscovery.discoverFromStack('create-frigg-app-production');
724
+ const result = await cfDiscovery.discoverFromStack(
725
+ 'create-frigg-app-production'
726
+ );
642
727
 
643
728
  // Verify routing infrastructure was discovered
644
729
  expect(result.routeTableId).toBe('rtb-0b83aca77ccde20a6');
@@ -723,7 +808,9 @@ describe('CloudFormationDiscovery', () => {
723
808
  // Lambda security group should be extracted
724
809
  expect(result.lambdaSecurityGroupId).toBe('sg-01002240c6a446202');
725
810
  expect(result.defaultSecurityGroupId).toBe('sg-01002240c6a446202');
726
- expect(result.existingLogicalIds).toContain('FriggLambdaSecurityGroup');
811
+ expect(result.existingLogicalIds).toContain(
812
+ 'FriggLambdaSecurityGroup'
813
+ );
727
814
  });
728
815
 
729
816
  it('should support FriggPrivateRoute naming for NAT routes', async () => {
@@ -774,22 +861,27 @@ describe('CloudFormationDiscovery', () => {
774
861
 
775
862
  mockProvider.describeStack.mockResolvedValue(mockStack);
776
863
  mockProvider.listStackResources.mockResolvedValue(mockResources);
777
-
864
+
778
865
  // Mock EC2 DescribeRouteTables to return route table with VPC info
779
866
  mockProvider.getEC2Client = jest.fn().mockReturnValue({
780
867
  send: jest.fn().mockResolvedValue({
781
- RouteTables: [{
782
- RouteTableId: 'rtb-real-id',
783
- VpcId: 'vpc-extracted',
784
- Routes: [
785
- { NatGatewayId: 'nat-extracted', DestinationCidrBlock: '0.0.0.0/0' }
786
- ],
787
- Associations: [
788
- { SubnetId: 'subnet-1' },
789
- { SubnetId: 'subnet-2' }
790
- ]
791
- }]
792
- })
868
+ RouteTables: [
869
+ {
870
+ RouteTableId: 'rtb-real-id',
871
+ VpcId: 'vpc-extracted',
872
+ Routes: [
873
+ {
874
+ NatGatewayId: 'nat-extracted',
875
+ DestinationCidrBlock: '0.0.0.0/0',
876
+ },
877
+ ],
878
+ Associations: [
879
+ { SubnetId: 'subnet-1' },
880
+ { SubnetId: 'subnet-2' },
881
+ ],
882
+ },
883
+ ],
884
+ }),
793
885
  });
794
886
 
795
887
  const result = await cfDiscovery.discoverFromStack('test-stack');
@@ -799,7 +891,7 @@ describe('CloudFormationDiscovery', () => {
799
891
  expect(result.existingNatGatewayId).toBe('nat-extracted');
800
892
  expect(result.privateSubnetId1).toBe('subnet-1');
801
893
  expect(result.privateSubnetId2).toBe('subnet-2');
802
-
894
+
803
895
  // Should NOT throw 'stackName is not defined' error
804
896
  expect(result).toBeDefined();
805
897
  });
@@ -810,30 +902,60 @@ describe('CloudFormationDiscovery', () => {
810
902
  // CRITICAL: Frontify production uses OLD naming convention
811
903
  const mockStack = {
812
904
  StackName: 'create-frigg-app-production',
813
- Outputs: []
905
+ Outputs: [],
814
906
  };
815
907
 
816
908
  const mockResources = [
817
- { LogicalResourceId: 'FriggLambdaRouteTable', PhysicalResourceId: 'rtb-123', ResourceType: 'AWS::EC2::RouteTable' },
818
- { LogicalResourceId: 'FriggNATRoute', PhysicalResourceId: 'rtb-123|0.0.0.0/0', ResourceType: 'AWS::EC2::Route' },
819
- { LogicalResourceId: 'FriggSubnet1RouteAssociation', PhysicalResourceId: 'rtbassoc-1', ResourceType: 'AWS::EC2::SubnetRouteTableAssociation' },
820
- { LogicalResourceId: 'FriggSubnet2RouteAssociation', PhysicalResourceId: 'rtbassoc-2', ResourceType: 'AWS::EC2::SubnetRouteTableAssociation' },
821
- { LogicalResourceId: 'VPCEndpointS3', PhysicalResourceId: 'vpce-s3-123', ResourceType: 'AWS::EC2::VPCEndpoint' },
822
- { LogicalResourceId: 'VPCEndpointDynamoDB', PhysicalResourceId: 'vpce-ddb-123', ResourceType: 'AWS::EC2::VPCEndpoint' }
909
+ {
910
+ LogicalResourceId: 'FriggLambdaRouteTable',
911
+ PhysicalResourceId: 'rtb-123',
912
+ ResourceType: 'AWS::EC2::RouteTable',
913
+ },
914
+ {
915
+ LogicalResourceId: 'FriggNATRoute',
916
+ PhysicalResourceId: 'rtb-123|0.0.0.0/0',
917
+ ResourceType: 'AWS::EC2::Route',
918
+ },
919
+ {
920
+ LogicalResourceId: 'FriggSubnet1RouteAssociation',
921
+ PhysicalResourceId: 'rtbassoc-1',
922
+ ResourceType: 'AWS::EC2::SubnetRouteTableAssociation',
923
+ },
924
+ {
925
+ LogicalResourceId: 'FriggSubnet2RouteAssociation',
926
+ PhysicalResourceId: 'rtbassoc-2',
927
+ ResourceType: 'AWS::EC2::SubnetRouteTableAssociation',
928
+ },
929
+ {
930
+ LogicalResourceId: 'VPCEndpointS3',
931
+ PhysicalResourceId: 'vpce-s3-123',
932
+ ResourceType: 'AWS::EC2::VPCEndpoint',
933
+ },
934
+ {
935
+ LogicalResourceId: 'VPCEndpointDynamoDB',
936
+ PhysicalResourceId: 'vpce-ddb-123',
937
+ ResourceType: 'AWS::EC2::VPCEndpoint',
938
+ },
823
939
  ];
824
940
 
825
941
  mockProvider.describeStack.mockResolvedValue(mockStack);
826
942
  mockProvider.listStackResources.mockResolvedValue(mockResources);
827
943
 
828
- const result = await cfDiscovery.discoverFromStack('create-frigg-app-production');
944
+ const result = await cfDiscovery.discoverFromStack(
945
+ 'create-frigg-app-production'
946
+ );
829
947
 
830
948
  // CRITICAL: existingLogicalIds MUST contain old VPC endpoint names
831
949
  expect(result.existingLogicalIds).toBeDefined();
832
950
  expect(result.existingLogicalIds).toContain('FriggNATRoute');
833
- expect(result.existingLogicalIds).toContain('FriggSubnet1RouteAssociation');
834
- expect(result.existingLogicalIds).toContain('FriggSubnet2RouteAssociation');
835
- expect(result.existingLogicalIds).toContain('VPCEndpointS3'); // OLD naming
836
- expect(result.existingLogicalIds).toContain('VPCEndpointDynamoDB'); // OLD naming
951
+ expect(result.existingLogicalIds).toContain(
952
+ 'FriggSubnet1RouteAssociation'
953
+ );
954
+ expect(result.existingLogicalIds).toContain(
955
+ 'FriggSubnet2RouteAssociation'
956
+ );
957
+ expect(result.existingLogicalIds).toContain('VPCEndpointS3'); // OLD naming
958
+ expect(result.existingLogicalIds).toContain('VPCEndpointDynamoDB'); // OLD naming
837
959
 
838
960
  // Should also have the flat discovery properties
839
961
  expect(result.routeTableId).toBe('rtb-123');
@@ -845,15 +967,35 @@ describe('CloudFormationDiscovery', () => {
845
967
  it('should track NEW VPC endpoint logical IDs (FriggS3VPCEndpoint pattern) for newer stacks', async () => {
846
968
  const mockStack = {
847
969
  StackName: 'test-stack',
848
- Outputs: []
970
+ Outputs: [],
849
971
  };
850
972
 
851
973
  const mockResources = [
852
- { LogicalResourceId: 'FriggLambdaRouteTable', PhysicalResourceId: 'rtb-456', ResourceType: 'AWS::EC2::RouteTable' },
853
- { LogicalResourceId: 'FriggPrivateRoute', PhysicalResourceId: 'rtb-456|0.0.0.0/0', ResourceType: 'AWS::EC2::Route' },
854
- { LogicalResourceId: 'FriggS3VPCEndpoint', PhysicalResourceId: 'vpce-s3-456', ResourceType: 'AWS::EC2::VPCEndpoint' },
855
- { LogicalResourceId: 'FriggDynamoDBVPCEndpoint', PhysicalResourceId: 'vpce-ddb-456', ResourceType: 'AWS::EC2::VPCEndpoint' },
856
- { LogicalResourceId: 'FriggKMSVPCEndpoint', PhysicalResourceId: 'vpce-kms-456', ResourceType: 'AWS::EC2::VPCEndpoint' }
974
+ {
975
+ LogicalResourceId: 'FriggLambdaRouteTable',
976
+ PhysicalResourceId: 'rtb-456',
977
+ ResourceType: 'AWS::EC2::RouteTable',
978
+ },
979
+ {
980
+ LogicalResourceId: 'FriggPrivateRoute',
981
+ PhysicalResourceId: 'rtb-456|0.0.0.0/0',
982
+ ResourceType: 'AWS::EC2::Route',
983
+ },
984
+ {
985
+ LogicalResourceId: 'FriggS3VPCEndpoint',
986
+ PhysicalResourceId: 'vpce-s3-456',
987
+ ResourceType: 'AWS::EC2::VPCEndpoint',
988
+ },
989
+ {
990
+ LogicalResourceId: 'FriggDynamoDBVPCEndpoint',
991
+ PhysicalResourceId: 'vpce-ddb-456',
992
+ ResourceType: 'AWS::EC2::VPCEndpoint',
993
+ },
994
+ {
995
+ LogicalResourceId: 'FriggKMSVPCEndpoint',
996
+ PhysicalResourceId: 'vpce-kms-456',
997
+ ResourceType: 'AWS::EC2::VPCEndpoint',
998
+ },
857
999
  ];
858
1000
 
859
1001
  mockProvider.describeStack.mockResolvedValue(mockStack);
@@ -864,9 +1006,11 @@ describe('CloudFormationDiscovery', () => {
864
1006
  // Should track NEW naming pattern in existingLogicalIds
865
1007
  expect(result.existingLogicalIds).toContain('FriggPrivateRoute');
866
1008
  expect(result.existingLogicalIds).toContain('FriggS3VPCEndpoint');
867
- expect(result.existingLogicalIds).toContain('FriggDynamoDBVPCEndpoint');
1009
+ expect(result.existingLogicalIds).toContain(
1010
+ 'FriggDynamoDBVPCEndpoint'
1011
+ );
868
1012
  expect(result.existingLogicalIds).toContain('FriggKMSVPCEndpoint');
869
-
1013
+
870
1014
  // Should NOT contain old naming patterns
871
1015
  expect(result.existingLogicalIds).not.toContain('FriggNATRoute');
872
1016
  expect(result.existingLogicalIds).not.toContain('VPCEndpointS3');
@@ -879,50 +1023,89 @@ describe('CloudFormationDiscovery', () => {
879
1023
  // 1. Query ALL subnets in VPC using vpc-id filter (not association filter!)
880
1024
  // 2. Query route table by ID (RouteTableIds parameter, not Filters!)
881
1025
  // 3. Extract subnet IDs from route table's Associations array
882
-
1026
+
883
1027
  const mockStack = {
884
1028
  StackName: 'test-stack',
885
- Outputs: []
1029
+ Outputs: [],
886
1030
  };
887
1031
 
888
1032
  const mockResources = [
889
- { LogicalResourceId: 'FriggLambdaRouteTable', PhysicalResourceId: 'rtb-123', ResourceType: 'AWS::EC2::RouteTable' },
890
- { LogicalResourceId: 'FriggVPC', PhysicalResourceId: 'vpc-456', ResourceType: 'AWS::EC2::VPC' }
1033
+ {
1034
+ LogicalResourceId: 'FriggLambdaRouteTable',
1035
+ PhysicalResourceId: 'rtb-123',
1036
+ ResourceType: 'AWS::EC2::RouteTable',
1037
+ },
1038
+ {
1039
+ LogicalResourceId: 'FriggVPC',
1040
+ PhysicalResourceId: 'vpc-456',
1041
+ ResourceType: 'AWS::EC2::VPC',
1042
+ },
891
1043
  ];
892
1044
 
893
1045
  const sendMock = jest.fn();
894
1046
  sendMock
895
1047
  .mockResolvedValueOnce({
896
- RouteTables: [{
897
- RouteTableId: 'rtb-123',
898
- VpcId: 'vpc-456',
899
- Associations: [],
900
- Routes: [{ NatGatewayId: 'nat-789', DestinationCidrBlock: '0.0.0.0/0' }]
901
- }]
1048
+ RouteTables: [
1049
+ {
1050
+ RouteTableId: 'rtb-123',
1051
+ VpcId: 'vpc-456',
1052
+ Associations: [],
1053
+ Routes: [
1054
+ {
1055
+ NatGatewayId: 'nat-789',
1056
+ DestinationCidrBlock: '0.0.0.0/0',
1057
+ },
1058
+ ],
1059
+ },
1060
+ ],
1061
+ })
1062
+ .mockResolvedValueOnce({
1063
+ SecurityGroups: [{ GroupId: 'sg-default' }],
902
1064
  })
903
- .mockResolvedValueOnce({ SecurityGroups: [{ GroupId: 'sg-default' }] })
904
1065
  .mockResolvedValueOnce({
905
1066
  Subnets: [
906
- { SubnetId: 'subnet-aaa', VpcId: 'vpc-456', AvailabilityZone: 'us-east-1a' },
907
- { SubnetId: 'subnet-bbb', VpcId: 'vpc-456', AvailabilityZone: 'us-east-1b' },
908
- { SubnetId: 'subnet-ccc', VpcId: 'vpc-456', AvailabilityZone: 'us-east-1c' }
909
- ]
1067
+ {
1068
+ SubnetId: 'subnet-aaa',
1069
+ VpcId: 'vpc-456',
1070
+ AvailabilityZone: 'us-east-1a',
1071
+ },
1072
+ {
1073
+ SubnetId: 'subnet-bbb',
1074
+ VpcId: 'vpc-456',
1075
+ AvailabilityZone: 'us-east-1b',
1076
+ },
1077
+ {
1078
+ SubnetId: 'subnet-ccc',
1079
+ VpcId: 'vpc-456',
1080
+ AvailabilityZone: 'us-east-1c',
1081
+ },
1082
+ ],
910
1083
  })
911
1084
  .mockResolvedValueOnce({
912
- RouteTables: [{
913
- RouteTableId: 'rtb-123',
914
- Associations: [
915
- { RouteTableAssociationId: 'rtbassoc-111', SubnetId: 'subnet-aaa' },
916
- { RouteTableAssociationId: 'rtbassoc-222', SubnetId: 'subnet-bbb' }
917
- ]
918
- }]
1085
+ RouteTables: [
1086
+ {
1087
+ RouteTableId: 'rtb-123',
1088
+ Associations: [
1089
+ {
1090
+ RouteTableAssociationId: 'rtbassoc-111',
1091
+ SubnetId: 'subnet-aaa',
1092
+ },
1093
+ {
1094
+ RouteTableAssociationId: 'rtbassoc-222',
1095
+ SubnetId: 'subnet-bbb',
1096
+ },
1097
+ ],
1098
+ },
1099
+ ],
919
1100
  });
920
-
1101
+
921
1102
  const mockEC2Client = { send: sendMock };
922
1103
 
923
1104
  mockProvider.describeStack.mockResolvedValue(mockStack);
924
1105
  mockProvider.listStackResources.mockResolvedValue(mockResources);
925
- mockProvider.getEC2Client = jest.fn().mockReturnValue(mockEC2Client);
1106
+ mockProvider.getEC2Client = jest
1107
+ .fn()
1108
+ .mockReturnValue(mockEC2Client);
926
1109
 
927
1110
  const result = await cfDiscovery.discoverFromStack('test-stack');
928
1111
 
@@ -931,48 +1114,201 @@ describe('CloudFormationDiscovery', () => {
931
1114
  expect(result.privateSubnetId2).toBe('subnet-bbb');
932
1115
  });
933
1116
 
1117
+ it('should select only private subnets when route table has 0 associations and VPC has public + private subnets', async () => {
1118
+ // Real-world drift scenario:
1119
+ // - VPC has 3 subnets: 1 public (NAT/IGW) + 2 private (Lambda)
1120
+ // - IGW route table has all 3 associated (default route table)
1121
+ // - Frigg lambda route table has 0 associations (drift)
1122
+ // - Frigg-managed subnet query returns nothing useful
1123
+ // Expected: the fallback path selects only the 2 private subnets (MapPublicIpOnLaunch=false)
1124
+ const mockStack = {
1125
+ StackName: 'test-stack',
1126
+ Outputs: [],
1127
+ };
1128
+
1129
+ const mockResources = [
1130
+ {
1131
+ LogicalResourceId: 'FriggLambdaRouteTable',
1132
+ PhysicalResourceId: 'rtb-lambda',
1133
+ ResourceType: 'AWS::EC2::RouteTable',
1134
+ },
1135
+ {
1136
+ LogicalResourceId: 'FriggVPC',
1137
+ PhysicalResourceId: 'vpc-456',
1138
+ ResourceType: 'AWS::EC2::VPC',
1139
+ },
1140
+ ];
1141
+
1142
+ const sendMock = jest.fn();
1143
+ sendMock
1144
+ // External reference extraction: route table with 0 subnet associations
1145
+ .mockResolvedValueOnce({
1146
+ RouteTables: [
1147
+ {
1148
+ RouteTableId: 'rtb-lambda',
1149
+ VpcId: 'vpc-456',
1150
+ Associations: [
1151
+ {
1152
+ RouteTableAssociationId: 'rtbassoc-main',
1153
+ Main: true,
1154
+ },
1155
+ ],
1156
+ Routes: [
1157
+ {
1158
+ NatGatewayId: 'nat-789',
1159
+ DestinationCidrBlock: '0.0.0.0/0',
1160
+ },
1161
+ ],
1162
+ },
1163
+ ],
1164
+ })
1165
+ // DescribeSecurityGroupsCommand — default SG
1166
+ .mockResolvedValueOnce({
1167
+ SecurityGroups: [{ GroupId: 'sg-default' }],
1168
+ })
1169
+ // Frigg-managed subnet query returns nothing, forcing the fallback path to run
1170
+ .mockResolvedValueOnce({
1171
+ Subnets: [],
1172
+ })
1173
+ // Fallback VPC-wide subnet query — 3 subnets (1 public, 2 private)
1174
+ .mockResolvedValueOnce({
1175
+ Subnets: [
1176
+ {
1177
+ SubnetId: 'subnet-public',
1178
+ VpcId: 'vpc-456',
1179
+ MapPublicIpOnLaunch: true,
1180
+ AvailabilityZone: 'us-east-1a',
1181
+ },
1182
+ {
1183
+ SubnetId: 'subnet-priv-1',
1184
+ VpcId: 'vpc-456',
1185
+ MapPublicIpOnLaunch: false,
1186
+ AvailabilityZone: 'us-east-1b',
1187
+ },
1188
+ {
1189
+ SubnetId: 'subnet-priv-2',
1190
+ VpcId: 'vpc-456',
1191
+ MapPublicIpOnLaunch: false,
1192
+ AvailabilityZone: 'us-east-1c',
1193
+ },
1194
+ ],
1195
+ })
1196
+ // Fallback route table query — lambda route table still has 0 subnet associations
1197
+ .mockResolvedValueOnce({
1198
+ RouteTables: [
1199
+ {
1200
+ RouteTableId: 'rtb-lambda',
1201
+ Associations: [
1202
+ {
1203
+ RouteTableAssociationId: 'rtbassoc-main',
1204
+ Main: true,
1205
+ },
1206
+ ],
1207
+ },
1208
+ ],
1209
+ });
1210
+
1211
+ mockProvider.describeStack.mockResolvedValue(mockStack);
1212
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
1213
+ mockProvider.getEC2Client = jest
1214
+ .fn()
1215
+ .mockReturnValue({ send: sendMock });
1216
+
1217
+ const result = await cfDiscovery.discoverFromStack('test-stack');
1218
+
1219
+ // Should select ONLY the 2 private subnets
1220
+ expect(result.privateSubnetId1).toBe('subnet-priv-1');
1221
+ expect(result.privateSubnetId2).toBe('subnet-priv-2');
1222
+
1223
+ // Should NOT select the public subnet
1224
+ expect(result.privateSubnetId1).not.toBe('subnet-public');
1225
+ expect(result.privateSubnetId2).not.toBe('subnet-public');
1226
+
1227
+ // Should record 0 associations for self-heal to detect
1228
+ expect(result.routeTableAssociationCount).toBe(0);
1229
+
1230
+ // Verify the test actually exercised the fallback path:
1231
+ // 1) route table query for external refs
1232
+ // 2) default security group query
1233
+ // 3) Frigg-managed subnet query (empty)
1234
+ // 4) all-subnets-in-VPC fallback query
1235
+ // 5) route table query for association extraction
1236
+ expect(sendMock).toHaveBeenCalledTimes(5);
1237
+ expect(sendMock.mock.calls[2][0].input.Filters).toEqual([
1238
+ { Name: 'vpc-id', Values: ['vpc-456'] },
1239
+ { Name: 'tag:ManagedBy', Values: ['Frigg'] },
1240
+ ]);
1241
+ expect(sendMock.mock.calls[3][0].input.Filters).toEqual([
1242
+ { Name: 'vpc-id', Values: ['vpc-456'] },
1243
+ ]);
1244
+ });
1245
+
934
1246
  it('should handle VPC with only 1 associated subnet (use second as fallback)', async () => {
935
1247
  const mockStack = {
936
1248
  StackName: 'test-stack',
937
- Outputs: []
1249
+ Outputs: [],
938
1250
  };
939
1251
 
940
1252
  const mockResources = [
941
- { LogicalResourceId: 'FriggLambdaRouteTable', PhysicalResourceId: 'rtb-123', ResourceType: 'AWS::EC2::RouteTable' },
942
- { LogicalResourceId: 'FriggVPC', PhysicalResourceId: 'vpc-456', ResourceType: 'AWS::EC2::VPC' }
1253
+ {
1254
+ LogicalResourceId: 'FriggLambdaRouteTable',
1255
+ PhysicalResourceId: 'rtb-123',
1256
+ ResourceType: 'AWS::EC2::RouteTable',
1257
+ },
1258
+ {
1259
+ LogicalResourceId: 'FriggVPC',
1260
+ PhysicalResourceId: 'vpc-456',
1261
+ ResourceType: 'AWS::EC2::VPC',
1262
+ },
943
1263
  ];
944
1264
 
945
1265
  const sendMock = jest.fn();
946
1266
  sendMock
947
1267
  .mockResolvedValueOnce({
948
- RouteTables: [{
949
- RouteTableId: 'rtb-123',
950
- VpcId: 'vpc-456',
951
- Associations: [],
952
- Routes: [{ NatGatewayId: 'nat-789', DestinationCidrBlock: '0.0.0.0/0' }]
953
- }]
1268
+ RouteTables: [
1269
+ {
1270
+ RouteTableId: 'rtb-123',
1271
+ VpcId: 'vpc-456',
1272
+ Associations: [],
1273
+ Routes: [
1274
+ {
1275
+ NatGatewayId: 'nat-789',
1276
+ DestinationCidrBlock: '0.0.0.0/0',
1277
+ },
1278
+ ],
1279
+ },
1280
+ ],
1281
+ })
1282
+ .mockResolvedValueOnce({
1283
+ SecurityGroups: [{ GroupId: 'sg-default' }],
954
1284
  })
955
- .mockResolvedValueOnce({ SecurityGroups: [{ GroupId: 'sg-default' }] })
956
1285
  .mockResolvedValueOnce({
957
1286
  Subnets: [
958
1287
  { SubnetId: 'subnet-aaa', VpcId: 'vpc-456' },
959
- { SubnetId: 'subnet-bbb', VpcId: 'vpc-456' }
960
- ]
1288
+ { SubnetId: 'subnet-bbb', VpcId: 'vpc-456' },
1289
+ ],
961
1290
  })
962
1291
  .mockResolvedValueOnce({
963
- RouteTables: [{
964
- RouteTableId: 'rtb-123',
965
- Associations: [
966
- { RouteTableAssociationId: 'rtbassoc-111', SubnetId: 'subnet-aaa' }
967
- ]
968
- }]
1292
+ RouteTables: [
1293
+ {
1294
+ RouteTableId: 'rtb-123',
1295
+ Associations: [
1296
+ {
1297
+ RouteTableAssociationId: 'rtbassoc-111',
1298
+ SubnetId: 'subnet-aaa',
1299
+ },
1300
+ ],
1301
+ },
1302
+ ],
969
1303
  });
970
-
1304
+
971
1305
  const mockEC2Client = { send: sendMock };
972
1306
 
973
1307
  mockProvider.describeStack.mockResolvedValue(mockStack);
974
1308
  mockProvider.listStackResources.mockResolvedValue(mockResources);
975
- mockProvider.getEC2Client = jest.fn().mockReturnValue(mockEC2Client);
1309
+ mockProvider.getEC2Client = jest
1310
+ .fn()
1311
+ .mockReturnValue(mockEC2Client);
976
1312
 
977
1313
  const result = await cfDiscovery.discoverFromStack('test-stack');
978
1314
 
@@ -982,4 +1318,3 @@ describe('CloudFormationDiscovery', () => {
982
1318
  });
983
1319
  });
984
1320
  });
985
-
@@ -118,6 +118,29 @@ async function gatherDiscoveredResources(appDefinition) {
118
118
  appDefinition.vpcIsolation === 'isolated';
119
119
 
120
120
  if (stackResources && hasSomeUsefulData) {
121
+ // Self-heal: if route table exists but has 0 subnet associations, fix via EC2 API
122
+ if (appDefinition.vpc?.selfHeal &&
123
+ stackResources.routeTableId &&
124
+ stackResources.routeTableAssociationCount === 0 &&
125
+ stackResources.privateSubnetId1 && stackResources.privateSubnetId2) {
126
+
127
+ console.log(' ⚠️ Route table has 0 subnet associations - self-healing...');
128
+ const { AssociateRouteTableCommand } = require('@aws-sdk/client-ec2');
129
+ const ec2 = provider.getEC2Client();
130
+
131
+ for (const subnetId of [stackResources.privateSubnetId1, stackResources.privateSubnetId2]) {
132
+ try {
133
+ const response = await ec2.send(new AssociateRouteTableCommand({
134
+ RouteTableId: stackResources.routeTableId,
135
+ SubnetId: subnetId,
136
+ }));
137
+ console.log(` ✓ Self-healed: associated ${subnetId} → ${stackResources.routeTableId} (${response.AssociationId})`);
138
+ } catch (error) {
139
+ console.warn(` ⚠️ Self-heal failed for ${subnetId}: ${error.message}`);
140
+ }
141
+ }
142
+ }
143
+
121
144
  console.log(' ✓ Discovered resources from existing CloudFormation stack');
122
145
  console.log('✅ Cloud resource discovery completed successfully!');
123
146
  return stackResources;
@@ -12,12 +12,18 @@ jest.mock('../networking/vpc-discovery');
12
12
  jest.mock('../security/kms-discovery');
13
13
  jest.mock('../database/aurora-discovery');
14
14
  jest.mock('../parameters/ssm-discovery');
15
+ jest.mock('./cloudformation-discovery');
16
+ jest.mock('@aws-sdk/client-ec2', () => ({
17
+ AssociateRouteTableCommand: jest.fn().mockImplementation((params) => params),
18
+ }));
15
19
 
16
20
  const { CloudProviderFactory } = require('./providers/provider-factory');
17
21
  const { VpcDiscovery } = require('../networking/vpc-discovery');
18
22
  const { KmsDiscovery } = require('../security/kms-discovery');
19
23
  const { AuroraDiscovery } = require('../database/aurora-discovery');
20
24
  const { SsmDiscovery } = require('../parameters/ssm-discovery');
25
+ const { CloudFormationDiscovery } = require('./cloudformation-discovery');
26
+ const { AssociateRouteTableCommand } = require('@aws-sdk/client-ec2');
21
27
 
22
28
  describe('Resource Discovery', () => {
23
29
  let mockProvider;
@@ -31,6 +37,7 @@ describe('Resource Discovery', () => {
31
37
  delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
32
38
  delete process.env.CLOUD_PROVIDER;
33
39
  delete process.env.AWS_REGION;
40
+ delete process.env.SLS_STAGE;
34
41
 
35
42
  // Create mock provider
36
43
  mockProvider = {
@@ -64,6 +71,9 @@ describe('Resource Discovery', () => {
64
71
  };
65
72
 
66
73
  // Mock factory and discovery constructors
74
+ CloudFormationDiscovery.mockImplementation(() => ({
75
+ discoverFromStack: jest.fn().mockResolvedValue(null),
76
+ }));
67
77
  CloudProviderFactory.create = jest.fn().mockReturnValue(mockProvider);
68
78
  VpcDiscovery.mockImplementation(() => mockVpcDiscovery);
69
79
  KmsDiscovery.mockImplementation(() => mockKmsDiscovery);
@@ -377,8 +387,6 @@ describe('Resource Discovery', () => {
377
387
  });
378
388
 
379
389
  it('should default stage to dev', async () => {
380
- delete process.env.SLS_STAGE;
381
-
382
390
  const appDefinition = {
383
391
  vpc: { enable: true },
384
392
  };
@@ -411,8 +419,6 @@ describe('Resource Discovery', () => {
411
419
  stage: 'qa',
412
420
  })
413
421
  );
414
-
415
- delete process.env.SLS_STAGE;
416
422
  });
417
423
 
418
424
  it('should recognize routing infrastructure as useful data', async () => {
@@ -423,8 +429,7 @@ describe('Resource Discovery', () => {
423
429
 
424
430
  process.env.SLS_STAGE = 'production';
425
431
 
426
- // Mock CloudFormation discovery to return routing infrastructure but no VPC resource
427
- const mockCloudFormationDiscovery = {
432
+ CloudFormationDiscovery.mockImplementation(() => ({
428
433
  discoverFromStack: jest.fn().mockResolvedValue({
429
434
  fromCloudFormationStack: true,
430
435
  routeTableId: 'rtb-123',
@@ -434,20 +439,13 @@ describe('Resource Discovery', () => {
434
439
  dynamodb: 'vpce-ddb'
435
440
  },
436
441
  existingLogicalIds: ['FriggLambdaRouteTable', 'FriggNATRoute']
437
- // NO defaultVpcId, NO defaultKmsKeyId, NO auroraClusterId
438
442
  })
439
- };
440
-
441
- const { CloudFormationDiscovery } = require('./cloudformation-discovery');
442
- CloudFormationDiscovery.mockImplementation(() => mockCloudFormationDiscovery);
443
+ }));
443
444
 
444
445
  const result = await gatherDiscoveredResources(appDefinition);
445
446
 
446
- // Should use CloudFormation data without falling back to AWS API
447
447
  expect(result.routeTableId).toBe('rtb-123');
448
448
  expect(result.vpcEndpoints.s3).toBe('vpce-s3');
449
-
450
- // Should NOT call AWS API discovery
451
449
  expect(mockVpcDiscovery.discover).not.toHaveBeenCalled();
452
450
  });
453
451
 
@@ -468,11 +466,8 @@ describe('Resource Discovery', () => {
468
466
 
469
467
  describe('Isolated Mode Discovery', () => {
470
468
  beforeEach(() => {
471
- // Mock CloudFormation discovery
472
- jest.mock('./cloudformation-discovery');
473
- const { CloudFormationDiscovery } = require('./cloudformation-discovery');
474
469
  CloudFormationDiscovery.mockImplementation(() => ({
475
- discoverFromStack: jest.fn().mockResolvedValue({}), // No stack found
470
+ discoverFromStack: jest.fn().mockResolvedValue({}),
476
471
  }));
477
472
  });
478
473
 
@@ -506,12 +501,6 @@ describe('Resource Discovery', () => {
506
501
  });
507
502
 
508
503
  it('should return empty if no KMS found in isolated mode (fresh infrastructure)', async () => {
509
- const { CloudFormationDiscovery } = require('./cloudformation-discovery');
510
-
511
- // Mock that CF stack exists but we still want fresh resources
512
- CloudFormationDiscovery.mockImplementation(() => ({
513
- discoverFromStack: jest.fn().mockResolvedValue({}), // Stack exists but empty
514
- }));
515
504
 
516
505
  const appDefinition = {
517
506
  name: 'test-app',
@@ -584,5 +573,185 @@ describe('Resource Discovery', () => {
584
573
  });
585
574
  });
586
575
  });
587
- });
588
576
 
577
+ describe('VPC Self-Heal', () => {
578
+ let mockEc2Send;
579
+
580
+ beforeEach(() => {
581
+ AssociateRouteTableCommand.mockClear();
582
+ mockEc2Send = jest.fn().mockResolvedValue({ AssociationId: 'rtbassoc-new-123' });
583
+
584
+ CloudFormationDiscovery.mockImplementation(() => ({
585
+ discoverFromStack: jest.fn().mockResolvedValue({
586
+ fromCloudFormationStack: true,
587
+ routeTableId: 'rtb-123',
588
+ routeTableAssociationCount: 0,
589
+ privateSubnetId1: 'subnet-priv-1',
590
+ privateSubnetId2: 'subnet-priv-2',
591
+ defaultVpcId: 'vpc-123',
592
+ }),
593
+ }));
594
+
595
+ mockProvider.getEC2Client = jest.fn().mockReturnValue({
596
+ send: mockEc2Send,
597
+ });
598
+ });
599
+
600
+ it('should self-heal when route table has 0 associations and selfHeal is enabled', async () => {
601
+ const appDefinition = {
602
+ name: 'test-app',
603
+ vpc: { enable: true, selfHeal: true },
604
+ };
605
+
606
+ process.env.SLS_STAGE = 'production';
607
+
608
+ const result = await gatherDiscoveredResources(appDefinition);
609
+
610
+ expect(mockEc2Send).toHaveBeenCalledTimes(2);
611
+ expect(AssociateRouteTableCommand).toHaveBeenCalledWith({
612
+ RouteTableId: 'rtb-123',
613
+ SubnetId: 'subnet-priv-1',
614
+ });
615
+ expect(AssociateRouteTableCommand).toHaveBeenCalledWith({
616
+ RouteTableId: 'rtb-123',
617
+ SubnetId: 'subnet-priv-2',
618
+ });
619
+ expect(result.routeTableId).toBe('rtb-123');
620
+ });
621
+
622
+ it('should only associate private subnets with lambda route table (not public subnet)', async () => {
623
+ // Simulates CF discovery output for: 3 subnets in VPC (1 public, 2 private),
624
+ // IGW route table has all 3, Frigg lambda route table has 0 associations.
625
+ // CF discovery already filtered by !MapPublicIpOnLaunch, so only private IDs arrive here.
626
+ CloudFormationDiscovery.mockImplementation(() => ({
627
+ discoverFromStack: jest.fn().mockResolvedValue({
628
+ fromCloudFormationStack: true,
629
+ routeTableId: 'rtb-lambda',
630
+ routeTableAssociationCount: 0,
631
+ privateSubnetId1: 'subnet-priv-1',
632
+ privateSubnetId2: 'subnet-priv-2',
633
+ defaultVpcId: 'vpc-123',
634
+ }),
635
+ }));
636
+
637
+ const appDefinition = {
638
+ name: 'test-app',
639
+ vpc: { enable: true, selfHeal: true },
640
+ };
641
+
642
+ process.env.SLS_STAGE = 'production';
643
+
644
+ await gatherDiscoveredResources(appDefinition);
645
+
646
+ // Self-heal should associate ONLY the 2 private subnets
647
+ expect(mockEc2Send).toHaveBeenCalledTimes(2);
648
+ expect(AssociateRouteTableCommand).toHaveBeenCalledWith({
649
+ RouteTableId: 'rtb-lambda',
650
+ SubnetId: 'subnet-priv-1',
651
+ });
652
+ expect(AssociateRouteTableCommand).toHaveBeenCalledWith({
653
+ RouteTableId: 'rtb-lambda',
654
+ SubnetId: 'subnet-priv-2',
655
+ });
656
+
657
+ // Public subnet (subnet-public) should never appear in any AssociateRouteTableCommand call
658
+ const allCalls = AssociateRouteTableCommand.mock.calls.map(c => c[0].SubnetId);
659
+ expect(allCalls).not.toContain('subnet-public');
660
+ });
661
+
662
+ it('should not self-heal when selfHeal is disabled', async () => {
663
+ const appDefinition = {
664
+ name: 'test-app',
665
+ vpc: { enable: true, selfHeal: false },
666
+ };
667
+
668
+ process.env.SLS_STAGE = 'production';
669
+
670
+ await gatherDiscoveredResources(appDefinition);
671
+
672
+ expect(mockEc2Send).not.toHaveBeenCalled();
673
+ });
674
+
675
+ it('should not self-heal when routeTableAssociationCount is not 0', async () => {
676
+ CloudFormationDiscovery.mockImplementation(() => ({
677
+ discoverFromStack: jest.fn().mockResolvedValue({
678
+ fromCloudFormationStack: true,
679
+ routeTableId: 'rtb-123',
680
+ routeTableAssociationCount: 2,
681
+ privateSubnetId1: 'subnet-priv-1',
682
+ privateSubnetId2: 'subnet-priv-2',
683
+ defaultVpcId: 'vpc-123',
684
+ }),
685
+ }));
686
+
687
+ const appDefinition = {
688
+ name: 'test-app',
689
+ vpc: { enable: true, selfHeal: true },
690
+ };
691
+
692
+ process.env.SLS_STAGE = 'production';
693
+
694
+ await gatherDiscoveredResources(appDefinition);
695
+
696
+ expect(mockEc2Send).not.toHaveBeenCalled();
697
+ });
698
+
699
+ it('should continue associating subnet 2 if subnet 1 fails', async () => {
700
+ mockEc2Send
701
+ .mockRejectedValueOnce(new Error('Resource.AlreadyAssociated'))
702
+ .mockResolvedValueOnce({ AssociationId: 'rtbassoc-456' });
703
+
704
+ const appDefinition = {
705
+ name: 'test-app',
706
+ vpc: { enable: true, selfHeal: true },
707
+ };
708
+
709
+ process.env.SLS_STAGE = 'production';
710
+
711
+ const result = await gatherDiscoveredResources(appDefinition);
712
+
713
+ expect(mockEc2Send).toHaveBeenCalledTimes(2);
714
+ expect(result.routeTableId).toBe('rtb-123');
715
+ });
716
+
717
+ it('should handle both subnet associations failing gracefully', async () => {
718
+ mockEc2Send.mockRejectedValue(new Error('AccessDenied'));
719
+
720
+ const appDefinition = {
721
+ name: 'test-app',
722
+ vpc: { enable: true, selfHeal: true },
723
+ };
724
+
725
+ process.env.SLS_STAGE = 'production';
726
+
727
+ const result = await gatherDiscoveredResources(appDefinition);
728
+
729
+ expect(mockEc2Send).toHaveBeenCalledTimes(2);
730
+ expect(result.routeTableId).toBe('rtb-123');
731
+ });
732
+
733
+ it('should not self-heal when privateSubnetId2 is missing', async () => {
734
+ CloudFormationDiscovery.mockImplementation(() => ({
735
+ discoverFromStack: jest.fn().mockResolvedValue({
736
+ fromCloudFormationStack: true,
737
+ routeTableId: 'rtb-123',
738
+ routeTableAssociationCount: 0,
739
+ privateSubnetId1: 'subnet-priv-1',
740
+ // privateSubnetId2 missing
741
+ defaultVpcId: 'vpc-123',
742
+ }),
743
+ }));
744
+
745
+ const appDefinition = {
746
+ name: 'test-app',
747
+ vpc: { enable: true, selfHeal: true },
748
+ };
749
+
750
+ process.env.SLS_STAGE = 'production';
751
+
752
+ await gatherDiscoveredResources(appDefinition);
753
+
754
+ expect(mockEc2Send).not.toHaveBeenCalled();
755
+ });
756
+ });
757
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/devtools",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0-next.73",
4
+ "version": "2.0.0-next.74",
5
5
  "bin": {
6
6
  "frigg": "./frigg-cli/index.js"
7
7
  },
@@ -25,9 +25,9 @@
25
25
  "@babel/eslint-parser": "^7.18.9",
26
26
  "@babel/parser": "^7.25.3",
27
27
  "@babel/traverse": "^7.25.3",
28
- "@friggframework/core": "2.0.0-next.73",
29
- "@friggframework/schemas": "2.0.0-next.73",
30
- "@friggframework/test": "2.0.0-next.73",
28
+ "@friggframework/core": "2.0.0-next.74",
29
+ "@friggframework/schemas": "2.0.0-next.74",
30
+ "@friggframework/test": "2.0.0-next.74",
31
31
  "@hapi/boom": "^10.0.1",
32
32
  "@inquirer/prompts": "^5.3.8",
33
33
  "axios": "^1.7.2",
@@ -55,8 +55,8 @@
55
55
  "validate-npm-package-name": "^5.0.0"
56
56
  },
57
57
  "devDependencies": {
58
- "@friggframework/eslint-config": "2.0.0-next.73",
59
- "@friggframework/prettier-config": "2.0.0-next.73",
58
+ "@friggframework/eslint-config": "2.0.0-next.74",
59
+ "@friggframework/prettier-config": "2.0.0-next.74",
60
60
  "aws-sdk-client-mock": "^4.1.0",
61
61
  "aws-sdk-client-mock-jest": "^4.1.0",
62
62
  "jest": "^30.1.3",
@@ -88,5 +88,5 @@
88
88
  "publishConfig": {
89
89
  "access": "public"
90
90
  },
91
- "gitHead": "187cc9ebe636a66454928e03801b7876bbf252bb"
91
+ "gitHead": "8f935e0715e46299f5bc720d2ce8551da5d5705a"
92
92
  }