@fishawack/lab-env 5.7.0-beta.1 → 5.7.0-beta.3

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