@friggframework/devtools 2.0.0--canary.428.887095f.0 โ†’ 2.0.0--canary.428.6b04c24.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.
@@ -96,10 +96,11 @@ class AWSDiscovery {
96
96
  /**
97
97
  * Find private subnets for the given VPC
98
98
  * @param {string} vpcId - The VPC ID to search within
99
+ * @param {boolean} autoConvert - If true, convert public subnets to private if needed
99
100
  * @returns {Promise<Array>} Array of subnet objects (at least 2 for high availability)
100
101
  * @throws {Error} If no subnets are found in the VPC
101
102
  */
102
- async findPrivateSubnets(vpcId) {
103
+ async findPrivateSubnets(vpcId, autoConvert = false) {
103
104
  try {
104
105
  const command = new DescribeSubnetsCommand({
105
106
  Filters: [
@@ -109,14 +110,16 @@ class AWSDiscovery {
109
110
  }
110
111
  ]
111
112
  });
112
-
113
+
113
114
  const response = await this.ec2Client.send(command);
114
-
115
+
115
116
  if (!response.Subnets || response.Subnets.length === 0) {
116
117
  throw new Error(`No subnets found in VPC ${vpcId}`);
117
118
  }
118
119
 
119
- // Prefer private subnets (no direct route to IGW)
120
+ console.log(`\n๐Ÿ” Analyzing ${response.Subnets.length} subnets in VPC ${vpcId}...`);
121
+
122
+ // Categorize subnets by their actual routing
120
123
  const privateSubnets = [];
121
124
  const publicSubnets = [];
122
125
 
@@ -125,17 +128,74 @@ class AWSDiscovery {
125
128
  const isPrivate = await this.isSubnetPrivate(subnet.SubnetId);
126
129
  if (isPrivate) {
127
130
  privateSubnets.push(subnet);
131
+ console.log(` ๐Ÿ”’ Private subnet: ${subnet.SubnetId} (AZ: ${subnet.AvailabilityZone})`);
128
132
  } else {
129
133
  publicSubnets.push(subnet);
134
+ console.log(` ๐ŸŒ Public subnet: ${subnet.SubnetId} (AZ: ${subnet.AvailabilityZone})`);
135
+ }
136
+ }
137
+
138
+ console.log(`\n๐Ÿ“Š Subnet Analysis Results:`);
139
+ console.log(` - Private subnets: ${privateSubnets.length}`);
140
+ console.log(` - Public subnets: ${publicSubnets.length}`);
141
+
142
+ // If we have at least 2 private subnets, use them
143
+ if (privateSubnets.length >= 2) {
144
+ console.log(`โœ… Found ${privateSubnets.length} private subnets for Lambda deployment`);
145
+ return privateSubnets.slice(0, 2);
146
+ }
147
+
148
+ // If we have 1 private subnet, we need at least one more
149
+ if (privateSubnets.length === 1) {
150
+ console.warn(`โš ๏ธ Only 1 private subnet found. Need at least 2 for high availability.`);
151
+ if (publicSubnets.length > 0 && autoConvert) {
152
+ console.log(`๐Ÿ”„ Will convert 1 public subnet to private for high availability...`);
153
+ // Note: The actual conversion happens in the serverless template
130
154
  }
155
+ // Return what we have - mix of private and public if needed
156
+ return [...privateSubnets, ...publicSubnets].slice(0, 2);
131
157
  }
132
158
 
133
- // Return at least 2 subnets for high availability
134
- const selectedSubnets = privateSubnets.length >= 2 ?
135
- privateSubnets.slice(0, 2) :
136
- response.Subnets.slice(0, 2);
159
+ // No private subnets found at all - this is a problem!
160
+ if (privateSubnets.length === 0 && publicSubnets.length > 0) {
161
+ console.error(`โŒ CRITICAL: No private subnets found, but ${publicSubnets.length} public subnets exist`);
162
+ console.error(`โŒ Lambda functions should NOT be deployed in public subnets!`);
163
+
164
+ if (autoConvert && publicSubnets.length >= 3) {
165
+ console.log(`\n๐Ÿ”ง AUTO-CONVERSION: Will configure subnets for proper isolation...`);
166
+ console.log(` - Keeping ${publicSubnets[0].SubnetId} as public (for NAT Gateway)`);
167
+ console.log(` - Converting ${publicSubnets[1].SubnetId} to private (for Lambda)`);
168
+ if (publicSubnets[2]) {
169
+ console.log(` - Converting ${publicSubnets[2].SubnetId} to private (for Lambda)`);
170
+ }
137
171
 
138
- return selectedSubnets;
172
+ // Return subnets that SHOULD be private (indexes 1 and 2)
173
+ // The actual conversion happens in the serverless template
174
+ return publicSubnets.slice(1, 3);
175
+ } else if (autoConvert && publicSubnets.length >= 2) {
176
+ console.log(`\n๐Ÿ”ง AUTO-CONVERSION: Only ${publicSubnets.length} subnets available`);
177
+ console.log(` - Will need to create new subnets or reconfigure existing ones`);
178
+ // Return what we have but flag for conversion
179
+ return publicSubnets.slice(0, 2);
180
+ } else {
181
+ console.error(`\nโš ๏ธ CONFIGURATION ERROR:`);
182
+ console.error(` Found ${publicSubnets.length} public subnets but no private subnets.`);
183
+ console.error(` Lambda functions require private subnets for security.`);
184
+ console.error(`\n Options:`);
185
+ console.error(` 1. Enable selfHeal: true in vpc configuration`);
186
+ console.error(` 2. Create private subnets manually`);
187
+ console.error(` 3. Set subnets.management: 'create' to create new private subnets`);
188
+
189
+ throw new Error(
190
+ `No private subnets found in VPC ${vpcId}. ` +
191
+ `Found ${publicSubnets.length} public subnets. ` +
192
+ `Lambda requires private subnets. Enable selfHeal or create private subnets.`
193
+ );
194
+ }
195
+ }
196
+
197
+ // No subnets at all?
198
+ throw new Error(`No subnets found in VPC ${vpcId}`);
139
199
  } catch (error) {
140
200
  console.error('Error finding private subnets:', error);
141
201
  throw error;
@@ -450,6 +510,8 @@ class AWSDiscovery {
450
510
  console.warn('This is a Frigg-managed NAT Gateway that may have been misconfigured by route table changes');
451
511
  console.warn('Consider enabling selfHeal: true to fix this automatically');
452
512
  // Return it anyway if it's Frigg-managed - we can fix the routes
513
+ // Mark that it's in a private subnet
514
+ natGateway._isInPrivateSubnet = true;
453
515
  return natGateway;
454
516
  } else {
455
517
  console.warn('NAT Gateways MUST be placed in public subnets with Internet Gateway routes');
@@ -460,11 +522,13 @@ class AWSDiscovery {
460
522
 
461
523
  if (isFriggNat) {
462
524
  console.log(`Found existing Frigg-managed NAT Gateway: ${natGateway.NatGatewayId}`);
525
+ natGateway._isInPrivateSubnet = false;
463
526
  return natGateway;
464
527
  }
465
528
 
466
529
  // Return first valid NAT Gateway that's in a public subnet
467
530
  console.log(`Found existing NAT Gateway in public subnet: ${natGateway.NatGatewayId}`);
531
+ natGateway._isInPrivateSubnet = false;
468
532
  return natGateway;
469
533
  }
470
534
 
@@ -790,39 +854,51 @@ class AWSDiscovery {
790
854
  * @returns {string|null} return.defaultKmsKeyId - Default KMS key ARN or null if not found
791
855
  * @throws {Error} If resource discovery fails
792
856
  */
793
- async discoverResources() {
857
+ async discoverResources(options = {}) {
794
858
  try {
795
- console.log('Discovering AWS resources for Frigg deployment...');
796
-
859
+ console.log('\n๐Ÿš€ Discovering AWS resources for Frigg deployment...');
860
+ console.log('โ•'.repeat(60));
861
+
797
862
  const vpc = await this.findDefaultVpc();
798
- console.log(`Found VPC: ${vpc.VpcId}`);
799
-
800
- const privateSubnets = await this.findPrivateSubnets(vpc.VpcId);
801
- console.log(`Found ${privateSubnets.length} private subnets: ${privateSubnets.map(s => s.SubnetId).join(', ')}`);
802
-
863
+ console.log(`\nโœ… Found VPC: ${vpc.VpcId}`);
864
+
865
+ // Enable auto-convert if selfHeal is enabled
866
+ const autoConvert = options.selfHeal || false;
867
+
868
+ const privateSubnets = await this.findPrivateSubnets(vpc.VpcId, autoConvert);
869
+ console.log(`\nโœ… Selected subnets for Lambda: ${privateSubnets.map(s => s.SubnetId).join(', ')}`);
870
+
803
871
  const publicSubnet = await this.findPublicSubnets(vpc.VpcId);
804
- console.log(`Found public subnet for NAT Gateway: ${publicSubnet.SubnetId}`);
805
-
872
+ if (publicSubnet) {
873
+ console.log(`\nโœ… Found public subnet for NAT Gateway: ${publicSubnet.SubnetId}`);
874
+ } else {
875
+ console.log(`\nโš ๏ธ No public subnet found - NAT Gateway creation may fail`);
876
+ }
877
+
806
878
  const securityGroup = await this.findDefaultSecurityGroup(vpc.VpcId);
807
- console.log(`Found security group: ${securityGroup.GroupId}`);
808
-
879
+ console.log(`\nโœ… Found security group: ${securityGroup.GroupId}`);
880
+
809
881
  const routeTable = await this.findPrivateRouteTable(vpc.VpcId);
810
- console.log(`Found route table: ${routeTable.RouteTableId}`);
811
-
882
+ console.log(`โœ… Found route table: ${routeTable.RouteTableId}`);
883
+
812
884
  const kmsKeyArn = await this.findDefaultKmsKey();
813
885
  if (kmsKeyArn) {
814
- console.log(`Found KMS key: ${kmsKeyArn}`);
886
+ console.log(`โœ… Found KMS key: ${kmsKeyArn}`);
815
887
  } else {
816
- console.log('No KMS key found');
888
+ console.log('โ„น๏ธ No KMS key found');
817
889
  }
818
890
 
819
891
  // Try to find existing NAT Gateway
820
892
  const existingNatGateway = await this.findExistingNatGateway(vpc.VpcId);
821
893
  let natGatewayId = null;
822
894
  let elasticIpAllocationId = null;
823
-
895
+ let natGatewayInPrivateSubnet = false;
896
+
824
897
  if (existingNatGateway) {
825
898
  natGatewayId = existingNatGateway.NatGatewayId;
899
+ // Check if NAT Gateway is in a private subnet (from our detection)
900
+ natGatewayInPrivateSubnet = existingNatGateway._isInPrivateSubnet || false;
901
+
826
902
  // Get the EIP allocation ID from the NAT Gateway
827
903
  if (existingNatGateway.NatGatewayAddresses && existingNatGateway.NatGatewayAddresses.length > 0) {
828
904
  elasticIpAllocationId = existingNatGateway.NatGatewayAddresses[0].AllocationId;
@@ -835,6 +911,42 @@ class AWSDiscovery {
835
911
  }
836
912
  }
837
913
 
914
+ // Check if the "private" subnets are actually public
915
+ const subnet1IsActuallyPrivate = privateSubnets[0] ?
916
+ await this.isSubnetPrivate(privateSubnets[0].SubnetId) : false;
917
+ const subnet2IsActuallyPrivate = privateSubnets[1] ?
918
+ await this.isSubnetPrivate(privateSubnets[1].SubnetId) :
919
+ subnet1IsActuallyPrivate;
920
+
921
+ const subnetStatus = {
922
+ requiresConversion: !subnet1IsActuallyPrivate || !subnet2IsActuallyPrivate,
923
+ subnet1NeedsConversion: !subnet1IsActuallyPrivate,
924
+ subnet2NeedsConversion: !subnet2IsActuallyPrivate
925
+ };
926
+
927
+ if (subnetStatus.requiresConversion) {
928
+ console.log(`\nโš ๏ธ SUBNET CONFIGURATION WARNING:`);
929
+ if (subnetStatus.subnet1NeedsConversion && privateSubnets[0]) {
930
+ console.log(` - Subnet ${privateSubnets[0].SubnetId} is currently PUBLIC but will be used for Lambda`);
931
+ }
932
+ if (subnetStatus.subnet2NeedsConversion && privateSubnets[1]) {
933
+ console.log(` - Subnet ${privateSubnets[1].SubnetId} is currently PUBLIC but will be used for Lambda`);
934
+ }
935
+ console.log(` ๐Ÿ’ก Enable selfHeal: true to automatically fix this`);
936
+ }
937
+
938
+ console.log(`\n${'โ•'.repeat(60)}`);
939
+ console.log('๐Ÿ“‹ Discovery Summary:');
940
+ console.log(` VPC: ${vpc.VpcId}`);
941
+ console.log(` Lambda Subnets: ${privateSubnets.map(s => s.SubnetId).join(', ')}`);
942
+ console.log(` NAT Subnet: ${publicSubnet?.SubnetId || 'None (needs creation)'}`);
943
+ console.log(` NAT Gateway: ${natGatewayId || 'None (will be created)'}`);
944
+ console.log(` Elastic IP: ${elasticIpAllocationId || 'None (will be allocated)'}`);
945
+ if (subnetStatus.requiresConversion) {
946
+ console.log(` โš ๏ธ Subnet Conversion Required: Yes`);
947
+ }
948
+ console.log(`${'โ•'.repeat(60)}\n`);
949
+
838
950
  return {
839
951
  defaultVpcId: vpc.VpcId,
840
952
  defaultSecurityGroupId: securityGroup.GroupId,
@@ -844,7 +956,19 @@ class AWSDiscovery {
844
956
  privateRouteTableId: routeTable.RouteTableId,
845
957
  defaultKmsKeyId: kmsKeyArn,
846
958
  existingNatGatewayId: natGatewayId,
847
- existingElasticIpAllocationId: elasticIpAllocationId
959
+ existingElasticIpAllocationId: elasticIpAllocationId,
960
+ natGatewayInPrivateSubnet: natGatewayInPrivateSubnet,
961
+ subnetConversionRequired: subnetStatus.requiresConversion,
962
+ privateSubnetsWithWrongRoutes: (() => {
963
+ const wrongRoutes = [];
964
+ if (subnetStatus.subnet1NeedsConversion && privateSubnets[0]) {
965
+ wrongRoutes.push(privateSubnets[0].SubnetId);
966
+ }
967
+ if (subnetStatus.subnet2NeedsConversion && privateSubnets[1]) {
968
+ wrongRoutes.push(privateSubnets[1].SubnetId);
969
+ }
970
+ return wrongRoutes;
971
+ })()
848
972
  };
849
973
  } catch (error) {
850
974
  console.error('Error discovering AWS resources:', error);
@@ -1,298 +1,545 @@
1
- const AWS = require('aws-sdk');
1
+ const { mockClient } = require('aws-sdk-client-mock');
2
2
  const { AWSDiscovery } = require('./aws-discovery');
3
3
 
4
- // Mock AWS SDK
5
- jest.mock('@aws-sdk/client-ec2');
6
- jest.mock('@aws-sdk/client-kms');
7
- jest.mock('@aws-sdk/client-sts');
8
-
9
- const { EC2Client, DescribeVpcsCommand, DescribeSubnetsCommand, DescribeSecurityGroupsCommand, DescribeRouteTablesCommand } = require('@aws-sdk/client-ec2');
10
- const { KMSClient, ListKeysCommand, DescribeKeyCommand } = require('@aws-sdk/client-kms');
11
- const { STSClient, GetCallerIdentityCommand } = require('@aws-sdk/client-sts');
4
+ // Import AWS SDK commands
5
+ const {
6
+ EC2Client,
7
+ DescribeVpcsCommand,
8
+ DescribeSubnetsCommand,
9
+ DescribeSecurityGroupsCommand,
10
+ DescribeRouteTablesCommand,
11
+ DescribeNatGatewaysCommand,
12
+ DescribeAddressesCommand
13
+ } = require('@aws-sdk/client-ec2');
14
+ const {
15
+ KMSClient,
16
+ ListKeysCommand,
17
+ DescribeKeyCommand
18
+ } = require('@aws-sdk/client-kms');
19
+ const {
20
+ STSClient,
21
+ GetCallerIdentityCommand
22
+ } = require('@aws-sdk/client-sts');
23
+
24
+ // Create mock clients
25
+ const ec2Mock = mockClient(EC2Client);
26
+ const kmsMock = mockClient(KMSClient);
27
+ const stsMock = mockClient(STSClient);
12
28
 
13
29
  describe('AWSDiscovery', () => {
14
30
  let discovery;
15
- let mockEC2Send;
16
- let mockKMSSend;
17
- let mockSTSSend;
18
31
 
19
32
  beforeEach(() => {
33
+ // Reset all mocks before each test
34
+ ec2Mock.reset();
35
+ kmsMock.reset();
36
+ stsMock.reset();
37
+
20
38
  discovery = new AWSDiscovery('us-east-1');
21
-
22
- // Create mock send functions
23
- mockEC2Send = jest.fn();
24
- mockKMSSend = jest.fn();
25
- mockSTSSend = jest.fn();
26
-
27
- // Mock the client constructors and send methods
28
- EC2Client.mockImplementation(() => ({
29
- send: mockEC2Send
30
- }));
31
-
32
- KMSClient.mockImplementation(() => ({
33
- send: mockKMSSend
34
- }));
35
-
36
- STSClient.mockImplementation(() => ({
37
- send: mockSTSSend
38
- }));
39
-
40
- // Reset mocks
41
- jest.clearAllMocks();
42
39
  });
43
40
 
44
41
  describe('getAccountId', () => {
45
42
  it('should return AWS account ID', async () => {
46
43
  const mockAccountId = '123456789012';
47
- mockSTSSend.mockResolvedValue({
44
+ stsMock.on(GetCallerIdentityCommand).resolves({
48
45
  Account: mockAccountId
49
46
  });
50
47
 
51
- const result = await discovery.getAccountId();
52
-
53
- expect(result).toBe(mockAccountId);
54
- expect(mockSTSSend).toHaveBeenCalledWith(expect.any(GetCallerIdentityCommand));
48
+ const accountId = await discovery.getAccountId();
49
+ expect(accountId).toBe(mockAccountId);
55
50
  });
56
51
 
57
52
  it('should throw error when STS call fails', async () => {
58
- const error = new Error('STS Error');
59
- mockSTSSend.mockRejectedValue(error);
53
+ stsMock.on(GetCallerIdentityCommand).rejects(new Error('STS error'));
60
54
 
61
- await expect(discovery.getAccountId()).rejects.toThrow('STS Error');
55
+ await expect(discovery.getAccountId()).rejects.toThrow('STS error');
62
56
  });
63
57
  });
64
58
 
65
59
  describe('findDefaultVpc', () => {
66
- it('should return default VPC when found', async () => {
67
- const mockVpc = {
68
- VpcId: 'vpc-12345678',
69
- IsDefault: true,
70
- State: 'available'
71
- };
60
+ it('should return default VPC when available', async () => {
61
+ const mockVpc = { VpcId: 'vpc-12345678', IsDefault: true };
62
+ ec2Mock.on(DescribeVpcsCommand).resolves({
63
+ Vpcs: [mockVpc]
64
+ });
72
65
 
73
- mockEC2Send.mockResolvedValue({
66
+ const vpc = await discovery.findDefaultVpc();
67
+ expect(vpc).toEqual(mockVpc);
68
+ });
69
+
70
+ it('should return first VPC when no default VPC exists', async () => {
71
+ const mockVpc = { VpcId: 'vpc-12345678', IsDefault: false };
72
+ ec2Mock.on(DescribeVpcsCommand).resolves({
74
73
  Vpcs: [mockVpc]
75
74
  });
76
75
 
77
- const result = await discovery.findDefaultVpc();
76
+ const vpc = await discovery.findDefaultVpc();
77
+ expect(vpc).toEqual(mockVpc);
78
+ });
78
79
 
79
- expect(result).toEqual(mockVpc);
80
- expect(mockEC2Send).toHaveBeenCalledWith(expect.objectContaining({
81
- input: {
82
- Filters: [{
83
- Name: 'is-default',
84
- Values: ['true']
85
- }]
86
- }
87
- }));
80
+ it('should throw error when no VPCs found', async () => {
81
+ ec2Mock.on(DescribeVpcsCommand).resolves({
82
+ Vpcs: []
83
+ });
84
+
85
+ await expect(discovery.findDefaultVpc()).rejects.toThrow('No VPC found in the account');
88
86
  });
87
+ });
89
88
 
90
- it('should return first available VPC when no default VPC exists', async () => {
91
- const mockVpc = {
92
- VpcId: 'vpc-87654321',
93
- IsDefault: false,
94
- State: 'available'
95
- };
89
+ describe('isSubnetPrivate', () => {
90
+ const mockVpcId = 'vpc-12345678';
91
+ const mockSubnetId = 'subnet-12345678';
96
92
 
97
- mockEC2Send
98
- .mockResolvedValueOnce({ Vpcs: [] }) // No default VPC
99
- .mockResolvedValueOnce({ Vpcs: [mockVpc] }); // All VPCs
93
+ it('should return true for private subnet', async () => {
94
+ // Mock subnet lookup first
95
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
96
+ Subnets: [{ SubnetId: mockSubnetId, VpcId: mockVpcId }]
97
+ });
100
98
 
101
- const result = await discovery.findDefaultVpc();
99
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
100
+ RouteTables: [{
101
+ Associations: [{ SubnetId: mockSubnetId }],
102
+ Routes: [
103
+ { GatewayId: 'local', DestinationCidrBlock: '10.0.0.0/16' }
104
+ ]
105
+ }]
106
+ });
102
107
 
103
- expect(result).toEqual(mockVpc);
104
- expect(mockEC2Send).toHaveBeenCalledTimes(2);
108
+ const isPrivate = await discovery.isSubnetPrivate(mockSubnetId, mockVpcId);
109
+ expect(isPrivate).toBe(true);
105
110
  });
106
111
 
107
- it('should throw error when no VPCs found', async () => {
108
- mockEC2Send
109
- .mockResolvedValueOnce({ Vpcs: [] }) // No default VPC
110
- .mockResolvedValueOnce({ Vpcs: [] }); // No VPCs at all
112
+ it('should return false for public subnet', async () => {
113
+ // Mock subnet lookup first
114
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
115
+ Subnets: [{ SubnetId: mockSubnetId, VpcId: mockVpcId }]
116
+ });
111
117
 
112
- await expect(discovery.findDefaultVpc()).rejects.toThrow('No VPC found in the account');
118
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
119
+ RouteTables: [{
120
+ Associations: [{ SubnetId: mockSubnetId }],
121
+ Routes: [
122
+ { GatewayId: 'igw-12345', DestinationCidrBlock: '0.0.0.0/0' }
123
+ ]
124
+ }]
125
+ });
126
+
127
+ const isPrivate = await discovery.isSubnetPrivate(mockSubnetId, mockVpcId);
128
+ expect(isPrivate).toBe(false);
113
129
  });
114
130
  });
115
131
 
116
132
  describe('findPrivateSubnets', () => {
117
133
  const mockVpcId = 'vpc-12345678';
118
134
 
119
- it('should return private subnets when found', async () => {
135
+ it('should return private subnets', async () => {
120
136
  const mockSubnets = [
121
- { SubnetId: 'subnet-private-1', VpcId: mockVpcId },
122
- { SubnetId: 'subnet-private-2', VpcId: mockVpcId }
137
+ { SubnetId: 'subnet-private-1', AvailabilityZone: 'us-east-1a' },
138
+ { SubnetId: 'subnet-private-2', AvailabilityZone: 'us-east-1b' }
123
139
  ];
124
140
 
125
- mockEC2Send
126
- .mockResolvedValueOnce({ Subnets: mockSubnets }) // DescribeSubnets
127
- .mockResolvedValue({ RouteTables: [] }); // DescribeRouteTables (private)
141
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
142
+ Subnets: mockSubnets
143
+ });
128
144
 
129
- const result = await discovery.findPrivateSubnets(mockVpcId);
145
+ // Mock route tables - no IGW routes (private)
146
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
147
+ RouteTables: [{
148
+ Associations: [
149
+ { SubnetId: 'subnet-private-1' },
150
+ { SubnetId: 'subnet-private-2' }
151
+ ],
152
+ Routes: [
153
+ { GatewayId: 'local', DestinationCidrBlock: '10.0.0.0/16' }
154
+ ]
155
+ }]
156
+ });
130
157
 
131
- expect(result).toHaveLength(2);
132
- expect(result[0].SubnetId).toBe('subnet-private-1');
133
- expect(result[1].SubnetId).toBe('subnet-private-2');
158
+ const subnets = await discovery.findPrivateSubnets(mockVpcId);
159
+ expect(subnets).toEqual(mockSubnets);
134
160
  });
135
161
 
136
- it('should return at least 2 subnets even if mixed private/public', async () => {
162
+ it('should throw error when no private subnets found and autoConvert is false', async () => {
137
163
  const mockSubnets = [
138
- { SubnetId: 'subnet-1', VpcId: mockVpcId },
139
- { SubnetId: 'subnet-2', VpcId: mockVpcId },
140
- { SubnetId: 'subnet-3', VpcId: mockVpcId }
164
+ { SubnetId: 'subnet-public-1', AvailabilityZone: 'us-east-1a' },
165
+ { SubnetId: 'subnet-public-2', AvailabilityZone: 'us-east-1b' },
166
+ { SubnetId: 'subnet-public-3', AvailabilityZone: 'us-east-1c' }
141
167
  ];
142
168
 
143
- mockEC2Send
144
- .mockResolvedValueOnce({ Subnets: mockSubnets }) // DescribeSubnets
145
- .mockResolvedValue({ // Only one private subnet
146
- RouteTables: [{
147
- Routes: [] // No IGW route = private
148
- }]
149
- });
169
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
170
+ Subnets: mockSubnets
171
+ });
150
172
 
151
- const result = await discovery.findPrivateSubnets(mockVpcId);
173
+ // Mock route tables - has IGW routes (public)
174
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
175
+ RouteTables: [{
176
+ Associations: [
177
+ { SubnetId: 'subnet-public-1' },
178
+ { SubnetId: 'subnet-public-2' },
179
+ { SubnetId: 'subnet-public-3' }
180
+ ],
181
+ Routes: [
182
+ { GatewayId: 'igw-12345', DestinationCidrBlock: '0.0.0.0/0' }
183
+ ]
184
+ }]
185
+ });
152
186
 
153
- expect(result).toHaveLength(2);
187
+ await expect(discovery.findPrivateSubnets(mockVpcId, false))
188
+ .rejects.toThrow('No private subnets found in VPC');
154
189
  });
155
190
 
156
- it('should throw error when no subnets found', async () => {
157
- mockEC2Send.mockResolvedValue({ Subnets: [] });
158
-
159
- await expect(discovery.findPrivateSubnets(mockVpcId)).rejects.toThrow(`No subnets found in VPC ${mockVpcId}`);
160
- });
161
- });
191
+ it('should return public subnets with warning when autoConvert is true', async () => {
192
+ const mockSubnets = [
193
+ { SubnetId: 'subnet-public-1', AvailabilityZone: 'us-east-1a' },
194
+ { SubnetId: 'subnet-public-2', AvailabilityZone: 'us-east-1b' }
195
+ ];
162
196
 
163
- describe('isSubnetPrivate', () => {
164
- const mockSubnetId = 'subnet-12345678';
197
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
198
+ Subnets: mockSubnets
199
+ });
165
200
 
166
- it('should return false for public subnet (has IGW route)', async () => {
167
- mockEC2Send.mockResolvedValue({
201
+ // Mock route tables - has IGW routes (public)
202
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
168
203
  RouteTables: [{
169
- Routes: [{
170
- GatewayId: 'igw-12345678',
171
- DestinationCidrBlock: '0.0.0.0/0'
172
- }]
204
+ Associations: [
205
+ { SubnetId: 'subnet-public-1' },
206
+ { SubnetId: 'subnet-public-2' }
207
+ ],
208
+ Routes: [
209
+ { GatewayId: 'igw-12345', DestinationCidrBlock: '0.0.0.0/0' }
210
+ ]
173
211
  }]
174
212
  });
175
213
 
176
- const result = await discovery.isSubnetPrivate(mockSubnetId);
177
-
178
- expect(result).toBe(false);
214
+ const subnets = await discovery.findPrivateSubnets(mockVpcId, true);
215
+ expect(subnets).toHaveLength(2);
216
+ expect(subnets[0].SubnetId).toBe('subnet-public-1');
179
217
  });
218
+ });
180
219
 
181
- it('should return true for private subnet (no IGW route)', async () => {
182
- mockEC2Send.mockResolvedValue({
220
+ describe('findPublicSubnets', () => {
221
+ const mockVpcId = 'vpc-12345678';
222
+
223
+ it('should return public subnet', async () => {
224
+ const mockSubnet = { SubnetId: 'subnet-public-1' };
225
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
226
+ Subnets: [mockSubnet]
227
+ });
228
+
229
+ // Mock route table check to show it's public
230
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
183
231
  RouteTables: [{
184
- Routes: [{
185
- GatewayId: 'local',
186
- DestinationCidrBlock: '10.0.0.0/16'
187
- }]
232
+ Associations: [{ SubnetId: 'subnet-public-1' }],
233
+ Routes: [{ GatewayId: 'igw-12345' }] // Has IGW = public
188
234
  }]
189
235
  });
190
236
 
191
- const result = await discovery.isSubnetPrivate(mockSubnetId);
237
+ const subnet = await discovery.findPublicSubnets(mockVpcId);
238
+ expect(subnet).toEqual(mockSubnet);
239
+ });
240
+
241
+ it('should throw error when no subnets found', async () => {
242
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
243
+ Subnets: []
244
+ });
192
245
 
193
- expect(result).toBe(true);
246
+ await expect(discovery.findPublicSubnets(mockVpcId))
247
+ .rejects.toThrow('No subnets found in VPC');
194
248
  });
249
+ });
195
250
 
196
- it('should default to private on error', async () => {
197
- mockEC2Send.mockRejectedValue(new Error('Route table error'));
251
+ describe('findDefaultSecurityGroup', () => {
252
+ const mockVpcId = 'vpc-12345678';
198
253
 
199
- const result = await discovery.isSubnetPrivate(mockSubnetId);
254
+ it('should return default security group', async () => {
255
+ const mockSecurityGroup = {
256
+ GroupId: 'sg-12345678',
257
+ GroupName: 'default'
258
+ };
259
+
260
+ ec2Mock.on(DescribeSecurityGroupsCommand).resolves({
261
+ SecurityGroups: [mockSecurityGroup]
262
+ });
263
+
264
+ const sg = await discovery.findDefaultSecurityGroup(mockVpcId);
265
+ expect(sg).toEqual(mockSecurityGroup);
266
+ });
267
+
268
+ it('should throw error when no default security group found', async () => {
269
+ ec2Mock.on(DescribeSecurityGroupsCommand).resolves({
270
+ SecurityGroups: []
271
+ });
200
272
 
201
- expect(result).toBe(true);
273
+ await expect(discovery.findDefaultSecurityGroup(mockVpcId))
274
+ .rejects.toThrow('No security group found for VPC');
202
275
  });
203
276
  });
204
277
 
205
- describe('findDefaultSecurityGroup', () => {
278
+ describe('findPrivateRouteTable', () => {
206
279
  const mockVpcId = 'vpc-12345678';
207
280
 
208
- it('should return Frigg security group when found', async () => {
209
- const mockFriggSg = {
210
- GroupId: 'sg-frigg-123',
211
- GroupName: 'frigg-lambda-sg',
212
- VpcId: mockVpcId
281
+ it('should return private route table', async () => {
282
+ const mockRouteTable = {
283
+ RouteTableId: 'rtb-12345678',
284
+ Routes: [
285
+ { GatewayId: 'local', DestinationCidrBlock: '10.0.0.0/16' }
286
+ ]
213
287
  };
214
288
 
215
- mockEC2Send.mockResolvedValue({
216
- SecurityGroups: [mockFriggSg]
289
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
290
+ RouteTables: [mockRouteTable]
217
291
  });
218
292
 
219
- const result = await discovery.findDefaultSecurityGroup(mockVpcId);
293
+ const rt = await discovery.findPrivateRouteTable(mockVpcId);
294
+ expect(rt).toEqual(mockRouteTable);
295
+ });
220
296
 
221
- expect(result).toEqual(mockFriggSg);
222
- expect(mockEC2Send).toHaveBeenCalledWith(expect.objectContaining({
223
- input: {
224
- Filters: [
225
- { Name: 'vpc-id', Values: [mockVpcId] },
226
- { Name: 'group-name', Values: ['frigg-lambda-sg'] }
227
- ]
297
+ it('should return first route table when no private route table found', async () => {
298
+ const mockRouteTable = {
299
+ RouteTableId: 'rtb-12345678',
300
+ Routes: [
301
+ { GatewayId: 'igw-12345', DestinationCidrBlock: '0.0.0.0/0' }
302
+ ]
303
+ };
304
+
305
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
306
+ RouteTables: [mockRouteTable]
307
+ });
308
+
309
+ const rt = await discovery.findPrivateRouteTable(mockVpcId);
310
+ expect(rt).toEqual(mockRouteTable);
311
+ });
312
+ });
313
+
314
+ describe('findDefaultKmsKey', () => {
315
+ it('should return default KMS key ARN', async () => {
316
+ const mockKeyId = '12345678-1234-1234-1234-123456789012';
317
+ const mockKeyArn = `arn:aws:kms:us-east-1:123456789012:key/${mockKeyId}`;
318
+
319
+ kmsMock.on(ListKeysCommand).resolves({
320
+ Keys: [{ KeyId: mockKeyId }]
321
+ });
322
+
323
+ kmsMock.on(DescribeKeyCommand).resolves({
324
+ KeyMetadata: {
325
+ Arn: mockKeyArn,
326
+ KeyManager: 'CUSTOMER',
327
+ KeyState: 'Enabled'
228
328
  }
229
- }));
329
+ });
330
+
331
+ const keyArn = await discovery.findDefaultKmsKey();
332
+ expect(keyArn).toBe(mockKeyArn);
230
333
  });
231
334
 
232
- it('should fallback to default security group', async () => {
233
- const mockDefaultSg = {
234
- GroupId: 'sg-default-123',
235
- GroupName: 'default',
236
- VpcId: mockVpcId
237
- };
335
+ it('should return null when no AWS-managed keys found', async () => {
336
+ kmsMock.on(ListKeysCommand).resolves({
337
+ Keys: []
338
+ });
238
339
 
239
- mockEC2Send
240
- .mockResolvedValueOnce({ SecurityGroups: [] }) // No Frigg SG
241
- .mockResolvedValueOnce({ SecurityGroups: [mockDefaultSg] }); // Default SG
340
+ const keyArn = await discovery.findDefaultKmsKey();
341
+ expect(keyArn).toBeNull();
342
+ });
343
+ });
242
344
 
243
- const result = await discovery.findDefaultSecurityGroup(mockVpcId);
345
+ describe('findAvailableElasticIP', () => {
346
+ it('should return available Elastic IP', async () => {
347
+ const mockElasticIP = {
348
+ AllocationId: 'eipalloc-12345',
349
+ PublicIp: '52.1.2.3'
350
+ };
351
+
352
+ ec2Mock.on(DescribeAddressesCommand).resolves({
353
+ Addresses: [mockElasticIP]
354
+ });
244
355
 
245
- expect(result).toEqual(mockDefaultSg);
246
- expect(mockEC2Send).toHaveBeenCalledTimes(2);
356
+ const eip = await discovery.findAvailableElasticIP();
357
+ expect(eip).toEqual(mockElasticIP);
247
358
  });
248
359
 
249
- it('should throw error when no security groups found', async () => {
250
- mockEC2Send.mockResolvedValue({ SecurityGroups: [] });
360
+ it('should return null when no available Elastic IPs', async () => {
361
+ ec2Mock.on(DescribeAddressesCommand).resolves({
362
+ Addresses: []
363
+ });
251
364
 
252
- await expect(discovery.findDefaultSecurityGroup(mockVpcId)).rejects.toThrow(`No security group found for VPC ${mockVpcId}`);
365
+ const eip = await discovery.findAvailableElasticIP();
366
+ expect(eip).toBeNull();
253
367
  });
254
368
  });
255
369
 
256
- describe('findDefaultKmsKey', () => {
257
- it('should return customer managed key when found', async () => {
258
- const mockKeyId = 'key-12345678';
259
- const mockKeyArn = 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012';
370
+ describe('findExistingNatGateway', () => {
371
+ const mockVpcId = 'vpc-12345678';
260
372
 
261
- mockKMSSend
262
- .mockResolvedValueOnce({ // ListKeys
263
- Keys: [{ KeyId: mockKeyId }]
373
+ beforeEach(() => {
374
+ // Create a fresh discovery instance for each test
375
+ discovery = new AWSDiscovery('us-east-1');
376
+ });
377
+
378
+ it('should return NAT Gateway in public subnet', async () => {
379
+ const mockNatGateway = {
380
+ NatGatewayId: 'nat-12345678',
381
+ SubnetId: 'subnet-public-1',
382
+ State: 'available',
383
+ NatGatewayAddresses: [{ AllocationId: 'eipalloc-12345' }],
384
+ Tags: []
385
+ };
386
+
387
+ ec2Mock.on(DescribeNatGatewaysCommand).resolves({
388
+ NatGateways: [mockNatGateway]
389
+ });
390
+
391
+ // Mock subnet lookup
392
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
393
+ Subnets: [{ SubnetId: 'subnet-public-1', VpcId: mockVpcId }]
394
+ });
395
+
396
+ // Mock route table - has IGW (public)
397
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
398
+ RouteTables: [{
399
+ Associations: [{ SubnetId: 'subnet-public-1' }],
400
+ Routes: [{ GatewayId: 'igw-12345' }]
401
+ }]
402
+ });
403
+
404
+ const result = await discovery.findExistingNatGateway(mockVpcId);
405
+
406
+ expect(result).toBeDefined();
407
+ expect(result.NatGatewayId).toBe('nat-12345678');
408
+ expect(result._isInPrivateSubnet).toBe(false);
409
+ });
410
+
411
+ it('should detect NAT Gateway in private subnet', async () => {
412
+ const mockNatGateway = {
413
+ NatGatewayId: 'nat-12345678',
414
+ SubnetId: 'subnet-private-1',
415
+ State: 'available',
416
+ NatGatewayAddresses: [{ AllocationId: 'eipalloc-12345' }],
417
+ Tags: [
418
+ { Key: 'ManagedBy', Value: 'Frigg' }
419
+ ]
420
+ };
421
+
422
+ ec2Mock.on(DescribeNatGatewaysCommand).resolves({
423
+ NatGateways: [mockNatGateway]
424
+ });
425
+
426
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
427
+ Subnets: [{ SubnetId: 'subnet-private-1', VpcId: mockVpcId }]
428
+ });
429
+
430
+ // Mock route table - no IGW (private)
431
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
432
+ RouteTables: [{
433
+ Associations: [{ SubnetId: 'subnet-private-1' }],
434
+ Routes: [{ GatewayId: 'local' }]
435
+ }]
436
+ });
437
+
438
+ const result = await discovery.findExistingNatGateway(mockVpcId);
439
+
440
+ expect(result).toBeDefined();
441
+ expect(result.NatGatewayId).toBe('nat-12345678');
442
+ expect(result._isInPrivateSubnet).toBe(true);
443
+ });
444
+
445
+ it('should skip non-Frigg NAT Gateway in private subnet', async () => {
446
+ const mockNatGateways = [
447
+ {
448
+ NatGatewayId: 'nat-other-12345',
449
+ SubnetId: 'subnet-private-1',
450
+ State: 'available',
451
+ Tags: [] // No Frigg tags
452
+ },
453
+ {
454
+ NatGatewayId: 'nat-good-12345',
455
+ SubnetId: 'subnet-public-1',
456
+ State: 'available',
457
+ Tags: []
458
+ }
459
+ ];
460
+
461
+ ec2Mock.on(DescribeNatGatewaysCommand).resolves({
462
+ NatGateways: mockNatGateways
463
+ });
464
+
465
+ // First call for subnet-private-1
466
+ ec2Mock.on(DescribeSubnetsCommand)
467
+ .resolvesOnce({
468
+ Subnets: [{ SubnetId: 'subnet-private-1', VpcId: mockVpcId }]
264
469
  })
265
- .mockResolvedValueOnce({ // DescribeKey
266
- KeyMetadata: {
267
- KeyId: mockKeyId,
268
- Arn: mockKeyArn,
269
- KeyManager: 'CUSTOMER',
270
- KeyState: 'Enabled'
271
- }
470
+ // Second call for subnet-public-1
471
+ .resolvesOnce({
472
+ Subnets: [{ SubnetId: 'subnet-public-1', VpcId: mockVpcId }]
272
473
  });
273
474
 
274
- mockSTSSend.mockResolvedValue({ Account: '123456789012' });
475
+ // First call for private subnet route table
476
+ ec2Mock.on(DescribeRouteTablesCommand)
477
+ .resolvesOnce({
478
+ RouteTables: [{
479
+ Associations: [{ SubnetId: 'subnet-private-1' }],
480
+ Routes: [{ GatewayId: 'local' }] // Private
481
+ }]
482
+ })
483
+ // Second call for public subnet route table
484
+ .resolvesOnce({
485
+ RouteTables: [{
486
+ Associations: [{ SubnetId: 'subnet-public-1' }],
487
+ Routes: [{ GatewayId: 'igw-12345' }] // Public
488
+ }]
489
+ });
275
490
 
276
- const result = await discovery.findDefaultKmsKey();
491
+ const result = await discovery.findExistingNatGateway(mockVpcId);
277
492
 
278
- expect(result).toBe(mockKeyArn);
493
+ expect(result).toBeDefined();
494
+ expect(result.NatGatewayId).toBe('nat-good-12345');
495
+ expect(result._isInPrivateSubnet).toBe(false);
279
496
  });
280
497
 
281
- it('should return wildcard pattern when no customer keys found', async () => {
282
- mockKMSSend.mockResolvedValue({ Keys: [] });
283
- mockSTSSend.mockResolvedValue({ Account: '123456789012' });
498
+ it('should prioritize Frigg-managed NAT Gateways', async () => {
499
+ const mockNatGateways = [
500
+ {
501
+ NatGatewayId: 'nat-other-12345',
502
+ SubnetId: 'subnet-public-1',
503
+ State: 'available',
504
+ Tags: []
505
+ },
506
+ {
507
+ NatGatewayId: 'nat-frigg-12345',
508
+ SubnetId: 'subnet-public-2',
509
+ State: 'available',
510
+ Tags: [{ Key: 'ManagedBy', Value: 'Frigg' }]
511
+ }
512
+ ];
284
513
 
285
- const result = await discovery.findDefaultKmsKey();
514
+ ec2Mock.on(DescribeNatGatewaysCommand).resolves({
515
+ NatGateways: mockNatGateways
516
+ });
286
517
 
287
- expect(result).toBe('arn:aws:kms:us-east-1:123456789012:key/*');
288
- });
518
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
519
+ Subnets: [{ SubnetId: 'subnet-public-2', VpcId: mockVpcId }]
520
+ });
289
521
 
290
- it('should return fallback on error', async () => {
291
- mockKMSSend.mockRejectedValue(new Error('KMS Error'));
522
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
523
+ RouteTables: [{
524
+ Associations: [{ SubnetId: 'subnet-public-2' }],
525
+ Routes: [{ GatewayId: 'igw-12345' }] // Public
526
+ }]
527
+ });
528
+
529
+ const result = await discovery.findExistingNatGateway(mockVpcId);
530
+
531
+ expect(result).toBeDefined();
532
+ expect(result.NatGatewayId).toBe('nat-frigg-12345');
533
+ expect(result._isInPrivateSubnet).toBe(false);
534
+ });
292
535
 
293
- const result = await discovery.findDefaultKmsKey();
536
+ it('should return null when no NAT Gateways found', async () => {
537
+ ec2Mock.on(DescribeNatGatewaysCommand).resolves({
538
+ NatGateways: []
539
+ });
294
540
 
295
- expect(result).toBe('*');
541
+ const result = await discovery.findExistingNatGateway(mockVpcId);
542
+ expect(result).toBeNull();
296
543
  });
297
544
  });
298
545
 
@@ -300,48 +547,171 @@ describe('AWSDiscovery', () => {
300
547
  it('should discover all AWS resources successfully', async () => {
301
548
  const mockVpc = { VpcId: 'vpc-12345678' };
302
549
  const mockSubnets = [
303
- { SubnetId: 'subnet-1' },
550
+ { SubnetId: 'subnet-1' },
304
551
  { SubnetId: 'subnet-2' }
305
552
  ];
553
+ const mockPublicSubnet = { SubnetId: 'subnet-public-1' };
306
554
  const mockSecurityGroup = { GroupId: 'sg-12345678' };
307
555
  const mockRouteTable = { RouteTableId: 'rtb-12345678' };
308
556
  const mockKmsArn = 'arn:aws:kms:us-east-1:123456789012:key/12345678';
557
+ const mockNatGateway = {
558
+ NatGatewayId: 'nat-12345678',
559
+ SubnetId: 'subnet-public-1',
560
+ NatGatewayAddresses: [{ AllocationId: 'eipalloc-12345' }],
561
+ _isInPrivateSubnet: false
562
+ };
309
563
 
310
564
  // Mock all the discovery methods
311
565
  jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
312
566
  jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
567
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(mockPublicSubnet);
313
568
  jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
314
569
  jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
315
570
  jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(mockKmsArn);
571
+ jest.spyOn(discovery, 'findExistingNatGateway').mockResolvedValue(mockNatGateway);
572
+ jest.spyOn(discovery, 'isSubnetPrivate')
573
+ .mockResolvedValueOnce(true) // subnet-1 is private
574
+ .mockResolvedValueOnce(true); // subnet-2 is private
316
575
 
317
576
  const result = await discovery.discoverResources();
318
577
 
319
- expect(result).toEqual({
578
+ expect(result).toMatchObject({
320
579
  defaultVpcId: 'vpc-12345678',
321
580
  defaultSecurityGroupId: 'sg-12345678',
322
581
  privateSubnetId1: 'subnet-1',
323
582
  privateSubnetId2: 'subnet-2',
583
+ publicSubnetId: 'subnet-public-1',
324
584
  privateRouteTableId: 'rtb-12345678',
325
- defaultKmsKeyId: mockKmsArn
585
+ defaultKmsKeyId: mockKmsArn,
586
+ existingNatGatewayId: 'nat-12345678',
587
+ existingElasticIpAllocationId: 'eipalloc-12345',
588
+ natGatewayInPrivateSubnet: false,
589
+ subnetConversionRequired: false,
590
+ privateSubnetsWithWrongRoutes: []
326
591
  });
327
592
 
328
593
  // Verify all methods were called
329
594
  expect(discovery.findDefaultVpc).toHaveBeenCalled();
330
- expect(discovery.findPrivateSubnets).toHaveBeenCalledWith('vpc-12345678');
595
+ expect(discovery.findPrivateSubnets).toHaveBeenCalledWith('vpc-12345678', false);
596
+ expect(discovery.findPublicSubnets).toHaveBeenCalledWith('vpc-12345678');
331
597
  expect(discovery.findDefaultSecurityGroup).toHaveBeenCalledWith('vpc-12345678');
332
598
  expect(discovery.findPrivateRouteTable).toHaveBeenCalledWith('vpc-12345678');
333
599
  expect(discovery.findDefaultKmsKey).toHaveBeenCalled();
600
+ expect(discovery.findExistingNatGateway).toHaveBeenCalledWith('vpc-12345678');
601
+ });
602
+
603
+ it('should detect subnet conversion requirements', async () => {
604
+ const mockVpc = { VpcId: 'vpc-12345678' };
605
+ const mockSubnets = [
606
+ { SubnetId: 'subnet-1' },
607
+ { SubnetId: 'subnet-2' }
608
+ ];
609
+ const mockPublicSubnet = { SubnetId: 'subnet-public-1' };
610
+ const mockSecurityGroup = { GroupId: 'sg-12345678' };
611
+ const mockRouteTable = { RouteTableId: 'rtb-12345678' };
612
+
613
+ jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
614
+ jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
615
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(mockPublicSubnet);
616
+ jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
617
+ jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
618
+ jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(null);
619
+ jest.spyOn(discovery, 'findExistingNatGateway').mockResolvedValue(null);
620
+ jest.spyOn(discovery, 'findAvailableElasticIP').mockResolvedValue(null);
621
+ jest.spyOn(discovery, 'isSubnetPrivate')
622
+ .mockImplementation((subnetId) => {
623
+ // subnet-1 is public, subnet-2 is private
624
+ return Promise.resolve(subnetId === 'subnet-2');
625
+ });
626
+
627
+ const result = await discovery.discoverResources({ selfHeal: true });
628
+
629
+ expect(result).toMatchObject({
630
+ subnetConversionRequired: true,
631
+ privateSubnetsWithWrongRoutes: ['subnet-1']
632
+ });
633
+ });
634
+
635
+ it('should handle selfHeal option', async () => {
636
+ const mockVpc = { VpcId: 'vpc-12345678' };
637
+ const mockSubnets = [
638
+ { SubnetId: 'subnet-public-1' },
639
+ { SubnetId: 'subnet-public-2' }
640
+ ];
641
+ const mockPublicSubnet = { SubnetId: 'subnet-public-3' };
642
+ const mockSecurityGroup = { GroupId: 'sg-12345678' };
643
+ const mockRouteTable = { RouteTableId: 'rtb-12345678' };
644
+
645
+ jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
646
+ jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
647
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(mockPublicSubnet);
648
+ jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
649
+ jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
650
+ jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(null);
651
+ jest.spyOn(discovery, 'findExistingNatGateway').mockResolvedValue(null);
652
+ jest.spyOn(discovery, 'findAvailableElasticIP').mockResolvedValue(null);
653
+ jest.spyOn(discovery, 'isSubnetPrivate')
654
+ .mockResolvedValue(false); // All subnets are public
655
+
656
+ const result = await discovery.discoverResources({ selfHeal: true });
657
+
658
+ // Verify that findPrivateSubnets was called with autoConvert=true
659
+ expect(discovery.findPrivateSubnets).toHaveBeenCalledWith('vpc-12345678', true);
660
+
661
+ expect(result).toMatchObject({
662
+ subnetConversionRequired: true,
663
+ privateSubnetsWithWrongRoutes: ['subnet-public-1', 'subnet-public-2']
664
+ });
665
+ });
666
+
667
+ it('should detect NAT Gateway in private subnet in discoverResources', async () => {
668
+ const mockVpc = { VpcId: 'vpc-12345678' };
669
+ const mockSubnets = [
670
+ { SubnetId: 'subnet-1' },
671
+ { SubnetId: 'subnet-2' }
672
+ ];
673
+ const mockPublicSubnet = { SubnetId: 'subnet-public-1' };
674
+ const mockSecurityGroup = { GroupId: 'sg-12345678' };
675
+ const mockRouteTable = { RouteTableId: 'rtb-12345678' };
676
+ const mockNatGateway = {
677
+ NatGatewayId: 'nat-12345678',
678
+ NatGatewayAddresses: [{ AllocationId: 'eipalloc-12345' }],
679
+ _isInPrivateSubnet: true // NAT is in private subnet
680
+ };
681
+
682
+ jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
683
+ jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
684
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(mockPublicSubnet);
685
+ jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
686
+ jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
687
+ jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(null);
688
+ jest.spyOn(discovery, 'findExistingNatGateway').mockResolvedValue(mockNatGateway);
689
+ jest.spyOn(discovery, 'isSubnetPrivate')
690
+ .mockResolvedValueOnce(true)
691
+ .mockResolvedValueOnce(true);
692
+
693
+ const result = await discovery.discoverResources();
694
+
695
+ expect(result).toMatchObject({
696
+ defaultVpcId: 'vpc-12345678',
697
+ existingNatGatewayId: 'nat-12345678',
698
+ natGatewayInPrivateSubnet: true, // Should be true
699
+ subnetConversionRequired: false,
700
+ privateSubnetsWithWrongRoutes: []
701
+ });
334
702
  });
335
703
 
336
704
  it('should handle single subnet scenario', async () => {
337
705
  const mockVpc = { VpcId: 'vpc-12345678' };
338
706
  const mockSubnets = [{ SubnetId: 'subnet-1' }]; // Only one subnet
707
+ const mockPublicSubnet = { SubnetId: 'subnet-public-1' };
339
708
  const mockSecurityGroup = { GroupId: 'sg-12345678' };
340
709
  const mockRouteTable = { RouteTableId: 'rtb-12345678' };
341
710
  const mockKmsArn = 'arn:aws:kms:us-east-1:123456789012:key/12345678';
342
711
 
343
712
  jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
344
713
  jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
714
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(mockPublicSubnet);
345
715
  jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
346
716
  jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
347
717
  jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(mockKmsArn);
@@ -1174,7 +1174,8 @@ const composeServerlessDefinition = async (AppDefinition) => {
1174
1174
  healed: [],
1175
1175
  warnings: [],
1176
1176
  errors: [],
1177
- recommendations: []
1177
+ recommendations: [],
1178
+ criticalActions: []
1178
1179
  };
1179
1180
 
1180
1181
  // Only heal if selfHeal is explicitly enabled
@@ -1218,13 +1219,27 @@ const composeServerlessDefinition = async (AppDefinition) => {
1218
1219
  }
1219
1220
  }
1220
1221
 
1221
- // Check route table associations
1222
- if (discoveredResources.privateSubnetsWithWrongRoutes) {
1222
+ // Check route table associations and subnet conversion requirements
1223
+ if (discoveredResources.privateSubnetsWithWrongRoutes &&
1224
+ discoveredResources.privateSubnetsWithWrongRoutes.length > 0) {
1223
1225
  healingReport.warnings.push(
1224
- `Found ${discoveredResources.privateSubnetsWithWrongRoutes.length} private subnets with incorrect routes`
1226
+ `Found ${discoveredResources.privateSubnetsWithWrongRoutes.length} subnets that are PUBLIC but will be used for Lambda`
1225
1227
  );
1226
1228
  healingReport.healed.push(
1227
- 'Route tables will be corrected during deployment'
1229
+ 'Route tables will be corrected during deployment - converting public subnets to private'
1230
+ );
1231
+ healingReport.criticalActions.push(
1232
+ 'SUBNET ISOLATION: Will create separate route tables to ensure Lambda subnets are private'
1233
+ );
1234
+ }
1235
+
1236
+ // Check if subnet conversion is required
1237
+ if (discoveredResources.subnetConversionRequired) {
1238
+ healingReport.warnings.push(
1239
+ 'Subnet configuration mismatch detected - Lambda functions require private subnets'
1240
+ );
1241
+ healingReport.healed.push(
1242
+ 'Will create proper route table configuration for subnet isolation'
1228
1243
  );
1229
1244
  }
1230
1245
 
@@ -1239,6 +1254,11 @@ const composeServerlessDefinition = async (AppDefinition) => {
1239
1254
  }
1240
1255
 
1241
1256
  // Log healing report
1257
+ if (healingReport.criticalActions.length > 0) {
1258
+ console.log('๐Ÿšจ CRITICAL ACTIONS:');
1259
+ healingReport.criticalActions.forEach(action => console.log(` - ${action}`));
1260
+ }
1261
+
1242
1262
  if (healingReport.healed.length > 0) {
1243
1263
  console.log('โœ… Self-healing actions:');
1244
1264
  healingReport.healed.forEach(action => console.log(` - ${action}`));
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Test script to verify subnet detection and classification logic
5
+ */
6
+
7
+ const { AWSDiscovery } = require('./aws-discovery');
8
+
9
+ async function testSubnetLogic() {
10
+ console.log('๐Ÿงช Testing Subnet Detection and Classification Logic');
11
+ console.log('โ•'.repeat(60));
12
+
13
+ try {
14
+ const discovery = new AWSDiscovery();
15
+
16
+ // Test with selfHeal enabled
17
+ console.log('\n๐Ÿ“‹ Testing with selfHeal: true');
18
+ console.log('-'.repeat(40));
19
+
20
+ const resources = await discovery.discoverResources({ selfHeal: true });
21
+
22
+ console.log('\n๐Ÿ“Š Results:');
23
+ console.log(` VPC ID: ${resources.defaultVpcId}`);
24
+ console.log(` Private Subnet 1: ${resources.privateSubnetId1}`);
25
+ console.log(` Private Subnet 2: ${resources.privateSubnetId2}`);
26
+ console.log(` Public Subnet: ${resources.publicSubnetId}`);
27
+ console.log(` Conversion Required: ${resources.subnetConversionRequired}`);
28
+
29
+ if (resources.privateSubnetsWithWrongRoutes && resources.privateSubnetsWithWrongRoutes.length > 0) {
30
+ console.log(`\nโš ๏ธ Subnets needing conversion:`);
31
+ resources.privateSubnetsWithWrongRoutes.forEach(subnet => {
32
+ console.log(` - ${subnet}`);
33
+ });
34
+ }
35
+
36
+ console.log('\nโœ… Test completed successfully!');
37
+
38
+ } catch (error) {
39
+ console.error('\nโŒ Test failed:', error.message);
40
+ console.error('\nStack trace:', error.stack);
41
+ process.exit(1);
42
+ }
43
+ }
44
+
45
+ // Run the test
46
+ testSubnetLogic().catch(error => {
47
+ console.error('Unhandled error:', error);
48
+ process.exit(1);
49
+ });
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--canary.428.887095f.0",
4
+ "version": "2.0.0--canary.428.6b04c24.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-ec2": "^3.835.0",
7
7
  "@aws-sdk/client-kms": "^3.835.0",
@@ -9,8 +9,8 @@
9
9
  "@babel/eslint-parser": "^7.18.9",
10
10
  "@babel/parser": "^7.25.3",
11
11
  "@babel/traverse": "^7.25.3",
12
- "@friggframework/schemas": "2.0.0--canary.428.887095f.0",
13
- "@friggframework/test": "2.0.0--canary.428.887095f.0",
12
+ "@friggframework/schemas": "2.0.0--canary.428.6b04c24.0",
13
+ "@friggframework/test": "2.0.0--canary.428.6b04c24.0",
14
14
  "@hapi/boom": "^10.0.1",
15
15
  "@inquirer/prompts": "^5.3.8",
16
16
  "axios": "^1.7.2",
@@ -32,8 +32,10 @@
32
32
  "serverless-http": "^2.7.0"
33
33
  },
34
34
  "devDependencies": {
35
- "@friggframework/eslint-config": "2.0.0--canary.428.887095f.0",
36
- "@friggframework/prettier-config": "2.0.0--canary.428.887095f.0",
35
+ "@friggframework/eslint-config": "2.0.0--canary.428.6b04c24.0",
36
+ "@friggframework/prettier-config": "2.0.0--canary.428.6b04c24.0",
37
+ "aws-sdk-client-mock": "^4.1.0",
38
+ "aws-sdk-client-mock-jest": "^4.1.0",
37
39
  "jest": "^30.1.3",
38
40
  "prettier": "^2.7.1",
39
41
  "serverless": "3.39.0",
@@ -66,5 +68,5 @@
66
68
  "publishConfig": {
67
69
  "access": "public"
68
70
  },
69
- "gitHead": "887095f388c5ff1f0cbfc86c25a803fa8b5e134a"
71
+ "gitHead": "6b04c24df1e253fd23afd6acb39ec9b5ad61456f"
70
72
  }