@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
|
-
|
|
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
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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(
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
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(
|
|
808
|
-
|
|
875
|
+
console.log(`\nā
Found security group: ${securityGroup.GroupId}`);
|
|
876
|
+
|
|
809
877
|
const routeTable = await this.findPrivateRouteTable(vpc.VpcId);
|
|
810
|
-
console.log(
|
|
811
|
-
|
|
878
|
+
console.log(`ā
Found route table: ${routeTable.RouteTableId}`);
|
|
879
|
+
|
|
812
880
|
const kmsKeyArn = await this.findDefaultKmsKey();
|
|
813
881
|
if (kmsKeyArn) {
|
|
814
|
-
console.log(
|
|
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
|
-
.
|
|
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
|
|
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
|
-
.
|
|
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
|
-
|
|
180
|
+
Associations: [{ SubnetId: 'subnet-public-3' }],
|
|
181
|
+
Routes: [{ GatewayId: 'igw-12345' }] // IGW = public
|
|
148
182
|
}]
|
|
149
183
|
});
|
|
150
184
|
|
|
151
|
-
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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).
|
|
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}
|
|
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.
|
|
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.
|
|
13
|
-
"@friggframework/test": "2.0.0--canary.428.
|
|
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.
|
|
36
|
-
"@friggframework/prettier-config": "2.0.0--canary.428.
|
|
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": "
|
|
69
|
+
"gitHead": "7665532bd0d3fa2968e2575d9f9e79dd3d119375"
|
|
70
70
|
}
|