@friggframework/devtools 2.0.0--canary.428.887095f.0 → 2.0.0--canary.428.7665532.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
+ }
171
+
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
+ }
137
196
 
138
- return selectedSubnets;
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;
@@ -790,30 +850,38 @@ class AWSDiscovery {
790
850
  * @returns {string|null} return.defaultKmsKeyId - Default KMS key ARN or null if not found
791
851
  * @throws {Error} If resource discovery fails
792
852
  */
793
- async discoverResources() {
853
+ async discoverResources(options = {}) {
794
854
  try {
795
- console.log('Discovering AWS resources for Frigg deployment...');
796
-
855
+ console.log('\nšŸš€ Discovering AWS resources for Frigg deployment...');
856
+ console.log('═'.repeat(60));
857
+
797
858
  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
-
859
+ console.log(`\nāœ… Found VPC: ${vpc.VpcId}`);
860
+
861
+ // Enable auto-convert if selfHeal is enabled
862
+ const autoConvert = options.selfHeal || false;
863
+
864
+ const privateSubnets = await this.findPrivateSubnets(vpc.VpcId, autoConvert);
865
+ console.log(`\nāœ… Selected subnets for Lambda: ${privateSubnets.map(s => s.SubnetId).join(', ')}`);
866
+
803
867
  const publicSubnet = await this.findPublicSubnets(vpc.VpcId);
804
- console.log(`Found public subnet for NAT Gateway: ${publicSubnet.SubnetId}`);
805
-
868
+ if (publicSubnet) {
869
+ console.log(`\nāœ… Found public subnet for NAT Gateway: ${publicSubnet.SubnetId}`);
870
+ } else {
871
+ console.log(`\nāš ļø No public subnet found - NAT Gateway creation may fail`);
872
+ }
873
+
806
874
  const securityGroup = await this.findDefaultSecurityGroup(vpc.VpcId);
807
- console.log(`Found security group: ${securityGroup.GroupId}`);
808
-
875
+ console.log(`\nāœ… Found security group: ${securityGroup.GroupId}`);
876
+
809
877
  const routeTable = await this.findPrivateRouteTable(vpc.VpcId);
810
- console.log(`Found route table: ${routeTable.RouteTableId}`);
811
-
878
+ console.log(`āœ… Found route table: ${routeTable.RouteTableId}`);
879
+
812
880
  const kmsKeyArn = await this.findDefaultKmsKey();
813
881
  if (kmsKeyArn) {
814
- console.log(`Found KMS key: ${kmsKeyArn}`);
882
+ console.log(`āœ… Found KMS key: ${kmsKeyArn}`);
815
883
  } else {
816
- console.log('No KMS key found');
884
+ console.log('ā„¹ļø No KMS key found');
817
885
  }
818
886
 
819
887
  // Try to find existing NAT Gateway
@@ -835,6 +903,42 @@ class AWSDiscovery {
835
903
  }
836
904
  }
837
905
 
906
+ // Check if the "private" subnets are actually public
907
+ const subnet1IsActuallyPrivate = privateSubnets[0] ?
908
+ await this.isSubnetPrivate(privateSubnets[0].SubnetId) : false;
909
+ const subnet2IsActuallyPrivate = privateSubnets[1] ?
910
+ await this.isSubnetPrivate(privateSubnets[1].SubnetId) :
911
+ subnet1IsActuallyPrivate;
912
+
913
+ const subnetStatus = {
914
+ requiresConversion: !subnet1IsActuallyPrivate || !subnet2IsActuallyPrivate,
915
+ subnet1NeedsConversion: !subnet1IsActuallyPrivate,
916
+ subnet2NeedsConversion: !subnet2IsActuallyPrivate
917
+ };
918
+
919
+ if (subnetStatus.requiresConversion) {
920
+ console.log(`\nāš ļø SUBNET CONFIGURATION WARNING:`);
921
+ if (subnetStatus.subnet1NeedsConversion && privateSubnets[0]) {
922
+ console.log(` - Subnet ${privateSubnets[0].SubnetId} is currently PUBLIC but will be used for Lambda`);
923
+ }
924
+ if (subnetStatus.subnet2NeedsConversion && privateSubnets[1]) {
925
+ console.log(` - Subnet ${privateSubnets[1].SubnetId} is currently PUBLIC but will be used for Lambda`);
926
+ }
927
+ console.log(` šŸ’” Enable selfHeal: true to automatically fix this`);
928
+ }
929
+
930
+ console.log(`\n${'═'.repeat(60)}`);
931
+ console.log('šŸ“‹ Discovery Summary:');
932
+ console.log(` VPC: ${vpc.VpcId}`);
933
+ console.log(` Lambda Subnets: ${privateSubnets.map(s => s.SubnetId).join(', ')}`);
934
+ console.log(` NAT Subnet: ${publicSubnet?.SubnetId || 'None (needs creation)'}`);
935
+ console.log(` NAT Gateway: ${natGatewayId || 'None (will be created)'}`);
936
+ console.log(` Elastic IP: ${elasticIpAllocationId || 'None (will be allocated)'}`);
937
+ if (subnetStatus.requiresConversion) {
938
+ console.log(` āš ļø Subnet Conversion Required: Yes`);
939
+ }
940
+ console.log(`${'═'.repeat(60)}\n`);
941
+
838
942
  return {
839
943
  defaultVpcId: vpc.VpcId,
840
944
  defaultSecurityGroupId: securityGroup.GroupId,
@@ -844,7 +948,10 @@ class AWSDiscovery {
844
948
  privateRouteTableId: routeTable.RouteTableId,
845
949
  defaultKmsKeyId: kmsKeyArn,
846
950
  existingNatGatewayId: natGatewayId,
847
- existingElasticIpAllocationId: elasticIpAllocationId
951
+ existingElasticIpAllocationId: elasticIpAllocationId,
952
+ subnetConversionRequired: subnetStatus.requiresConversion,
953
+ privateSubnetsWithWrongRoutes: subnetStatus.requiresConversion ?
954
+ [privateSubnets[0]?.SubnetId, privateSubnets[1]?.SubnetId].filter(Boolean) : []
848
955
  };
849
956
  } catch (error) {
850
957
  console.error('Error discovering AWS resources:', error);
@@ -118,39 +118,144 @@ describe('AWSDiscovery', () => {
118
118
 
119
119
  it('should return private subnets when found', async () => {
120
120
  const mockSubnets = [
121
- { SubnetId: 'subnet-private-1', VpcId: mockVpcId },
122
- { SubnetId: 'subnet-private-2', VpcId: mockVpcId }
121
+ { SubnetId: 'subnet-private-1', VpcId: mockVpcId, AvailabilityZone: 'us-east-1a' },
122
+ { SubnetId: 'subnet-private-2', VpcId: mockVpcId, AvailabilityZone: 'us-east-1b' }
123
123
  ];
124
124
 
125
125
  mockEC2Send
126
126
  .mockResolvedValueOnce({ Subnets: mockSubnets }) // DescribeSubnets
127
- .mockResolvedValue({ RouteTables: [] }); // DescribeRouteTables (private)
127
+ .mockResolvedValueOnce({ // Check subnet-private-1
128
+ Subnets: [{ SubnetId: 'subnet-private-1', VpcId: mockVpcId }]
129
+ })
130
+ .mockResolvedValueOnce({ // Route tables for private-1
131
+ RouteTables: [{
132
+ Associations: [{ SubnetId: 'subnet-private-1' }],
133
+ Routes: [{ GatewayId: 'local' }] // No IGW = private
134
+ }]
135
+ })
136
+ .mockResolvedValueOnce({ // Check subnet-private-2
137
+ Subnets: [{ SubnetId: 'subnet-private-2', VpcId: mockVpcId }]
138
+ })
139
+ .mockResolvedValueOnce({ // Route tables for private-2
140
+ RouteTables: [{
141
+ Associations: [{ SubnetId: 'subnet-private-2' }],
142
+ Routes: [{ GatewayId: 'local' }] // No IGW = private
143
+ }]
144
+ });
128
145
 
129
- const result = await discovery.findPrivateSubnets(mockVpcId);
146
+ const result = await discovery.findPrivateSubnets(mockVpcId, false);
130
147
 
131
148
  expect(result).toHaveLength(2);
132
149
  expect(result[0].SubnetId).toBe('subnet-private-1');
133
150
  expect(result[1].SubnetId).toBe('subnet-private-2');
134
151
  });
135
152
 
136
- it('should return at least 2 subnets even if mixed private/public', async () => {
153
+ it('should handle all public subnets with autoConvert enabled', async () => {
137
154
  const mockSubnets = [
138
- { SubnetId: 'subnet-1', VpcId: mockVpcId },
139
- { SubnetId: 'subnet-2', VpcId: mockVpcId },
140
- { SubnetId: 'subnet-3', VpcId: mockVpcId }
155
+ { SubnetId: 'subnet-public-1', VpcId: mockVpcId, AvailabilityZone: 'us-east-1a' },
156
+ { SubnetId: 'subnet-public-2', VpcId: mockVpcId, AvailabilityZone: 'us-east-1b' },
157
+ { SubnetId: 'subnet-public-3', VpcId: mockVpcId, AvailabilityZone: 'us-east-1c' }
141
158
  ];
142
159
 
160
+ // Mock all subnets as public
143
161
  mockEC2Send
144
162
  .mockResolvedValueOnce({ Subnets: mockSubnets }) // DescribeSubnets
145
- .mockResolvedValue({ // Only one private subnet
163
+ .mockResolvedValueOnce({ Subnets: [{ SubnetId: 'subnet-public-1', VpcId: mockVpcId }] })
164
+ .mockResolvedValueOnce({ // Route table for public-1
165
+ RouteTables: [{
166
+ Associations: [{ SubnetId: 'subnet-public-1' }],
167
+ Routes: [{ GatewayId: 'igw-12345' }] // IGW = public
168
+ }]
169
+ })
170
+ .mockResolvedValueOnce({ Subnets: [{ SubnetId: 'subnet-public-2', VpcId: mockVpcId }] })
171
+ .mockResolvedValueOnce({ // Route table for public-2
172
+ RouteTables: [{
173
+ Associations: [{ SubnetId: 'subnet-public-2' }],
174
+ Routes: [{ GatewayId: 'igw-12345' }] // IGW = public
175
+ }]
176
+ })
177
+ .mockResolvedValueOnce({ Subnets: [{ SubnetId: 'subnet-public-3', VpcId: mockVpcId }] })
178
+ .mockResolvedValueOnce({ // Route table for public-3
146
179
  RouteTables: [{
147
- Routes: [] // No IGW route = private
180
+ Associations: [{ SubnetId: 'subnet-public-3' }],
181
+ Routes: [{ GatewayId: 'igw-12345' }] // IGW = public
148
182
  }]
149
183
  });
150
184
 
151
- const result = await discovery.findPrivateSubnets(mockVpcId);
185
+ // With autoConvert=true, should return subnets to be converted
186
+ const result = await discovery.findPrivateSubnets(mockVpcId, true);
152
187
 
153
188
  expect(result).toHaveLength(2);
189
+ // Should return subnets 2 and 3 for conversion (keeping 1 as public for NAT)
190
+ expect(result[0].SubnetId).toBe('subnet-public-2');
191
+ expect(result[1].SubnetId).toBe('subnet-public-3');
192
+ });
193
+
194
+ it('should throw error when all subnets are public and autoConvert is false', async () => {
195
+ const mockSubnets = [
196
+ { SubnetId: 'subnet-public-1', VpcId: mockVpcId, AvailabilityZone: 'us-east-1a' },
197
+ { SubnetId: 'subnet-public-2', VpcId: mockVpcId, AvailabilityZone: 'us-east-1b' }
198
+ ];
199
+
200
+ mockEC2Send
201
+ .mockResolvedValueOnce({ Subnets: mockSubnets })
202
+ .mockResolvedValueOnce({ Subnets: [{ SubnetId: 'subnet-public-1', VpcId: mockVpcId }] })
203
+ .mockResolvedValueOnce({ // Public route table
204
+ RouteTables: [{
205
+ Associations: [{ SubnetId: 'subnet-public-1' }],
206
+ Routes: [{ GatewayId: 'igw-12345' }]
207
+ }]
208
+ })
209
+ .mockResolvedValueOnce({ Subnets: [{ SubnetId: 'subnet-public-2', VpcId: mockVpcId }] })
210
+ .mockResolvedValueOnce({ // Public route table
211
+ RouteTables: [{
212
+ Associations: [{ SubnetId: 'subnet-public-2' }],
213
+ Routes: [{ GatewayId: 'igw-12345' }]
214
+ }]
215
+ });
216
+
217
+ await expect(discovery.findPrivateSubnets(mockVpcId, false)).rejects.toThrow(
218
+ `No private subnets found in VPC ${mockVpcId}`
219
+ );
220
+ });
221
+
222
+ it('should handle mixed private/public subnets correctly', async () => {
223
+ const mockSubnets = [
224
+ { SubnetId: 'subnet-private-1', VpcId: mockVpcId, AvailabilityZone: 'us-east-1a' },
225
+ { SubnetId: 'subnet-public-1', VpcId: mockVpcId, AvailabilityZone: 'us-east-1b' },
226
+ { SubnetId: 'subnet-public-2', VpcId: mockVpcId, AvailabilityZone: 'us-east-1c' }
227
+ ];
228
+
229
+ mockEC2Send
230
+ .mockResolvedValueOnce({ Subnets: mockSubnets })
231
+ .mockResolvedValueOnce({ Subnets: [{ SubnetId: 'subnet-private-1', VpcId: mockVpcId }] })
232
+ .mockResolvedValueOnce({ // Private route table
233
+ RouteTables: [{
234
+ Associations: [{ SubnetId: 'subnet-private-1' }],
235
+ Routes: [{ GatewayId: 'local' }] // No IGW
236
+ }]
237
+ })
238
+ .mockResolvedValueOnce({ Subnets: [{ SubnetId: 'subnet-public-1', VpcId: mockVpcId }] })
239
+ .mockResolvedValueOnce({ // Public route table
240
+ RouteTables: [{
241
+ Associations: [{ SubnetId: 'subnet-public-1' }],
242
+ Routes: [{ GatewayId: 'igw-12345' }]
243
+ }]
244
+ })
245
+ .mockResolvedValueOnce({ Subnets: [{ SubnetId: 'subnet-public-2', VpcId: mockVpcId }] })
246
+ .mockResolvedValueOnce({ // Public route table
247
+ RouteTables: [{
248
+ Associations: [{ SubnetId: 'subnet-public-2' }],
249
+ Routes: [{ GatewayId: 'igw-12345' }]
250
+ }]
251
+ });
252
+
253
+ const result = await discovery.findPrivateSubnets(mockVpcId, false);
254
+
255
+ expect(result).toHaveLength(2);
256
+ // Should return the one private and one public subnet for HA
257
+ expect(result[0].SubnetId).toBe('subnet-private-1');
258
+ expect(result[1].SubnetId).toBe('subnet-public-1');
154
259
  });
155
260
 
156
261
  it('should throw error when no subnets found', async () => {
@@ -162,16 +267,22 @@ describe('AWSDiscovery', () => {
162
267
 
163
268
  describe('isSubnetPrivate', () => {
164
269
  const mockSubnetId = 'subnet-12345678';
270
+ const mockVpcId = 'vpc-12345678';
165
271
 
166
272
  it('should return false for public subnet (has IGW route)', async () => {
167
- mockEC2Send.mockResolvedValue({
168
- RouteTables: [{
169
- Routes: [{
170
- GatewayId: 'igw-12345678',
171
- DestinationCidrBlock: '0.0.0.0/0'
273
+ mockEC2Send
274
+ .mockResolvedValueOnce({ // DescribeSubnets
275
+ Subnets: [{ SubnetId: mockSubnetId, VpcId: mockVpcId }]
276
+ })
277
+ .mockResolvedValueOnce({ // DescribeRouteTables
278
+ RouteTables: [{
279
+ Associations: [{ SubnetId: mockSubnetId }],
280
+ Routes: [{
281
+ GatewayId: 'igw-12345678',
282
+ DestinationCidrBlock: '0.0.0.0/0'
283
+ }]
172
284
  }]
173
- }]
174
- });
285
+ });
175
286
 
176
287
  const result = await discovery.isSubnetPrivate(mockSubnetId);
177
288
 
@@ -179,27 +290,64 @@ describe('AWSDiscovery', () => {
179
290
  });
180
291
 
181
292
  it('should return true for private subnet (no IGW route)', async () => {
182
- mockEC2Send.mockResolvedValue({
183
- RouteTables: [{
184
- Routes: [{
185
- GatewayId: 'local',
186
- DestinationCidrBlock: '10.0.0.0/16'
293
+ mockEC2Send
294
+ .mockResolvedValueOnce({ // DescribeSubnets
295
+ Subnets: [{ SubnetId: mockSubnetId, VpcId: mockVpcId }]
296
+ })
297
+ .mockResolvedValueOnce({ // DescribeRouteTables
298
+ RouteTables: [{
299
+ Associations: [{ SubnetId: mockSubnetId }],
300
+ Routes: [{
301
+ GatewayId: 'local',
302
+ DestinationCidrBlock: '10.0.0.0/16'
303
+ }]
187
304
  }]
188
- }]
189
- });
305
+ });
190
306
 
191
307
  const result = await discovery.isSubnetPrivate(mockSubnetId);
192
308
 
193
309
  expect(result).toBe(true);
194
310
  });
195
311
 
312
+ it('should use main route table if no explicit association', async () => {
313
+ mockEC2Send
314
+ .mockResolvedValueOnce({ // DescribeSubnets
315
+ Subnets: [{ SubnetId: mockSubnetId, VpcId: mockVpcId }]
316
+ })
317
+ .mockResolvedValueOnce({ // DescribeRouteTables
318
+ RouteTables: [{
319
+ Associations: [{ Main: true }], // Main route table
320
+ Routes: [{
321
+ GatewayId: 'igw-12345678',
322
+ DestinationCidrBlock: '0.0.0.0/0'
323
+ }]
324
+ }]
325
+ });
326
+
327
+ const result = await discovery.isSubnetPrivate(mockSubnetId);
328
+
329
+ expect(result).toBe(false); // Public because main route has IGW
330
+ });
331
+
196
332
  it('should default to private on error', async () => {
197
- mockEC2Send.mockRejectedValue(new Error('Route table error'));
333
+ mockEC2Send
334
+ .mockResolvedValueOnce({ // DescribeSubnets
335
+ Subnets: [{ SubnetId: mockSubnetId, VpcId: mockVpcId }]
336
+ })
337
+ .mockRejectedValue(new Error('Route table error'));
198
338
 
199
339
  const result = await discovery.isSubnetPrivate(mockSubnetId);
200
340
 
201
341
  expect(result).toBe(true);
202
342
  });
343
+
344
+ it('should throw error when subnet not found', async () => {
345
+ mockEC2Send.mockResolvedValueOnce({ Subnets: [] });
346
+
347
+ await expect(discovery.isSubnetPrivate(mockSubnetId)).rejects.toThrow(
348
+ `Subnet ${mockSubnetId} not found`
349
+ );
350
+ });
203
351
  });
204
352
 
205
353
  describe('findDefaultSecurityGroup', () => {
@@ -300,37 +448,116 @@ describe('AWSDiscovery', () => {
300
448
  it('should discover all AWS resources successfully', async () => {
301
449
  const mockVpc = { VpcId: 'vpc-12345678' };
302
450
  const mockSubnets = [
303
- { SubnetId: 'subnet-1' },
451
+ { SubnetId: 'subnet-1' },
304
452
  { SubnetId: 'subnet-2' }
305
453
  ];
454
+ const mockPublicSubnet = { SubnetId: 'subnet-public-1' };
306
455
  const mockSecurityGroup = { GroupId: 'sg-12345678' };
307
456
  const mockRouteTable = { RouteTableId: 'rtb-12345678' };
308
457
  const mockKmsArn = 'arn:aws:kms:us-east-1:123456789012:key/12345678';
458
+ const mockNatGateway = {
459
+ NatGatewayId: 'nat-12345678',
460
+ NatGatewayAddresses: [{ AllocationId: 'eipalloc-12345' }]
461
+ };
309
462
 
310
463
  // Mock all the discovery methods
311
464
  jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
312
465
  jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
466
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(mockPublicSubnet);
313
467
  jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
314
468
  jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
315
469
  jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(mockKmsArn);
470
+ jest.spyOn(discovery, 'findExistingNatGateway').mockResolvedValue(mockNatGateway);
471
+ jest.spyOn(discovery, 'isSubnetPrivate')
472
+ .mockResolvedValueOnce(true) // subnet-1 is private
473
+ .mockResolvedValueOnce(true); // subnet-2 is private
316
474
 
317
475
  const result = await discovery.discoverResources();
318
476
 
319
- expect(result).toEqual({
477
+ expect(result).toMatchObject({
320
478
  defaultVpcId: 'vpc-12345678',
321
479
  defaultSecurityGroupId: 'sg-12345678',
322
480
  privateSubnetId1: 'subnet-1',
323
481
  privateSubnetId2: 'subnet-2',
482
+ publicSubnetId: 'subnet-public-1',
324
483
  privateRouteTableId: 'rtb-12345678',
325
- defaultKmsKeyId: mockKmsArn
484
+ defaultKmsKeyId: mockKmsArn,
485
+ existingNatGatewayId: 'nat-12345678',
486
+ existingElasticIpAllocationId: 'eipalloc-12345',
487
+ subnetConversionRequired: false,
488
+ privateSubnetsWithWrongRoutes: []
326
489
  });
327
490
 
328
491
  // Verify all methods were called
329
492
  expect(discovery.findDefaultVpc).toHaveBeenCalled();
330
- expect(discovery.findPrivateSubnets).toHaveBeenCalledWith('vpc-12345678');
493
+ expect(discovery.findPrivateSubnets).toHaveBeenCalledWith('vpc-12345678', false);
494
+ expect(discovery.findPublicSubnets).toHaveBeenCalledWith('vpc-12345678');
331
495
  expect(discovery.findDefaultSecurityGroup).toHaveBeenCalledWith('vpc-12345678');
332
496
  expect(discovery.findPrivateRouteTable).toHaveBeenCalledWith('vpc-12345678');
333
497
  expect(discovery.findDefaultKmsKey).toHaveBeenCalled();
498
+ expect(discovery.findExistingNatGateway).toHaveBeenCalledWith('vpc-12345678');
499
+ });
500
+
501
+ it('should detect subnet conversion requirements', async () => {
502
+ const mockVpc = { VpcId: 'vpc-12345678' };
503
+ const mockSubnets = [
504
+ { SubnetId: 'subnet-1' },
505
+ { SubnetId: 'subnet-2' }
506
+ ];
507
+ const mockPublicSubnet = { SubnetId: 'subnet-public-1' };
508
+ const mockSecurityGroup = { GroupId: 'sg-12345678' };
509
+ const mockRouteTable = { RouteTableId: 'rtb-12345678' };
510
+
511
+ jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
512
+ jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
513
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(mockPublicSubnet);
514
+ jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
515
+ jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
516
+ jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(null);
517
+ jest.spyOn(discovery, 'findExistingNatGateway').mockResolvedValue(null);
518
+ jest.spyOn(discovery, 'findAvailableElasticIP').mockResolvedValue(null);
519
+ jest.spyOn(discovery, 'isSubnetPrivate')
520
+ .mockResolvedValueOnce(false) // subnet-1 is actually public
521
+ .mockResolvedValueOnce(true); // subnet-2 is private
522
+
523
+ const result = await discovery.discoverResources({ selfHeal: true });
524
+
525
+ expect(result).toMatchObject({
526
+ subnetConversionRequired: true,
527
+ privateSubnetsWithWrongRoutes: ['subnet-1']
528
+ });
529
+ });
530
+
531
+ it('should handle selfHeal option', async () => {
532
+ const mockVpc = { VpcId: 'vpc-12345678' };
533
+ const mockSubnets = [
534
+ { SubnetId: 'subnet-public-1' },
535
+ { SubnetId: 'subnet-public-2' }
536
+ ];
537
+ const mockPublicSubnet = { SubnetId: 'subnet-public-3' };
538
+ const mockSecurityGroup = { GroupId: 'sg-12345678' };
539
+ const mockRouteTable = { RouteTableId: 'rtb-12345678' };
540
+
541
+ jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
542
+ jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
543
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(mockPublicSubnet);
544
+ jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
545
+ jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
546
+ jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(null);
547
+ jest.spyOn(discovery, 'findExistingNatGateway').mockResolvedValue(null);
548
+ jest.spyOn(discovery, 'findAvailableElasticIP').mockResolvedValue(null);
549
+ jest.spyOn(discovery, 'isSubnetPrivate')
550
+ .mockResolvedValue(false); // All subnets are public
551
+
552
+ const result = await discovery.discoverResources({ selfHeal: true });
553
+
554
+ // Verify that findPrivateSubnets was called with autoConvert=true
555
+ expect(discovery.findPrivateSubnets).toHaveBeenCalledWith('vpc-12345678', true);
556
+
557
+ expect(result).toMatchObject({
558
+ subnetConversionRequired: true,
559
+ privateSubnetsWithWrongRoutes: ['subnet-public-1', 'subnet-public-2']
560
+ });
334
561
  });
335
562
 
336
563
  it('should handle single subnet scenario', async () => {
@@ -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}`));
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.7665532.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.7665532.0",
13
+ "@friggframework/test": "2.0.0--canary.428.7665532.0",
14
14
  "@hapi/boom": "^10.0.1",
15
15
  "@inquirer/prompts": "^5.3.8",
16
16
  "axios": "^1.7.2",
@@ -32,8 +32,8 @@
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.7665532.0",
36
+ "@friggframework/prettier-config": "2.0.0--canary.428.7665532.0",
37
37
  "jest": "^30.1.3",
38
38
  "prettier": "^2.7.1",
39
39
  "serverless": "3.39.0",
@@ -66,5 +66,5 @@
66
66
  "publishConfig": {
67
67
  "access": "public"
68
68
  },
69
- "gitHead": "887095f388c5ff1f0cbfc86c25a803fa8b5e134a"
69
+ "gitHead": "7665532bd0d3fa2968e2575d9f9e79dd3d119375"
70
70
  }