@fishawack/lab-env 5.6.1 → 5.7.0-beta.2

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.
@@ -1,5 +1,25 @@
1
- const { EC2Client, DescribeKeyPairsCommand } = require("@aws-sdk/client-ec2");
2
- const { Spinner } = require("../../libs/utilities");
1
+ const {
2
+ EC2Client,
3
+ DescribeKeyPairsCommand,
4
+ CreateSecurityGroupCommand,
5
+ AuthorizeSecurityGroupIngressCommand,
6
+ RevokeSecurityGroupIngressCommand,
7
+ DeleteSecurityGroupCommand,
8
+ DescribeSecurityGroupsCommand,
9
+ DescribeVpcsCommand,
10
+ DescribeSubnetsCommand,
11
+ CreateSubnetCommand,
12
+ CreateRouteTableCommand,
13
+ CreateRouteCommand,
14
+ AssociateRouteTableCommand,
15
+ CreateNatGatewayCommand,
16
+ DescribeNatGatewaysCommand,
17
+ AllocateAddressCommand,
18
+ DescribeAvailabilityZonesCommand,
19
+ DescribeRouteTablesCommand,
20
+ DescribeInternetGatewaysCommand,
21
+ } = require("@aws-sdk/client-ec2");
22
+ const { Spinner, poll } = require("../../libs/utilities");
3
23
 
4
24
  module.exports.getKeyPair = async (KeyName) => {
5
25
  const client = new EC2Client({});
@@ -15,3 +35,680 @@ module.exports.getKeyPair = async (KeyName) => {
15
35
 
16
36
  return res;
17
37
  };
38
+
39
+ module.exports.createRDSSecurityGroup = async (name, tags = []) => {
40
+ const client = new EC2Client({});
41
+
42
+ const groupName = `${name}-rds`;
43
+
44
+ try {
45
+ const sg = await Spinner.prototype.simple(
46
+ `Creating security group ${groupName}`,
47
+ () => {
48
+ return client.send(
49
+ new CreateSecurityGroupCommand({
50
+ GroupName: groupName,
51
+ Description: `RDS access for ${name}`,
52
+ TagSpecifications: [
53
+ {
54
+ ResourceType: "security-group",
55
+ Tags: [
56
+ {
57
+ Key: "Name",
58
+ Value: groupName,
59
+ },
60
+ {
61
+ Key: "client",
62
+ Value: process.env.AWS_PROFILE,
63
+ },
64
+ ].concat(tags),
65
+ },
66
+ ],
67
+ }),
68
+ );
69
+ },
70
+ );
71
+
72
+ return sg.GroupId;
73
+ } catch (e) {
74
+ if (e.Code === "InvalidGroup.Duplicate") {
75
+ const existing =
76
+ await module.exports.findSecurityGroupByName(groupName);
77
+ return existing.GroupId;
78
+ }
79
+ throw e;
80
+ }
81
+ };
82
+
83
+ module.exports.authorizeRDSIngress = async (
84
+ rdsSecurityGroupId,
85
+ sourceSecurityGroupId,
86
+ ) => {
87
+ const client = new EC2Client({});
88
+
89
+ await Spinner.prototype.simple(
90
+ `Adding MySQL inbound rule from EB security group`,
91
+ () => {
92
+ return client.send(
93
+ new AuthorizeSecurityGroupIngressCommand({
94
+ GroupId: rdsSecurityGroupId,
95
+ IpPermissions: [
96
+ {
97
+ IpProtocol: "tcp",
98
+ FromPort: 3306,
99
+ ToPort: 3306,
100
+ UserIdGroupPairs: [
101
+ {
102
+ GroupId: sourceSecurityGroupId,
103
+ Description:
104
+ "MySQL access for EB instances",
105
+ },
106
+ ],
107
+ },
108
+ ],
109
+ }),
110
+ );
111
+ },
112
+ );
113
+ };
114
+
115
+ module.exports.revokeRDSIngress = async (
116
+ rdsSecurityGroupId,
117
+ sourceSecurityGroupId,
118
+ ) => {
119
+ const client = new EC2Client({});
120
+
121
+ await Spinner.prototype.simple(
122
+ `Removing MySQL inbound rule from RDS security group`,
123
+ () => {
124
+ return client.send(
125
+ new RevokeSecurityGroupIngressCommand({
126
+ GroupId: rdsSecurityGroupId,
127
+ IpPermissions: [
128
+ {
129
+ IpProtocol: "tcp",
130
+ FromPort: 3306,
131
+ ToPort: 3306,
132
+ UserIdGroupPairs: [
133
+ {
134
+ GroupId: sourceSecurityGroupId,
135
+ },
136
+ ],
137
+ },
138
+ ],
139
+ }),
140
+ );
141
+ },
142
+ );
143
+ };
144
+
145
+ module.exports.createOpenSearchSecurityGroup = async (name, tags = []) => {
146
+ const client = new EC2Client({});
147
+
148
+ const groupName = `${name}-os`;
149
+
150
+ try {
151
+ const sg = await Spinner.prototype.simple(
152
+ `Creating security group ${groupName}`,
153
+ () => {
154
+ return client.send(
155
+ new CreateSecurityGroupCommand({
156
+ GroupName: groupName,
157
+ Description: `OpenSearch access for ${name}`,
158
+ TagSpecifications: [
159
+ {
160
+ ResourceType: "security-group",
161
+ Tags: [
162
+ {
163
+ Key: "Name",
164
+ Value: groupName,
165
+ },
166
+ {
167
+ Key: "client",
168
+ Value: process.env.AWS_PROFILE,
169
+ },
170
+ ].concat(tags),
171
+ },
172
+ ],
173
+ }),
174
+ );
175
+ },
176
+ );
177
+
178
+ return sg.GroupId;
179
+ } catch (e) {
180
+ if (e.Code === "InvalidGroup.Duplicate") {
181
+ const existing =
182
+ await module.exports.findSecurityGroupByName(groupName);
183
+ return existing.GroupId;
184
+ }
185
+ throw e;
186
+ }
187
+ };
188
+
189
+ module.exports.authorizeOpenSearchIngress = async (
190
+ osSecurityGroupId,
191
+ sourceSecurityGroupId,
192
+ ) => {
193
+ const client = new EC2Client({});
194
+
195
+ await Spinner.prototype.simple(
196
+ `Adding HTTPS inbound rule from EB security group`,
197
+ () => {
198
+ return client.send(
199
+ new AuthorizeSecurityGroupIngressCommand({
200
+ GroupId: osSecurityGroupId,
201
+ IpPermissions: [
202
+ {
203
+ IpProtocol: "tcp",
204
+ FromPort: 443,
205
+ ToPort: 443,
206
+ UserIdGroupPairs: [
207
+ {
208
+ GroupId: sourceSecurityGroupId,
209
+ Description:
210
+ "HTTPS access for EB instances",
211
+ },
212
+ ],
213
+ },
214
+ ],
215
+ }),
216
+ );
217
+ },
218
+ );
219
+ };
220
+
221
+ module.exports.revokeOpenSearchIngress = async (
222
+ osSecurityGroupId,
223
+ sourceSecurityGroupId,
224
+ ) => {
225
+ const client = new EC2Client({});
226
+
227
+ await Spinner.prototype.simple(
228
+ `Removing HTTPS inbound rule from OpenSearch security group`,
229
+ () => {
230
+ return client.send(
231
+ new RevokeSecurityGroupIngressCommand({
232
+ GroupId: osSecurityGroupId,
233
+ IpPermissions: [
234
+ {
235
+ IpProtocol: "tcp",
236
+ FromPort: 443,
237
+ ToPort: 443,
238
+ UserIdGroupPairs: [
239
+ {
240
+ GroupId: sourceSecurityGroupId,
241
+ },
242
+ ],
243
+ },
244
+ ],
245
+ }),
246
+ );
247
+ },
248
+ );
249
+ };
250
+
251
+ module.exports.findSecurityGroupByTag = async (filters = []) => {
252
+ const client = new EC2Client({});
253
+
254
+ const res = await Spinner.prototype.simple(
255
+ `Looking up security group by filters`,
256
+ () => {
257
+ return client.send(
258
+ new DescribeSecurityGroupsCommand({
259
+ Filters: filters,
260
+ }),
261
+ );
262
+ },
263
+ );
264
+
265
+ return res.SecurityGroups[0] || null;
266
+ };
267
+
268
+ module.exports.findSecurityGroupByName = async (name) => {
269
+ const client = new EC2Client({});
270
+
271
+ const res = await Spinner.prototype.simple(
272
+ `Looking up security group ${name}`,
273
+ () => {
274
+ return client.send(
275
+ new DescribeSecurityGroupsCommand({
276
+ Filters: [{ Name: "group-name", Values: [name] }],
277
+ }),
278
+ );
279
+ },
280
+ );
281
+
282
+ return res.SecurityGroups[0] || null;
283
+ };
284
+
285
+ module.exports.deleteSecurityGroup = async (groupId) => {
286
+ const client = new EC2Client({});
287
+
288
+ await Spinner.prototype.simple(`Deleting security group ${groupId}`, () => {
289
+ return client.send(
290
+ new DeleteSecurityGroupCommand({ GroupId: groupId }),
291
+ );
292
+ });
293
+ };
294
+
295
+ const discoverPublicSubnets = async (client, vpcId, excludeIds, azs) => {
296
+ const { Subnets: allSubnets } = await client.send(
297
+ new DescribeSubnetsCommand({
298
+ Filters: [{ Name: "vpc-id", Values: [vpcId] }],
299
+ }),
300
+ );
301
+
302
+ const { RouteTables } = await client.send(
303
+ new DescribeRouteTablesCommand({
304
+ Filters: [{ Name: "vpc-id", Values: [vpcId] }],
305
+ }),
306
+ );
307
+
308
+ const publicByAz = {};
309
+ for (const subnet of allSubnets) {
310
+ if (excludeIds.includes(subnet.SubnetId)) continue;
311
+ if (!azs.includes(subnet.AvailabilityZone)) continue;
312
+
313
+ let rt = RouteTables.find((r) =>
314
+ r.Associations.some((a) => a.SubnetId === subnet.SubnetId),
315
+ );
316
+ if (!rt) {
317
+ rt = RouteTables.find((r) => r.Associations.some((a) => a.Main));
318
+ }
319
+ if (!rt) continue;
320
+
321
+ const hasIgwRoute = rt.Routes.some(
322
+ (route) =>
323
+ route.GatewayId &&
324
+ route.GatewayId.startsWith("igw-") &&
325
+ route.DestinationCidrBlock === "0.0.0.0/0",
326
+ );
327
+
328
+ if (hasIgwRoute && !publicByAz[subnet.AvailabilityZone]) {
329
+ publicByAz[subnet.AvailabilityZone] = subnet.SubnetId;
330
+ }
331
+ }
332
+
333
+ return azs.map((az) => publicByAz[az]).filter(Boolean);
334
+ };
335
+
336
+ module.exports.ensurePrivateSubnets = async () => {
337
+ const client = new EC2Client({});
338
+
339
+ // Get default VPC
340
+ const { Vpcs } = await client.send(
341
+ new DescribeVpcsCommand({
342
+ Filters: [{ Name: "isDefault", Values: ["true"] }],
343
+ }),
344
+ );
345
+
346
+ if (!Vpcs.length) {
347
+ throw new Error("No default VPC found in this region");
348
+ }
349
+
350
+ const vpc = Vpcs[0];
351
+ const vpcId = vpc.VpcId;
352
+
353
+ // Preflight: check all private networking resources
354
+ const { Subnets: existingSubnets } = await client.send(
355
+ new DescribeSubnetsCommand({
356
+ Filters: [
357
+ { Name: "vpc-id", Values: [vpcId] },
358
+ { Name: "tag:private", Values: ["true"] },
359
+ ],
360
+ }),
361
+ );
362
+
363
+ const { NatGateways: allNats } = await client.send(
364
+ new DescribeNatGatewaysCommand({
365
+ Filter: [
366
+ { Name: "vpc-id", Values: [vpcId] },
367
+ { Name: "tag:private", Values: ["true"] },
368
+ ],
369
+ }),
370
+ );
371
+ const existingNats = allNats.filter(
372
+ (n) => n.State !== "deleted" && n.State !== "deleting",
373
+ );
374
+
375
+ const { RouteTables: existingRtbs } = await client.send(
376
+ new DescribeRouteTablesCommand({
377
+ Filters: [
378
+ { Name: "vpc-id", Values: [vpcId] },
379
+ { Name: "tag:private", Values: ["true"] },
380
+ ],
381
+ }),
382
+ );
383
+
384
+ // Check for inconsistencies (exactly 1 of any resource type)
385
+ const counts = {
386
+ "private subnets": existingSubnets.length,
387
+ "NAT gateways": existingNats.length,
388
+ "route tables": existingRtbs.length,
389
+ };
390
+
391
+ const inconsistent = Object.entries(counts).filter(
392
+ ([, count]) => count === 1,
393
+ );
394
+
395
+ if (inconsistent.length) {
396
+ const detail = inconsistent
397
+ .map(([name, count]) => `${count} ${name}`)
398
+ .join(", ");
399
+ throw new Error(
400
+ `Inconsistent private networking state: ${detail}. Expected 0 or 2 of each resource tagged private=true. Please resolve manually.`,
401
+ );
402
+ }
403
+
404
+ // Determine what needs creation
405
+ const needsSubnets = existingSubnets.length < 2;
406
+ const needsNats = existingNats.length < 2;
407
+ const needsRtbs = existingRtbs.length < 2;
408
+
409
+ // All resources exist — nothing to do
410
+ if (!needsSubnets && !needsNats && !needsRtbs) {
411
+ const publicSubnetIds = await discoverPublicSubnets(
412
+ client,
413
+ vpcId,
414
+ existingSubnets.map((s) => s.SubnetId),
415
+ existingSubnets.slice(0, 2).map((s) => s.AvailabilityZone),
416
+ );
417
+ return {
418
+ privateSubnetIds: existingSubnets.map((s) => s.SubnetId),
419
+ publicSubnetIds,
420
+ vpcId,
421
+ };
422
+ }
423
+
424
+ const spinner = new Spinner("Setting up private subnets", true);
425
+
426
+ try {
427
+ // Discover AZs — use existing subnets' AZs if available, otherwise first 2
428
+ let azs;
429
+ if (!needsSubnets) {
430
+ azs = existingSubnets.slice(0, 2).map((s) => s.AvailabilityZone);
431
+ } else {
432
+ spinner.ora.text = "Discovering availability zones";
433
+ const { AvailabilityZones } = await client.send(
434
+ new DescribeAvailabilityZonesCommand({
435
+ Filters: [{ Name: "state", Values: ["available"] }],
436
+ }),
437
+ );
438
+ azs = AvailabilityZones.slice(0, 2).map((az) => az.ZoneName);
439
+ }
440
+
441
+ if (azs.length < 2) {
442
+ throw new Error("Need at least 2 availability zones");
443
+ }
444
+
445
+ // Step 1: Subnets
446
+ let privateSubnetIds;
447
+ if (needsSubnets) {
448
+ spinner.ora.text = "Discovering available CIDR blocks";
449
+ const { Subnets: allSubnets } = await client.send(
450
+ new DescribeSubnetsCommand({
451
+ Filters: [{ Name: "vpc-id", Values: [vpcId] }],
452
+ }),
453
+ );
454
+
455
+ const usedBlocks = allSubnets.map((s) => {
456
+ const [ip, prefix] = s.CidrBlock.split("/");
457
+ const octets = ip.split(".").map(Number);
458
+ const start =
459
+ (octets[0] << 24) |
460
+ (octets[1] << 16) |
461
+ (octets[2] << 8) |
462
+ octets[3];
463
+ const size = 1 << (32 - Number(prefix));
464
+ return { start, end: start + size };
465
+ });
466
+
467
+ const vpcOctets = vpc.CidrBlock.split("/")[0]
468
+ .split(".")
469
+ .map(Number);
470
+ const vpcStart =
471
+ (vpcOctets[0] << 24) |
472
+ (vpcOctets[1] << 16) |
473
+ (vpcOctets[2] << 8) |
474
+ vpcOctets[3];
475
+ const vpcSize = 1 << (32 - Number(vpc.CidrBlock.split("/")[1]));
476
+ const vpcEnd = vpcStart + vpcSize;
477
+ const blockSize = 4096; // /20
478
+
479
+ const availableBlocks = [];
480
+ for (
481
+ let candidate = vpcStart;
482
+ candidate + blockSize <= vpcEnd && availableBlocks.length < 2;
483
+ candidate += blockSize
484
+ ) {
485
+ const candidateEnd = candidate + blockSize;
486
+ const overlaps = usedBlocks.some(
487
+ (b) => candidate < b.end && candidateEnd > b.start,
488
+ );
489
+ if (!overlaps) {
490
+ const o1 = (candidate >>> 24) & 0xff;
491
+ const o2 = (candidate >>> 16) & 0xff;
492
+ const o3 = (candidate >>> 8) & 0xff;
493
+ const o4 = candidate & 0xff;
494
+ availableBlocks.push(`${o1}.${o2}.${o3}.${o4}/20`);
495
+ }
496
+ }
497
+
498
+ if (availableBlocks.length < 2) {
499
+ throw new Error(
500
+ "Could not find 2 available /20 CIDR blocks in the default VPC",
501
+ );
502
+ }
503
+
504
+ privateSubnetIds = [];
505
+ for (let i = 0; i < 2; i++) {
506
+ spinner.ora.text = `Creating private subnet in ${azs[i]}`;
507
+ const subnet = await client.send(
508
+ new CreateSubnetCommand({
509
+ VpcId: vpcId,
510
+ CidrBlock: availableBlocks[i],
511
+ AvailabilityZone: azs[i],
512
+ TagSpecifications: [
513
+ {
514
+ ResourceType: "subnet",
515
+ Tags: [
516
+ {
517
+ Key: "Name",
518
+ Value: `fw-auto-private-subnet-${azs[i]}`,
519
+ },
520
+ { Key: "private", Value: "true" },
521
+ ],
522
+ },
523
+ ],
524
+ }),
525
+ );
526
+ privateSubnetIds.push(subnet.Subnet.SubnetId);
527
+ }
528
+ } else {
529
+ privateSubnetIds = existingSubnets.map((s) => s.SubnetId);
530
+ }
531
+
532
+ // Step 2: NAT gateways (+ Elastic IPs)
533
+ let natGatewayIds;
534
+ if (needsNats) {
535
+ // Find public subnets per AZ
536
+ spinner.ora.text = "Identifying public subnets";
537
+ const { InternetGateways } = await client.send(
538
+ new DescribeInternetGatewaysCommand({
539
+ Filters: [{ Name: "attachment.vpc-id", Values: [vpcId] }],
540
+ }),
541
+ );
542
+
543
+ if (!InternetGateways.length) {
544
+ throw new Error("No Internet Gateway found on default VPC");
545
+ }
546
+
547
+ const { Subnets: vpcSubnets } = await client.send(
548
+ new DescribeSubnetsCommand({
549
+ Filters: [{ Name: "vpc-id", Values: [vpcId] }],
550
+ }),
551
+ );
552
+
553
+ const { RouteTables } = await client.send(
554
+ new DescribeRouteTablesCommand({
555
+ Filters: [{ Name: "vpc-id", Values: [vpcId] }],
556
+ }),
557
+ );
558
+
559
+ const publicSubnetsByAz = {};
560
+ for (const subnet of vpcSubnets) {
561
+ if (privateSubnetIds.includes(subnet.SubnetId)) continue;
562
+
563
+ let rt = RouteTables.find((r) =>
564
+ r.Associations.some((a) => a.SubnetId === subnet.SubnetId),
565
+ );
566
+ if (!rt) {
567
+ rt = RouteTables.find((r) =>
568
+ r.Associations.some((a) => a.Main),
569
+ );
570
+ }
571
+ if (!rt) continue;
572
+
573
+ const hasIgwRoute = rt.Routes.some(
574
+ (route) =>
575
+ route.GatewayId &&
576
+ route.GatewayId.startsWith("igw-") &&
577
+ route.DestinationCidrBlock === "0.0.0.0/0",
578
+ );
579
+
580
+ if (
581
+ hasIgwRoute &&
582
+ !publicSubnetsByAz[subnet.AvailabilityZone]
583
+ ) {
584
+ publicSubnetsByAz[subnet.AvailabilityZone] =
585
+ subnet.SubnetId;
586
+ }
587
+ }
588
+
589
+ for (const az of azs) {
590
+ if (!publicSubnetsByAz[az]) {
591
+ throw new Error(
592
+ `No public subnet found in ${az} to host NAT gateway`,
593
+ );
594
+ }
595
+ }
596
+
597
+ natGatewayIds = [];
598
+ for (let i = 0; i < 2; i++) {
599
+ spinner.ora.text = `Allocating Elastic IP for NAT gateway in ${azs[i]}`;
600
+ const eip = await client.send(
601
+ new AllocateAddressCommand({
602
+ Domain: "vpc",
603
+ TagSpecifications: [
604
+ {
605
+ ResourceType: "elastic-ip",
606
+ Tags: [
607
+ {
608
+ Key: "Name",
609
+ Value: `fw-auto-nat-eip-${azs[i]}`,
610
+ },
611
+ { Key: "private", Value: "true" },
612
+ ],
613
+ },
614
+ ],
615
+ }),
616
+ );
617
+
618
+ spinner.ora.text = `Creating NAT gateway in ${azs[i]}`;
619
+ const nat = await client.send(
620
+ new CreateNatGatewayCommand({
621
+ SubnetId: publicSubnetsByAz[azs[i]],
622
+ AllocationId: eip.AllocationId,
623
+ TagSpecifications: [
624
+ {
625
+ ResourceType: "natgateway",
626
+ Tags: [
627
+ {
628
+ Key: "Name",
629
+ Value: `fw-auto-nat-gateway-${azs[i]}`,
630
+ },
631
+ { Key: "private", Value: "true" },
632
+ ],
633
+ },
634
+ ],
635
+ }),
636
+ );
637
+
638
+ natGatewayIds.push(nat.NatGateway.NatGatewayId);
639
+ }
640
+
641
+ for (let i = 0; i < 2; i++) {
642
+ spinner.ora.text = `Waiting for NAT gateway in ${azs[i]} to become available`;
643
+ await poll(
644
+ async () => {
645
+ const res = await client.send(
646
+ new DescribeNatGatewaysCommand({
647
+ NatGatewayIds: [natGatewayIds[i]],
648
+ }),
649
+ );
650
+ return res.NatGateways[0];
651
+ },
652
+ (nat) => nat.State !== "available",
653
+ );
654
+ }
655
+ } else {
656
+ natGatewayIds = existingNats.map((n) => n.NatGatewayId);
657
+ }
658
+
659
+ // Step 3: Route tables
660
+ if (needsRtbs) {
661
+ for (let i = 0; i < 2; i++) {
662
+ spinner.ora.text = `Creating route table for ${azs[i]}`;
663
+ const rt = await client.send(
664
+ new CreateRouteTableCommand({
665
+ VpcId: vpcId,
666
+ TagSpecifications: [
667
+ {
668
+ ResourceType: "route-table",
669
+ Tags: [
670
+ {
671
+ Key: "Name",
672
+ Value: `fw-auto-private-rtb-${azs[i]}`,
673
+ },
674
+ { Key: "private", Value: "true" },
675
+ ],
676
+ },
677
+ ],
678
+ }),
679
+ );
680
+
681
+ spinner.ora.text = `Adding NAT gateway route for ${azs[i]}`;
682
+ await client.send(
683
+ new CreateRouteCommand({
684
+ RouteTableId: rt.RouteTable.RouteTableId,
685
+ DestinationCidrBlock: "0.0.0.0/0",
686
+ NatGatewayId: natGatewayIds[i],
687
+ }),
688
+ );
689
+
690
+ spinner.ora.text = `Associating route table with private subnet in ${azs[i]}`;
691
+ await client.send(
692
+ new AssociateRouteTableCommand({
693
+ RouteTableId: rt.RouteTable.RouteTableId,
694
+ SubnetId: privateSubnetIds[i],
695
+ }),
696
+ );
697
+ }
698
+ }
699
+
700
+ spinner.ora.succeed("Private subnets configured");
701
+
702
+ const publicSubnetIds = await discoverPublicSubnets(
703
+ client,
704
+ vpcId,
705
+ privateSubnetIds,
706
+ azs,
707
+ );
708
+
709
+ return { privateSubnetIds, publicSubnetIds, vpcId };
710
+ } catch (e) {
711
+ spinner.ora.fail("Failed to set up private subnets");
712
+ throw e;
713
+ }
714
+ };