1import * as AWS from "@/AWS";
2import {
3 EgressOnlyInternetGateway,
4 EIP,
5 InternetGateway,
6 NatGateway,
7 NetworkAcl,
8 NetworkAclAssociation,
9 NetworkAclEntry,
10 Route,
11 RouteTable,
12 RouteTableAssociation,
13 SecurityGroup,
14 Subnet,
15 Vpc,
16 VpcEndpoint,
17} from "@/AWS/EC2";
18import * as Test from "./VpcTest.ts";
19import * as EC2 from "@distilled.cloud/aws/ec2";
20import * as ec2 from "@distilled.cloud/aws/ec2";
21import { expect } from "alchemy-test";
22import { Data, Schedule } from "effect";
23import * as Effect from "effect/Effect";
24import { MinimumLogLevel } from "effect/References";
25
26const logLevel = Effect.provideService(
27 MinimumLogLevel,
28 process.env.DEBUG ? "Debug" : "Info",
29);
30
31const { test } = Test.make({ providers: AWS.providers() });
32
33test.provider.skip(
34 "VPC evolution: from simple to complex",
35 (stack) =>
36 Effect.gen(function* () {
37 yield* stack.destroy();
38
39 // Get available AZs for multi-AZ stages
40 const azResult = yield* EC2.describeAvailabilityZones({});
41 const availableAzs =
42 azResult.AvailabilityZones?.filter((az) => az.State === "available") ??
43 [];
44 const az1 = availableAzs[0]?.ZoneName!;
45 const az2 = availableAzs[1]?.ZoneName!;
46
47 // =========================================================================
48 // STAGE 1: Bare Minimum VPC
49 // User starts with just a VPC - the most basic setup
50 // =========================================================================
51 yield* Effect.log("=== Stage 1: Bare Minimum VPC ===");
52 {
53 const myVpc = yield* stack.deploy(
54 Effect.gen(function* () {
55 return yield* Vpc("MyVpc", {
56 cidrBlock: "10.0.0.0/16",
57 });
58 }),
59 );
60
61 // Verify VPC was created
62 expect(myVpc.vpcId).toMatch(/^vpc-/);
63 expect(myVpc.cidrBlock).toEqual("10.0.0.0/16");
64 expect(myVpc.state).toEqual("available");
65
66 const vpcResult = yield* EC2.describeVpcs({
67 VpcIds: [myVpc.vpcId],
68 });
69 expect(vpcResult.Vpcs?.[0]?.CidrBlock).toEqual("10.0.0.0/16");
70 }
71
72 // =========================================================================
73 // STAGE 2: Add Internet Connectivity
74 // User needs public internet access - add IGW, public subnet, route table
75 // Tests: VPC update (DNS settings), IGW create, Subnet create, Route create
76 // =========================================================================
77 yield* Effect.log("=== Stage 2: Add Internet Connectivity ===");
78 {
79 const _stack = yield* stack.deploy(
80 Effect.gen(function* () {
81 const myVpc = yield* Vpc("MyVpc", {
82 cidrBlock: "10.0.0.0/16",
83 enableDnsSupport: true,
84 enableDnsHostnames: true,
85 });
86
87 const internetGateway = yield* InternetGateway("InternetGateway", {
88 vpcId: myVpc.vpcId,
89 });
90
91 const publicSubnet1 = yield* Subnet("PublicSubnet1", {
92 vpcId: myVpc.vpcId,
93 cidrBlock: "10.0.1.0/24",
94 availabilityZone: az1,
95 mapPublicIpOnLaunch: true,
96 });
97
98 const publicRouteTable = yield* RouteTable("PublicRouteTable", {
99 vpcId: myVpc.vpcId,
100 });
101
102 const internetRoute = yield* Route("InternetRoute", {
103 routeTableId: publicRouteTable.routeTableId,
104 destinationCidrBlock: "0.0.0.0/0",
105 gatewayId: internetGateway.internetGatewayId,
106 });
107
108 const publicSubnet1Association = yield* RouteTableAssociation(
109 "PublicSubnet1Association",
110 {
111 routeTableId: publicRouteTable.routeTableId,
112 subnetId: publicSubnet1.subnetId,
113 },
114 );
115
116 return {
117 myVpc,
118 internetGateway,
119 publicSubnet1,
120 publicRouteTable,
121 internetRoute,
122 publicSubnet1Association,
123 };
124 }),
125 );
126
127 // Verify IGW
128 expect(_stack.internetGateway.internetGatewayId).toMatch(/^igw-/);
129 expect(_stack.internetGateway.vpcId).toEqual(_stack.myVpc.vpcId);
130
131 // Verify public subnet
132 expect(_stack.publicSubnet1.subnetId).toMatch(/^subnet-/);
133 expect(_stack.publicSubnet1.mapPublicIpOnLaunch).toEqual(true);
134 expect(_stack.publicSubnet1.availabilityZone).toEqual(az1);
135
136 // Verify route to IGW
137 expect(_stack.internetRoute.state).toEqual("active");
138 expect(_stack.internetRoute.gatewayId).toEqual(
139 _stack.internetGateway.internetGatewayId,
140 );
141
142 // Verify association
143 expect(_stack.publicSubnet1Association.associationId).toMatch(
144 /^rtbassoc-/,
145 );
146 }
147
148 // =========================================================================
149 // STAGE 3: Add Private Subnet
150 // User needs private resources (databases, internal services)
151 // Tests: Adding private subnet with separate route table (no internet)
152 // =========================================================================
153 yield* Effect.log("=== Stage 3: Add Private Subnet ===");
154 {
155 const _stack = yield* stack.deploy(
156 Effect.gen(function* () {
157 const myVpc = yield* Vpc("MyVpc", {
158 cidrBlock: "10.0.0.0/16",
159 enableDnsSupport: true,
160 enableDnsHostnames: true,
161 });
162
163 const internetGateway = yield* InternetGateway("InternetGateway", {
164 vpcId: myVpc.vpcId,
165 });
166
167 const publicSubnet1 = yield* Subnet("PublicSubnet1", {
168 vpcId: myVpc.vpcId,
169 cidrBlock: "10.0.1.0/24",
170 availabilityZone: az1,
171 mapPublicIpOnLaunch: true,
172 });
173
174 const privateSubnet1 = yield* Subnet("PrivateSubnet1", {
175 vpcId: myVpc.vpcId,
176 cidrBlock: "10.0.10.0/24",
177 availabilityZone: az1,
178 });
179
180 const publicRouteTable = yield* RouteTable("PublicRouteTable", {
181 vpcId: myVpc.vpcId,
182 });
183
184 const privateRouteTable = yield* RouteTable("PrivateRouteTable", {
185 vpcId: myVpc.vpcId,
186 });
187
188 const internetRoute = yield* Route("InternetRoute", {
189 routeTableId: publicRouteTable.routeTableId,
190 destinationCidrBlock: "0.0.0.0/0",
191 gatewayId: internetGateway.internetGatewayId,
192 });
193
194 const publicSubnet1Association = yield* RouteTableAssociation(
195 "PublicSubnet1Association",
196 {
197 routeTableId: publicRouteTable.routeTableId,
198 subnetId: publicSubnet1.subnetId,
199 },
200 );
201
202 const privateSubnet1Association = yield* RouteTableAssociation(
203 "PrivateSubnet1Association",
204 {
205 routeTableId: privateRouteTable.routeTableId,
206 subnetId: privateSubnet1.subnetId,
207 },
208 );
209
210 return {
211 myVpc,
212 internetGateway,
213 publicSubnet1,
214 privateSubnet1,
215 publicRouteTable,
216 privateRouteTable,
217 internetRoute,
218 publicSubnet1Association,
219 privateSubnet1Association,
220 };
221 }),
222 );
223
224 // Verify private subnet
225 expect(_stack.privateSubnet1.subnetId).toMatch(/^subnet-/);
226 expect(_stack.privateSubnet1.mapPublicIpOnLaunch).toBeFalsy();
227
228 // Verify private route table has NO internet route
229 const privateRtResult = yield* EC2.describeRouteTables({
230 RouteTableIds: [_stack.privateRouteTable.routeTableId],
231 });
232 const privateRoutes = privateRtResult.RouteTables?.[0]?.Routes ?? [];
233 const privateInternetRoute = privateRoutes.find(
234 (r) => r.DestinationCidrBlock === "0.0.0.0/0",
235 );
236 expect(privateInternetRoute).toBeUndefined();
237 }
238
239 // =========================================================================
240 // STAGE 4: Multi-AZ Expansion
241 // User needs high availability - add subnets in second AZ
242 // Tests: Adding subnets in second AZ, sharing route tables
243 // =========================================================================
244 yield* Effect.log("=== Stage 4: Multi-AZ Expansion ===");
245 {
246 const _stack = yield* stack.deploy(
247 Effect.gen(function* () {
248 const myVpc = yield* Vpc("MyVpc", {
249 cidrBlock: "10.0.0.0/16",
250 enableDnsSupport: true,
251 enableDnsHostnames: true,
252 });
253
254 const internetGateway = yield* InternetGateway("InternetGateway", {
255 vpcId: myVpc.vpcId,
256 });
257
258 // AZ1 subnets
259 const publicSubnet1 = yield* Subnet("PublicSubnet1", {
260 vpcId: myVpc.vpcId,
261 cidrBlock: "10.0.1.0/24",
262 availabilityZone: az1,
263 mapPublicIpOnLaunch: true,
264 });
265
266 const privateSubnet1 = yield* Subnet("PrivateSubnet1", {
267 vpcId: myVpc.vpcId,
268 cidrBlock: "10.0.10.0/24",
269 availabilityZone: az1,
270 });
271
272 // AZ2 subnets
273 const publicSubnet2 = yield* Subnet("PublicSubnet2", {
274 vpcId: myVpc.vpcId,
275 cidrBlock: "10.0.2.0/24",
276 availabilityZone: az2,
277 mapPublicIpOnLaunch: true,
278 });
279
280 const privateSubnet2 = yield* Subnet("PrivateSubnet2", {
281 vpcId: myVpc.vpcId,
282 cidrBlock: "10.0.11.0/24",
283 availabilityZone: az2,
284 });
285
286 const publicRouteTable = yield* RouteTable("PublicRouteTable", {
287 vpcId: myVpc.vpcId,
288 });
289
290 const privateRouteTable = yield* RouteTable("PrivateRouteTable", {
291 vpcId: myVpc.vpcId,
292 });
293
294 const internetRoute = yield* Route("InternetRoute", {
295 routeTableId: publicRouteTable.routeTableId,
296 destinationCidrBlock: "0.0.0.0/0",
297 gatewayId: internetGateway.internetGatewayId,
298 });
299
300 // AZ1 associations
301 const publicSubnet1Association = yield* RouteTableAssociation(
302 "PublicSubnet1Association",
303 {
304 routeTableId: publicRouteTable.routeTableId,
305 subnetId: publicSubnet1.subnetId,
306 },
307 );
308
309 const privateSubnet1Association = yield* RouteTableAssociation(
310 "PrivateSubnet1Association",
311 {
312 routeTableId: privateRouteTable.routeTableId,
313 subnetId: privateSubnet1.subnetId,
314 },
315 );
316
317 // AZ2 associations (share route tables)
318 const publicSubnet2Association = yield* RouteTableAssociation(
319 "PublicSubnet2Association",
320 {
321 routeTableId: publicRouteTable.routeTableId,
322 subnetId: publicSubnet2.subnetId,
323 },
324 );
325
326 const privateSubnet2Association = yield* RouteTableAssociation(
327 "PrivateSubnet2Association",
328 {
329 routeTableId: privateRouteTable.routeTableId,
330 subnetId: privateSubnet2.subnetId,
331 },
332 );
333
334 return {
335 myVpc,
336 internetGateway,
337 publicSubnet1,
338 privateSubnet1,
339 publicSubnet2,
340 privateSubnet2,
341 publicRouteTable,
342 privateRouteTable,
343 internetRoute,
344 publicSubnet1Association,
345 privateSubnet1Association,
346 publicSubnet2Association,
347 privateSubnet2Association,
348 };
349 }),
350 );
351
352 // Verify subnets are in different AZs
353 expect(_stack.publicSubnet1.availabilityZone).toEqual(az1);
354 expect(_stack.publicSubnet2.availabilityZone).toEqual(az2);
355 expect(_stack.privateSubnet1.availabilityZone).toEqual(az1);
356 expect(_stack.privateSubnet2.availabilityZone).toEqual(az2);
357
358 // Verify all 4 associations exist
359 expect(_stack.publicSubnet1Association.associationId).toMatch(
360 /^rtbassoc-/,
361 );
362 expect(_stack.publicSubnet2Association.associationId).toMatch(
363 /^rtbassoc-/,
364 );
365 expect(_stack.privateSubnet1Association.associationId).toMatch(
366 /^rtbassoc-/,
367 );
368 expect(_stack.privateSubnet2Association.associationId).toMatch(
369 /^rtbassoc-/,
370 );
371
372 // Verify both public subnets share the same route table
373 expect(_stack.publicSubnet1Association.routeTableId).toEqual(
374 _stack.publicSubnet2Association.routeTableId,
375 );
376 }
377
378 // =========================================================================
379 // STAGE 5: Update Tags and Properties
380 // User needs better organization - add tags for production
381 // Tests: Tag updates on existing resources
382 // =========================================================================
383 yield* Effect.log("=== Stage 5: Update Tags and Properties ===");
384 {
385 const _stack = yield* stack.deploy(
386 Effect.gen(function* () {
387 const myVpc = yield* Vpc("MyVpc", {
388 cidrBlock: "10.0.0.0/16",
389 enableDnsSupport: true,
390 enableDnsHostnames: true,
391 tags: {
392 Name: "production-vpc",
393 Environment: "production",
394 },
395 });
396
397 const internetGateway = yield* InternetGateway("InternetGateway", {
398 vpcId: myVpc.vpcId,
399 tags: {
400 Name: "production-igw",
401 },
402 });
403
404 const publicSubnet1 = yield* Subnet("PublicSubnet1", {
405 vpcId: myVpc.vpcId,
406 cidrBlock: "10.0.1.0/24",
407 availabilityZone: az1,
408 mapPublicIpOnLaunch: true,
409 tags: { Name: "public-1a", Tier: "public" },
410 });
411
412 const privateSubnet1 = yield* Subnet("PrivateSubnet1", {
413 vpcId: myVpc.vpcId,
414 cidrBlock: "10.0.10.0/24",
415 availabilityZone: az1,
416 tags: { Name: "private-1a", Tier: "private" },
417 });
418
419 const publicSubnet2 = yield* Subnet("PublicSubnet2", {
420 vpcId: myVpc.vpcId,
421 cidrBlock: "10.0.2.0/24",
422 availabilityZone: az2,
423 mapPublicIpOnLaunch: true,
424 tags: { Name: "public-1b", Tier: "public" },
425 });
426
427 const privateSubnet2 = yield* Subnet("PrivateSubnet2", {
428 vpcId: myVpc.vpcId,
429 cidrBlock: "10.0.11.0/24",
430 availabilityZone: az2,
431 tags: { Name: "private-1b", Tier: "private" },
432 });
433
434 const publicRouteTable = yield* RouteTable("PublicRouteTable", {
435 vpcId: myVpc.vpcId,
436 tags: { Name: "public-rt" },
437 });
438
439 const privateRouteTable = yield* RouteTable("PrivateRouteTable", {
440 vpcId: myVpc.vpcId,
441 tags: { Name: "private-rt" },
442 });
443
444 const internetRoute = yield* Route("InternetRoute", {
445 routeTableId: publicRouteTable.routeTableId,
446 destinationCidrBlock: "0.0.0.0/0",
447 gatewayId: internetGateway.internetGatewayId,
448 });
449
450 const publicSubnet1Association = yield* RouteTableAssociation(
451 "PublicSubnet1Association",
452 {
453 routeTableId: publicRouteTable.routeTableId,
454 subnetId: publicSubnet1.subnetId,
455 },
456 );
457
458 const privateSubnet1Association = yield* RouteTableAssociation(
459 "PrivateSubnet1Association",
460 {
461 routeTableId: privateRouteTable.routeTableId,
462 subnetId: privateSubnet1.subnetId,
463 },
464 );
465
466 const publicSubnet2Association = yield* RouteTableAssociation(
467 "PublicSubnet2Association",
468 {
469 routeTableId: publicRouteTable.routeTableId,
470 subnetId: publicSubnet2.subnetId,
471 },
472 );
473
474 const privateSubnet2Association = yield* RouteTableAssociation(
475 "PrivateSubnet2Association",
476 {
477 routeTableId: privateRouteTable.routeTableId,
478 subnetId: privateSubnet2.subnetId,
479 },
480 );
481
482 return {
483 myVpc,
484 internetGateway,
485 publicSubnet1,
486 privateSubnet1,
487 publicSubnet2,
488 privateSubnet2,
489 publicRouteTable,
490 privateRouteTable,
491 internetRoute,
492 publicSubnet1Association,
493 privateSubnet1Association,
494 publicSubnet2Association,
495 privateSubnet2Association,
496 };
497 }),
498 );
499
500 // Verify tags were applied by checking AWS (with retry for eventual consistency)
501 yield* assertVpcTags(_stack.myVpc.vpcId, {
502 Name: "production-vpc",
503 Environment: "production",
504 });
505 }
506
507 // =========================================================================
508 // STAGE 6: Re-associate Subnet to Different Route Table
509 // User wants to move PublicSubnet2 to a dedicated route table
510 // Tests: Route table association update (replaceRouteTableAssociation)
511 // =========================================================================
512 yield* Effect.log("=== Stage 6: Re-associate Subnet ===");
513 {
514 const _stack = yield* stack.deploy(
515 Effect.gen(function* () {
516 const myVpc = yield* Vpc("MyVpc", {
517 cidrBlock: "10.0.0.0/16",
518 enableDnsSupport: true,
519 enableDnsHostnames: true,
520 tags: {
521 Name: "production-vpc",
522 Environment: "production",
523 },
524 });
525
526 const internetGateway = yield* InternetGateway("InternetGateway", {
527 vpcId: myVpc.vpcId,
528 tags: { Name: "production-igw" },
529 });
530
531 const publicSubnet1 = yield* Subnet("PublicSubnet1", {
532 vpcId: myVpc.vpcId,
533 cidrBlock: "10.0.1.0/24",
534 availabilityZone: az1,
535 mapPublicIpOnLaunch: true,
536 tags: { Name: "public-1a", Tier: "public" },
537 });
538
539 const privateSubnet1 = yield* Subnet("PrivateSubnet1", {
540 vpcId: myVpc.vpcId,
541 cidrBlock: "10.0.10.0/24",
542 availabilityZone: az1,
543 tags: { Name: "private-1a", Tier: "private" },
544 });
545
546 const publicSubnet2 = yield* Subnet("PublicSubnet2", {
547 vpcId: myVpc.vpcId,
548 cidrBlock: "10.0.2.0/24",
549 availabilityZone: az2,
550 mapPublicIpOnLaunch: true,
551 tags: { Name: "public-1b", Tier: "public" },
552 });
553
554 const privateSubnet2 = yield* Subnet("PrivateSubnet2", {
555 vpcId: myVpc.vpcId,
556 cidrBlock: "10.0.11.0/24",
557 availabilityZone: az2,
558 tags: { Name: "private-1b", Tier: "private" },
559 });
560
561 const publicRouteTable = yield* RouteTable("PublicRouteTable", {
562 vpcId: myVpc.vpcId,
563 tags: { Name: "public-rt" },
564 });
565
566 const privateRouteTable = yield* RouteTable("PrivateRouteTable", {
567 vpcId: myVpc.vpcId,
568 tags: { Name: "private-rt" },
569 });
570
571 // NEW: Dedicated route table for AZ2 public subnet
572 const publicRouteTable2 = yield* RouteTable("PublicRouteTable2", {
573 vpcId: myVpc.vpcId,
574 tags: { Name: "public-rt-az2" },
575 });
576
577 const internetRoute = yield* Route("InternetRoute", {
578 routeTableId: publicRouteTable.routeTableId,
579 destinationCidrBlock: "0.0.0.0/0",
580 gatewayId: internetGateway.internetGatewayId,
581 });
582
583 // NEW: Internet route for AZ2 public route table
584 const internetRoute2 = yield* Route("InternetRoute2", {
585 routeTableId: publicRouteTable2.routeTableId,
586 destinationCidrBlock: "0.0.0.0/0",
587 gatewayId: internetGateway.internetGatewayId,
588 });
589
590 const publicSubnet1Association = yield* RouteTableAssociation(
591 "PublicSubnet1Association",
592 {
593 routeTableId: publicRouteTable.routeTableId,
594 subnetId: publicSubnet1.subnetId,
595 },
596 );
597
598 const privateSubnet1Association = yield* RouteTableAssociation(
599 "PrivateSubnet1Association",
600 {
601 routeTableId: privateRouteTable.routeTableId,
602 subnetId: privateSubnet1.subnetId,
603 },
604 );
605
606 // CHANGED: PublicSubnet2 now uses PublicRouteTable2
607 const publicSubnet2Association = yield* RouteTableAssociation(
608 "PublicSubnet2Association",
609 {
610 routeTableId: publicRouteTable2.routeTableId,
611 subnetId: publicSubnet2.subnetId,
612 },
613 );
614
615 const privateSubnet2Association = yield* RouteTableAssociation(
616 "PrivateSubnet2Association",
617 {
618 routeTableId: privateRouteTable.routeTableId,
619 subnetId: privateSubnet2.subnetId,
620 },
621 );
622
623 return {
624 myVpc,
625 internetGateway,
626 publicSubnet1,
627 privateSubnet1,
628 publicSubnet2,
629 privateSubnet2,
630 publicRouteTable,
631 privateRouteTable,
632 publicRouteTable2,
633 internetRoute,
634 internetRoute2,
635 publicSubnet1Association,
636 privateSubnet1Association,
637 publicSubnet2Association,
638 privateSubnet2Association,
639 };
640 }),
641 );
642
643 // Verify PublicSubnet2 is now associated with a different route table
644 expect(_stack.publicSubnet2Association.routeTableId).toEqual(
645 _stack.publicRouteTable2.routeTableId,
646 );
647 expect(_stack.publicSubnet2Association.routeTableId).not.toEqual(
648 _stack.publicSubnet1Association.routeTableId,
649 );
650
651 // Verify the new route table has an internet route
652 expect(_stack.internetRoute2.state).toEqual("active");
653 }
654
655 // =========================================================================
656 // STAGE 7: Scale Down
657 // User removes AZ2 resources (cost savings)
658 // Tests: Resource deletion, dependency ordering during delete
659 // =========================================================================
660 yield* Effect.log("=== Stage 7: Scale Down ===");
661 {
662 const _stack = yield* stack.deploy(
663 Effect.gen(function* () {
664 const myVpc = yield* Vpc("MyVpc", {
665 cidrBlock: "10.0.0.0/16",
666 enableDnsSupport: true,
667 enableDnsHostnames: true,
668 tags: {
669 Name: "production-vpc",
670 Environment: "production",
671 },
672 });
673
674 const internetGateway = yield* InternetGateway("InternetGateway", {
675 vpcId: myVpc.vpcId,
676 tags: { Name: "production-igw" },
677 });
678
679 // Only AZ1 subnets remain
680 const publicSubnet1 = yield* Subnet("PublicSubnet1", {
681 vpcId: myVpc.vpcId,
682 cidrBlock: "10.0.1.0/24",
683 availabilityZone: az1,
684 mapPublicIpOnLaunch: true,
685 tags: { Name: "public-1a", Tier: "public" },
686 });
687
688 const privateSubnet1 = yield* Subnet("PrivateSubnet1", {
689 vpcId: myVpc.vpcId,
690 cidrBlock: "10.0.10.0/24",
691 availabilityZone: az1,
692 tags: { Name: "private-1a", Tier: "private" },
693 });
694
695 const publicRouteTable = yield* RouteTable("PublicRouteTable", {
696 vpcId: myVpc.vpcId,
697 tags: { Name: "public-rt" },
698 });
699
700 const privateRouteTable = yield* RouteTable("PrivateRouteTable", {
701 vpcId: myVpc.vpcId,
702 tags: { Name: "private-rt" },
703 });
704
705 const internetRoute = yield* Route("InternetRoute", {
706 routeTableId: publicRouteTable.routeTableId,
707 destinationCidrBlock: "0.0.0.0/0",
708 gatewayId: internetGateway.internetGatewayId,
709 });
710
711 const publicSubnet1Association = yield* RouteTableAssociation(
712 "PublicSubnet1Association",
713 {
714 routeTableId: publicRouteTable.routeTableId,
715 subnetId: publicSubnet1.subnetId,
716 },
717 );
718
719 const privateSubnet1Association = yield* RouteTableAssociation(
720 "PrivateSubnet1Association",
721 {
722 routeTableId: privateRouteTable.routeTableId,
723 subnetId: privateSubnet1.subnetId,
724 },
725 );
726
727 // Note: PublicSubnet2, PrivateSubnet2, PublicRouteTable2, InternetRoute2,
728 // and their associations are NOT included - they will be deleted
729
730 return {
731 myVpc,
732 internetGateway,
733 publicSubnet1,
734 privateSubnet1,
735 publicRouteTable,
736 privateRouteTable,
737 internetRoute,
738 publicSubnet1Association,
739 privateSubnet1Association,
740 };
741 }),
742 );
743
744 // Verify only 2 subnets exist now
745 const subnetsResult = yield* EC2.describeSubnets({
746 Filters: [{ Name: "vpc-id", Values: [_stack.myVpc.vpcId] }],
747 });
748 expect(subnetsResult.Subnets).toHaveLength(2);
749
750 // Verify remaining subnets are in AZ1
751 for (const subnet of subnetsResult.Subnets ?? []) {
752 expect(subnet.AvailabilityZone).toEqual(az1);
753 }
754 }
755
756 // =========================================================================
757 // STAGE 8: Add NAT Gateway for Private Subnet Internet Access
758 // User needs private instances to access internet for updates
759 // Tests: EIP create, NAT Gateway create with state waiting
760 // =========================================================================
761 yield* Effect.log("=== Stage 8: Add NAT Gateway ===");
762 {
763 const _stack = yield* stack.deploy(
764 Effect.gen(function* () {
765 const myVpc = yield* Vpc("MyVpc", {
766 cidrBlock: "10.0.0.0/16",
767 enableDnsSupport: true,
768 enableDnsHostnames: true,
769 tags: {
770 Name: "production-vpc",
771 Environment: "production",
772 },
773 });
774
775 const internetGateway = yield* InternetGateway("InternetGateway", {
776 vpcId: myVpc.vpcId,
777 tags: { Name: "production-igw" },
778 });
779
780 const publicSubnet1 = yield* Subnet("PublicSubnet1", {
781 vpcId: myVpc.vpcId,
782 cidrBlock: "10.0.1.0/24",
783 availabilityZone: az1,
784 mapPublicIpOnLaunch: true,
785 tags: { Name: "public-1a", Tier: "public" },
786 });
787
788 const privateSubnet1 = yield* Subnet("PrivateSubnet1", {
789 vpcId: myVpc.vpcId,
790 cidrBlock: "10.0.10.0/24",
791 availabilityZone: az1,
792 tags: { Name: "private-1a", Tier: "private" },
793 });
794
795 const publicRouteTable = yield* RouteTable("PublicRouteTable", {
796 vpcId: myVpc.vpcId,
797 tags: { Name: "public-rt" },
798 });
799
800 const privateRouteTable = yield* RouteTable("PrivateRouteTable", {
801 vpcId: myVpc.vpcId,
802 tags: { Name: "private-rt" },
803 });
804
805 const internetRoute = yield* Route("InternetRoute", {
806 routeTableId: publicRouteTable.routeTableId,
807 destinationCidrBlock: "0.0.0.0/0",
808 gatewayId: internetGateway.internetGatewayId,
809 });
810
811 // NEW: Elastic IP for NAT Gateway
812 const natEip = yield* EIP("NatEip", {
813 tags: { Name: "nat-eip" },
814 });
815
816 // NEW: NAT Gateway in public subnet
817 const natGateway = yield* NatGateway("NatGateway", {
818 subnetId: publicSubnet1.subnetId,
819 allocationId: natEip.allocationId,
820 tags: { Name: "production-nat" },
821 });
822
823 // NEW: Route from private subnet to NAT Gateway
824 const natRoute = yield* Route("NatRoute", {
825 routeTableId: privateRouteTable.routeTableId,
826 destinationCidrBlock: "0.0.0.0/0",
827 natGatewayId: natGateway.natGatewayId,
828 });
829
830 const publicSubnet1Association = yield* RouteTableAssociation(
831 "PublicSubnet1Association",
832 {
833 routeTableId: publicRouteTable.routeTableId,
834 subnetId: publicSubnet1.subnetId,
835 },
836 );
837
838 const privateSubnet1Association = yield* RouteTableAssociation(
839 "PrivateSubnet1Association",
840 {
841 routeTableId: privateRouteTable.routeTableId,
842 subnetId: privateSubnet1.subnetId,
843 },
844 );
845
846 return {
847 myVpc,
848 internetGateway,
849 publicSubnet1,
850 privateSubnet1,
851 publicRouteTable,
852 privateRouteTable,
853 internetRoute,
854 natEip,
855 natGateway,
856 natRoute,
857 publicSubnet1Association,
858 privateSubnet1Association,
859 };
860 }),
861 );
862
863 // Verify EIP
864 expect(_stack.natEip.allocationId).toMatch(/^eipalloc-/);
865 expect(_stack.natEip.publicIp).toBeDefined();
866
867 // Verify NAT Gateway
868 expect(_stack.natGateway.natGatewayId).toMatch(/^nat-/);
869 expect(_stack.natGateway.state).toEqual("available");
870 expect(_stack.natGateway.publicIp).toEqual(_stack.natEip.publicIp);
871
872 // Verify NAT route is active
873 expect(_stack.natRoute.state).toEqual("active");
874 expect(_stack.natRoute.natGatewayId).toEqual(
875 _stack.natGateway.natGatewayId,
876 );
877
878 // Verify private route table now has internet route via NAT
879 const privateRtResult = yield* EC2.describeRouteTables({
880 RouteTableIds: [_stack.privateRouteTable.routeTableId],
881 });
882 const privateRoutes = privateRtResult.RouteTables?.[0]?.Routes ?? [];
883 const privateInternetRoute = privateRoutes.find(
884 (r) => r.DestinationCidrBlock === "0.0.0.0/0",
885 );
886 expect(privateInternetRoute?.NatGatewayId).toEqual(
887 _stack.natGateway.natGatewayId,
888 );
889 }
890
891 // =========================================================================
892 // STAGE 9: Add Security Groups
893 // User needs to control instance access
894 // Tests: Security Group with inline ingress/egress rules
895 // =========================================================================
896 yield* Effect.log("=== Stage 9: Add Security Groups ===");
897 {
898 const _stack = yield* stack.deploy(
899 Effect.gen(function* () {
900 const myVpc = yield* Vpc("MyVpc", {
901 cidrBlock: "10.0.0.0/16",
902 enableDnsSupport: true,
903 enableDnsHostnames: true,
904 tags: {
905 Name: "production-vpc",
906 Environment: "production",
907 },
908 });
909
910 const internetGateway = yield* InternetGateway("InternetGateway", {
911 vpcId: myVpc.vpcId,
912 tags: { Name: "production-igw" },
913 });
914
915 const publicSubnet1 = yield* Subnet("PublicSubnet1", {
916 vpcId: myVpc.vpcId,
917 cidrBlock: "10.0.1.0/24",
918 availabilityZone: az1,
919 mapPublicIpOnLaunch: true,
920 tags: { Name: "public-1a", Tier: "public" },
921 });
922
923 const privateSubnet1 = yield* Subnet("PrivateSubnet1", {
924 vpcId: myVpc.vpcId,
925 cidrBlock: "10.0.10.0/24",
926 availabilityZone: az1,
927 tags: { Name: "private-1a", Tier: "private" },
928 });
929
930 const publicRouteTable = yield* RouteTable("PublicRouteTable", {
931 vpcId: myVpc.vpcId,
932 tags: { Name: "public-rt" },
933 });
934
935 const privateRouteTable = yield* RouteTable("PrivateRouteTable", {
936 vpcId: myVpc.vpcId,
937 tags: { Name: "private-rt" },
938 });
939
940 const internetRoute = yield* Route("InternetRoute", {
941 routeTableId: publicRouteTable.routeTableId,
942 destinationCidrBlock: "0.0.0.0/0",
943 gatewayId: internetGateway.internetGatewayId,
944 });
945
946 const natEip = yield* EIP("NatEip", {
947 tags: { Name: "nat-eip" },
948 });
949
950 const natGateway = yield* NatGateway("NatGateway", {
951 subnetId: publicSubnet1.subnetId,
952 allocationId: natEip.allocationId,
953 tags: { Name: "production-nat" },
954 });
955
956 const natRoute = yield* Route("NatRoute", {
957 routeTableId: privateRouteTable.routeTableId,
958 destinationCidrBlock: "0.0.0.0/0",
959 natGatewayId: natGateway.natGatewayId,
960 });
961
962 // NEW: Web security group allowing HTTP/HTTPS
963 const webSecurityGroup = yield* SecurityGroup("WebSecurityGroup", {
964 vpcId: myVpc.vpcId,
965 description: "Web tier security group",
966 ingress: [
967 {
968 ipProtocol: "tcp",
969 fromPort: 80,
970 toPort: 80,
971 cidrIpv4: "0.0.0.0/0",
972 description: "Allow HTTP",
973 },
974 {
975 ipProtocol: "tcp",
976 fromPort: 443,
977 toPort: 443,
978 cidrIpv4: "0.0.0.0/0",
979 description: "Allow HTTPS",
980 },
981 ],
982 egress: [
983 {
984 ipProtocol: "-1",
985 cidrIpv4: "0.0.0.0/0",
986 description: "Allow all outbound",
987 },
988 ],
989 tags: { Name: "web-sg" },
990 });
991
992 // NEW: Database security group allowing access from web tier
993 const dbSecurityGroup = yield* SecurityGroup("DbSecurityGroup", {
994 vpcId: myVpc.vpcId,
995 description: "Database tier security group",
996 ingress: [
997 {
998 ipProtocol: "tcp",
999 fromPort: 5432,
1000 toPort: 5432,
1001 referencedGroupId: webSecurityGroup.groupId,
1002 description: "Allow PostgreSQL from web tier",
1003 },
1004 ],
1005 egress: [
1006 {
1007 ipProtocol: "-1",
1008 cidrIpv4: "0.0.0.0/0",
1009 description: "Allow all outbound",
1010 },
1011 ],
1012 tags: { Name: "db-sg" },
1013 });
1014
1015 const publicSubnet1Association = yield* RouteTableAssociation(
1016 "PublicSubnet1Association",
1017 {
1018 routeTableId: publicRouteTable.routeTableId,
1019 subnetId: publicSubnet1.subnetId,
1020 },
1021 );
1022
1023 const privateSubnet1Association = yield* RouteTableAssociation(
1024 "PrivateSubnet1Association",
1025 {
1026 routeTableId: privateRouteTable.routeTableId,
1027 subnetId: privateSubnet1.subnetId,
1028 },
1029 );
1030
1031 return {
1032 myVpc,
1033 internetGateway,
1034 publicSubnet1,
1035 privateSubnet1,
1036 publicRouteTable,
1037 privateRouteTable,
1038 internetRoute,
1039 natEip,
1040 natGateway,
1041 natRoute,
1042 webSecurityGroup,
1043 dbSecurityGroup,
1044 publicSubnet1Association,
1045 privateSubnet1Association,
1046 };
1047 }),
1048 );
1049
1050 // Verify Web Security Group
1051 expect(_stack.webSecurityGroup.groupId).toMatch(/^sg-/);
1052 expect(_stack.webSecurityGroup.vpcId).toEqual(_stack.myVpc.vpcId);
1053 expect(_stack.webSecurityGroup.ingressRules).toHaveLength(2);
1054 expect(_stack.webSecurityGroup.egressRules).toHaveLength(1);
1055
1056 // Verify DB Security Group references Web Security Group
1057 expect(_stack.dbSecurityGroup.groupId).toMatch(/^sg-/);
1058 expect(_stack.dbSecurityGroup.ingressRules).toHaveLength(1);
1059 expect(
1060 _stack.dbSecurityGroup.ingressRules?.[0]?.referencedGroupId,
1061 ).toEqual(_stack.webSecurityGroup.groupId);
1062 }
1063
1064 // =========================================================================
1065 // STAGE 10: Scale Down - Remove NAT Gateway and Security Groups
1066 // User scales down to basic VPC for cost savings
1067 // Tests: NAT Gateway delete with state waiting, Security Group delete
1068 // =========================================================================
1069 yield* Effect.log("=== Stage 10: Scale Down to Basic VPC ===");
1070 {
1071 const _stack = yield* stack.deploy(
1072 Effect.gen(function* () {
1073 const myVpc = yield* Vpc("MyVpc", {
1074 cidrBlock: "10.0.0.0/16",
1075 enableDnsSupport: true,
1076 enableDnsHostnames: true,
1077 tags: {
1078 Name: "production-vpc",
1079 Environment: "production",
1080 },
1081 });
1082
1083 const internetGateway = yield* InternetGateway("InternetGateway", {
1084 vpcId: myVpc.vpcId,
1085 tags: { Name: "production-igw" },
1086 });
1087
1088 const publicSubnet1 = yield* Subnet("PublicSubnet1", {
1089 vpcId: myVpc.vpcId,
1090 cidrBlock: "10.0.1.0/24",
1091 availabilityZone: az1,
1092 mapPublicIpOnLaunch: true,
1093 tags: { Name: "public-1a", Tier: "public" },
1094 });
1095
1096 const privateSubnet1 = yield* Subnet("PrivateSubnet1", {
1097 vpcId: myVpc.vpcId,
1098 cidrBlock: "10.0.10.0/24",
1099 availabilityZone: az1,
1100 tags: { Name: "private-1a", Tier: "private" },
1101 });
1102
1103 const publicRouteTable = yield* RouteTable("PublicRouteTable", {
1104 vpcId: myVpc.vpcId,
1105 tags: { Name: "public-rt" },
1106 });
1107
1108 const privateRouteTable = yield* RouteTable("PrivateRouteTable", {
1109 vpcId: myVpc.vpcId,
1110 tags: { Name: "private-rt" },
1111 });
1112
1113 const internetRoute = yield* Route("InternetRoute", {
1114 routeTableId: publicRouteTable.routeTableId,
1115 destinationCidrBlock: "0.0.0.0/0",
1116 gatewayId: internetGateway.internetGatewayId,
1117 });
1118
1119 const publicSubnet1Association = yield* RouteTableAssociation(
1120 "PublicSubnet1Association",
1121 {
1122 routeTableId: publicRouteTable.routeTableId,
1123 subnetId: publicSubnet1.subnetId,
1124 },
1125 );
1126
1127 const privateSubnet1Association = yield* RouteTableAssociation(
1128 "PrivateSubnet1Association",
1129 {
1130 routeTableId: privateRouteTable.routeTableId,
1131 subnetId: privateSubnet1.subnetId,
1132 },
1133 );
1134
1135 // Note: NAT Gateway, EIP, NatRoute, and Security Groups are NOT included
1136 // They will be deleted
1137
1138 return {
1139 myVpc,
1140 internetGateway,
1141 publicSubnet1,
1142 privateSubnet1,
1143 publicRouteTable,
1144 privateRouteTable,
1145 internetRoute,
1146 publicSubnet1Association,
1147 privateSubnet1Association,
1148 };
1149 }),
1150 );
1151
1152 // Verify NAT Gateway is deleted
1153 const natGwResult = yield* ec2
1154 .describeNatGateways({
1155 Filter: [{ Name: "vpc-id", Values: [_stack.myVpc.vpcId] }],
1156 })
1157 .pipe(
1158 Effect.map((r) =>
1159 r.NatGateways?.filter((gw) => gw.State !== "deleted"),
1160 ),
1161 );
1162 expect(natGwResult).toHaveLength(0);
1163
1164 // Verify Security Groups are deleted (only default should remain)
1165 const sgResult = yield* EC2.describeSecurityGroups({
1166 Filters: [{ Name: "vpc-id", Values: [_stack.myVpc.vpcId] }],
1167 });
1168 expect(sgResult.SecurityGroups).toHaveLength(1); // Only default SG
1169 expect(sgResult.SecurityGroups?.[0]?.GroupName).toEqual("default");
1170 }
1171
1172 // =========================================================================
1173 // STAGE 11: Final Cleanup
1174 // Destroy everything and verify
1175 // =========================================================================
1176 yield* Effect.log("=== Stage 11: Final Cleanup ===");
1177 const vpcResult = yield* EC2.describeVpcs({
1178 Filters: [{ Name: "tag:Name", Values: ["production-vpc"] }],
1179 });
1180 const capturedVpcId = vpcResult.Vpcs?.[0]?.VpcId;
1181
1182 yield* stack.destroy();
1183
1184 // Verify VPC is deleted
1185 if (capturedVpcId) {
1186 yield* EC2.describeVpcs({ VpcIds: [capturedVpcId] }).pipe(
1187 Effect.flatMap(() => Effect.fail(new Error("VPC still exists"))),
1188 Effect.catchTag("InvalidVpcID.NotFound", () => Effect.void),
1189 );
1190 }
1191
1192 yield* Effect.log("=== All stages completed successfully! ===");
1193 }).pipe(logLevel),
1194 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 1_000_000 },
1195);
1196
1197test.provider.skip(
1198 "Comprehensive VPC with all components",
1199 (stack) =>
1200 Effect.gen(function* () {
1201 yield* stack.destroy();
1202
1203 // Get available AZs
1204 const azResult = yield* EC2.describeAvailabilityZones({});
1205 const availableAzs =
1206 azResult.AvailabilityZones?.filter((az) => az.State === "available") ??
1207 [];
1208 const az1 = availableAzs[0]?.ZoneName!;
1209 const az2 = availableAzs[1]?.ZoneName!;
1210
1211 // =========================================================================
1212 // Define all resources for a production-ready VPC
1213 // =========================================================================
1214 const _stack = yield* stack.deploy(
1215 Effect.gen(function* () {
1216 // VPC with DNS enabled and IPv6 for egress-only IGW
1217 const myVpc = yield* Vpc("MyVpc", {
1218 cidrBlock: "10.0.0.0/16",
1219 enableDnsSupport: true,
1220 enableDnsHostnames: true,
1221 amazonProvidedIpv6CidrBlock: true,
1222 tags: {
1223 Name: "comprehensive-vpc",
1224 Environment: "test",
1225 },
1226 });
1227
1228 // Internet Gateway for public internet access
1229 const internetGateway = yield* InternetGateway("InternetGateway", {
1230 vpcId: myVpc.vpcId,
1231 tags: { Name: "comprehensive-igw" },
1232 });
1233
1234 // Egress-Only Internet Gateway for IPv6 outbound traffic from private subnets
1235 const egressOnlyIgw = yield* EgressOnlyInternetGateway(
1236 "EgressOnlyIgw",
1237 {
1238 vpcId: myVpc.vpcId,
1239 tags: { Name: "comprehensive-eigw" },
1240 },
1241 );
1242
1243 // Public Subnets in two AZs
1244 const publicSubnet1 = yield* Subnet("PublicSubnet1", {
1245 vpcId: myVpc.vpcId,
1246 cidrBlock: "10.0.1.0/24",
1247 availabilityZone: az1,
1248 mapPublicIpOnLaunch: true,
1249 tags: { Name: "public-1a", Tier: "public" },
1250 });
1251
1252 const publicSubnet2 = yield* Subnet("PublicSubnet2", {
1253 vpcId: myVpc.vpcId,
1254 cidrBlock: "10.0.2.0/24",
1255 availabilityZone: az2,
1256 mapPublicIpOnLaunch: true,
1257 tags: { Name: "public-1b", Tier: "public" },
1258 });
1259
1260 // Private Subnets in two AZs
1261 const privateSubnet1 = yield* Subnet("PrivateSubnet1", {
1262 vpcId: myVpc.vpcId,
1263 cidrBlock: "10.0.10.0/24",
1264 availabilityZone: az1,
1265 tags: { Name: "private-1a", Tier: "private" },
1266 });
1267
1268 const privateSubnet2 = yield* Subnet("PrivateSubnet2", {
1269 vpcId: myVpc.vpcId,
1270 cidrBlock: "10.0.11.0/24",
1271 availabilityZone: az2,
1272 tags: { Name: "private-1b", Tier: "private" },
1273 });
1274
1275 // Route Tables
1276 const publicRouteTable = yield* RouteTable("PublicRouteTable", {
1277 vpcId: myVpc.vpcId,
1278 tags: { Name: "public-rt" },
1279 });
1280
1281 const privateRouteTable1 = yield* RouteTable("PrivateRouteTable1", {
1282 vpcId: myVpc.vpcId,
1283 tags: { Name: "private-rt-1" },
1284 });
1285
1286 const privateRouteTable2 = yield* RouteTable("PrivateRouteTable2", {
1287 vpcId: myVpc.vpcId,
1288 tags: { Name: "private-rt-2" },
1289 });
1290
1291 // Internet route for public subnets
1292 const internetRoute = yield* Route("InternetRoute", {
1293 routeTableId: publicRouteTable.routeTableId,
1294 destinationCidrBlock: "0.0.0.0/0",
1295 gatewayId: internetGateway.internetGatewayId,
1296 });
1297
1298 // NAT Gateway with EIP for AZ1
1299 const natEip1 = yield* EIP("NatEip1", {
1300 tags: { Name: "nat-eip-1" },
1301 });
1302
1303 const natGateway1 = yield* NatGateway("NatGateway1", {
1304 subnetId: publicSubnet1.subnetId,
1305 allocationId: natEip1.allocationId,
1306 tags: { Name: "nat-gateway-1" },
1307 });
1308
1309 // NAT Gateway with EIP for AZ2
1310 const natEip2 = yield* EIP("NatEip2", {
1311 tags: { Name: "nat-eip-2" },
1312 });
1313
1314 const natGateway2 = yield* NatGateway("NatGateway2", {
1315 subnetId: publicSubnet2.subnetId,
1316 allocationId: natEip2.allocationId,
1317 tags: { Name: "nat-gateway-2" },
1318 });
1319
1320 // NAT routes for private subnets (each AZ routes to its own NAT)
1321 const natRoute1 = yield* Route("NatRoute1", {
1322 routeTableId: privateRouteTable1.routeTableId,
1323 destinationCidrBlock: "0.0.0.0/0",
1324 natGatewayId: natGateway1.natGatewayId,
1325 });
1326
1327 const natRoute2 = yield* Route("NatRoute2", {
1328 routeTableId: privateRouteTable2.routeTableId,
1329 destinationCidrBlock: "0.0.0.0/0",
1330 natGatewayId: natGateway2.natGatewayId,
1331 });
1332
1333 // Route Table Associations
1334 const publicSubnet1Association = yield* RouteTableAssociation(
1335 "PublicSubnet1Association",
1336 {
1337 routeTableId: publicRouteTable.routeTableId,
1338 subnetId: publicSubnet1.subnetId,
1339 },
1340 );
1341
1342 const publicSubnet2Association = yield* RouteTableAssociation(
1343 "PublicSubnet2Association",
1344 {
1345 routeTableId: publicRouteTable.routeTableId,
1346 subnetId: publicSubnet2.subnetId,
1347 },
1348 );
1349
1350 const privateSubnet1Association = yield* RouteTableAssociation(
1351 "PrivateSubnet1Association",
1352 {
1353 routeTableId: privateRouteTable1.routeTableId,
1354 subnetId: privateSubnet1.subnetId,
1355 },
1356 );
1357
1358 const privateSubnet2Association = yield* RouteTableAssociation(
1359 "PrivateSubnet2Association",
1360 {
1361 routeTableId: privateRouteTable2.routeTableId,
1362 subnetId: privateSubnet2.subnetId,
1363 },
1364 );
1365
1366 // Security Groups
1367 const webSecurityGroup = yield* SecurityGroup("WebSecurityGroup", {
1368 vpcId: myVpc.vpcId,
1369 description: "Web tier security group",
1370 ingress: [
1371 {
1372 ipProtocol: "tcp",
1373 fromPort: 80,
1374 toPort: 80,
1375 cidrIpv4: "0.0.0.0/0",
1376 description: "Allow HTTP",
1377 },
1378 {
1379 ipProtocol: "tcp",
1380 fromPort: 443,
1381 toPort: 443,
1382 cidrIpv4: "0.0.0.0/0",
1383 description: "Allow HTTPS",
1384 },
1385 ],
1386 egress: [
1387 {
1388 ipProtocol: "-1",
1389 cidrIpv4: "0.0.0.0/0",
1390 description: "Allow all outbound",
1391 },
1392 ],
1393 tags: { Name: "web-sg" },
1394 });
1395
1396 const appSecurityGroup = yield* SecurityGroup("AppSecurityGroup", {
1397 vpcId: myVpc.vpcId,
1398 description: "Application tier security group",
1399 ingress: [
1400 {
1401 ipProtocol: "tcp",
1402 fromPort: 8080,
1403 toPort: 8080,
1404 referencedGroupId: webSecurityGroup.groupId,
1405 description: "Allow from web tier",
1406 },
1407 ],
1408 egress: [
1409 {
1410 ipProtocol: "-1",
1411 cidrIpv4: "0.0.0.0/0",
1412 description: "Allow all outbound",
1413 },
1414 ],
1415 tags: { Name: "app-sg" },
1416 });
1417
1418 const dbSecurityGroup = yield* SecurityGroup("DbSecurityGroup", {
1419 vpcId: myVpc.vpcId,
1420 description: "Database tier security group",
1421 ingress: [
1422 {
1423 ipProtocol: "tcp",
1424 fromPort: 5432,
1425 toPort: 5432,
1426 referencedGroupId: appSecurityGroup.groupId,
1427 description: "Allow PostgreSQL from app tier",
1428 },
1429 ],
1430 egress: [
1431 {
1432 ipProtocol: "-1",
1433 cidrIpv4: "0.0.0.0/0",
1434 description: "Allow all outbound",
1435 },
1436 ],
1437 tags: { Name: "db-sg" },
1438 });
1439
1440 // Network ACL for private subnets with custom rules
1441 const privateNetworkAcl = yield* NetworkAcl("PrivateNetworkAcl", {
1442 vpcId: myVpc.vpcId,
1443 tags: { Name: "private-nacl" },
1444 });
1445
1446 // Network ACL Entries (rules)
1447 // Allow inbound traffic from VPC CIDR
1448 const privateNaclIngressVpc = yield* NetworkAclEntry(
1449 "PrivateNaclIngressVpc",
1450 {
1451 networkAclId: privateNetworkAcl.networkAclId,
1452 ruleNumber: 100,
1453 protocol: "-1", // All protocols
1454 ruleAction: "allow",
1455 egress: false,
1456 cidrBlock: "10.0.0.0/16",
1457 },
1458 );
1459
1460 // Allow inbound ephemeral ports (for NAT return traffic)
1461 const privateNaclIngressEphemeral = yield* NetworkAclEntry(
1462 "PrivateNaclIngressEphemeral",
1463 {
1464 networkAclId: privateNetworkAcl.networkAclId,
1465 ruleNumber: 200,
1466 protocol: "6", // TCP
1467 ruleAction: "allow",
1468 egress: false,
1469 cidrBlock: "0.0.0.0/0",
1470 portRange: { from: 1024, to: 65535 },
1471 },
1472 );
1473
1474 // Allow all outbound traffic
1475 const privateNaclEgressAll = yield* NetworkAclEntry(
1476 "PrivateNaclEgressAll",
1477 {
1478 networkAclId: privateNetworkAcl.networkAclId,
1479 ruleNumber: 100,
1480 protocol: "-1", // All protocols
1481 ruleAction: "allow",
1482 egress: true,
1483 cidrBlock: "0.0.0.0/0",
1484 },
1485 );
1486
1487 // Network ACL Associations - associate private subnets with the custom NACL
1488 const privateSubnet1NaclAssoc = yield* NetworkAclAssociation(
1489 "PrivateSubnet1NaclAssoc",
1490 {
1491 networkAclId: privateNetworkAcl.networkAclId,
1492 subnetId: privateSubnet1.subnetId,
1493 },
1494 );
1495
1496 const privateSubnet2NaclAssoc = yield* NetworkAclAssociation(
1497 "PrivateSubnet2NaclAssoc",
1498 {
1499 networkAclId: privateNetworkAcl.networkAclId,
1500 subnetId: privateSubnet2.subnetId,
1501 },
1502 );
1503
1504 // VPC Gateway Endpoint for S3 (reduces NAT costs and improves latency)
1505 const s3Endpoint = yield* VpcEndpoint("S3Endpoint", {
1506 vpcId: myVpc.vpcId,
1507 serviceName: `com.amazonaws.${
1508 (yield* EC2.describeAvailabilityZones({})).AvailabilityZones?.[0]
1509 ?.RegionName
1510 }.s3`,
1511 vpcEndpointType: "Gateway",
1512 routeTableIds: [
1513 privateRouteTable1.routeTableId,
1514 privateRouteTable2.routeTableId,
1515 ],
1516 tags: { Name: "s3-endpoint" },
1517 });
1518
1519 return {
1520 myVpc,
1521 internetGateway,
1522 egressOnlyIgw,
1523 publicSubnet1,
1524 publicSubnet2,
1525 privateSubnet1,
1526 privateSubnet2,
1527 publicRouteTable,
1528 privateRouteTable1,
1529 privateRouteTable2,
1530 internetRoute,
1531 natEip1,
1532 natEip2,
1533 natGateway1,
1534 natGateway2,
1535 natRoute1,
1536 natRoute2,
1537 publicSubnet1Association,
1538 publicSubnet2Association,
1539 privateSubnet1Association,
1540 privateSubnet2Association,
1541 webSecurityGroup,
1542 appSecurityGroup,
1543 dbSecurityGroup,
1544 privateNetworkAcl,
1545 privateNaclIngressVpc,
1546 privateNaclIngressEphemeral,
1547 privateNaclEgressAll,
1548 privateSubnet1NaclAssoc,
1549 privateSubnet2NaclAssoc,
1550 s3Endpoint,
1551 };
1552 }),
1553 );
1554
1555 // =========================================================================
1556 // Verify VPC
1557 // =========================================================================
1558 expect(_stack.myVpc.vpcId).toMatch(/^vpc-/);
1559 expect(_stack.myVpc.cidrBlock).toEqual("10.0.0.0/16");
1560 expect(_stack.myVpc.state).toEqual("available");
1561
1562 const vpcResult = yield* EC2.describeVpcs({
1563 VpcIds: [_stack.myVpc.vpcId],
1564 });
1565 expect(vpcResult.Vpcs?.[0]?.CidrBlock).toEqual("10.0.0.0/16");
1566 // EnableDnsSupport and EnableDnsHostnames are not present in the describeVpcs output.
1567 // Instead, use describeVpcAttribute for these:
1568 const dnsSupport = yield* EC2.describeVpcAttribute({
1569 VpcId: _stack.myVpc.vpcId,
1570 Attribute: "enableDnsSupport",
1571 });
1572 expect(dnsSupport.EnableDnsSupport?.Value).toBeTruthy();
1573
1574 const dnsHostnames = yield* EC2.describeVpcAttribute({
1575 VpcId: _stack.myVpc.vpcId,
1576 Attribute: "enableDnsHostnames",
1577 });
1578 expect(dnsHostnames.EnableDnsHostnames?.Value).toBeTruthy();
1579
1580 // Verify VPC has IPv6 CIDR block
1581 expect(_stack.myVpc.ipv6CidrBlockAssociationSet).toBeDefined();
1582 expect(_stack.myVpc.ipv6CidrBlockAssociationSet?.length).toBeGreaterThan(
1583 0,
1584 );
1585
1586 // =========================================================================
1587 // Verify Internet Gateway
1588 // =========================================================================
1589 expect(_stack.internetGateway.internetGatewayId).toMatch(/^igw-/);
1590 expect(_stack.internetGateway.vpcId).toEqual(_stack.myVpc.vpcId);
1591
1592 // =========================================================================
1593 // Verify Egress-Only Internet Gateway
1594 // =========================================================================
1595 expect(_stack.egressOnlyIgw.egressOnlyInternetGatewayId).toMatch(
1596 /^eigw-/,
1597 );
1598 expect(_stack.egressOnlyIgw.attachments).toBeDefined();
1599 expect(_stack.egressOnlyIgw.attachments?.[0]?.vpcId).toEqual(
1600 _stack.myVpc.vpcId,
1601 );
1602
1603 // =========================================================================
1604 // Verify Subnets
1605 // =========================================================================
1606 expect(_stack.publicSubnet1.subnetId).toMatch(/^subnet-/);
1607 expect(_stack.publicSubnet1.availabilityZone).toEqual(az1);
1608 expect(_stack.publicSubnet1.mapPublicIpOnLaunch).toEqual(true);
1609
1610 expect(_stack.publicSubnet2.subnetId).toMatch(/^subnet-/);
1611 expect(_stack.publicSubnet2.availabilityZone).toEqual(az2);
1612 expect(_stack.publicSubnet2.mapPublicIpOnLaunch).toEqual(true);
1613
1614 expect(_stack.privateSubnet1.subnetId).toMatch(/^subnet-/);
1615 expect(_stack.privateSubnet1.availabilityZone).toEqual(az1);
1616 expect(_stack.privateSubnet1.mapPublicIpOnLaunch).toBeFalsy();
1617
1618 expect(_stack.privateSubnet2.subnetId).toMatch(/^subnet-/);
1619 expect(_stack.privateSubnet2.availabilityZone).toEqual(az2);
1620 expect(_stack.privateSubnet2.mapPublicIpOnLaunch).toBeFalsy();
1621
1622 // Verify 4 subnets total
1623 const subnetsResult = yield* EC2.describeSubnets({
1624 Filters: [{ Name: "vpc-id", Values: [_stack.myVpc.vpcId] }],
1625 });
1626 expect(subnetsResult.Subnets).toHaveLength(4);
1627
1628 // =========================================================================
1629 // Verify NAT Gateways and EIPs
1630 // =========================================================================
1631 expect(_stack.natEip1.allocationId).toMatch(/^eipalloc-/);
1632 expect(_stack.natEip1.publicIp).toBeDefined();
1633
1634 expect(_stack.natEip2.allocationId).toMatch(/^eipalloc-/);
1635 expect(_stack.natEip2.publicIp).toBeDefined();
1636
1637 expect(_stack.natGateway1.natGatewayId).toMatch(/^nat-/);
1638 expect(_stack.natGateway1.state).toEqual("available");
1639 expect(_stack.natGateway1.publicIp).toEqual(_stack.natEip1.publicIp);
1640 expect(_stack.natGateway1.subnetId).toEqual(
1641 _stack.publicSubnet1.subnetId,
1642 );
1643
1644 expect(_stack.natGateway2.natGatewayId).toMatch(/^nat-/);
1645 expect(_stack.natGateway2.state).toEqual("available");
1646 expect(_stack.natGateway2.publicIp).toEqual(_stack.natEip2.publicIp);
1647 expect(_stack.natGateway2.subnetId).toEqual(
1648 _stack.publicSubnet2.subnetId,
1649 );
1650
1651 // =========================================================================
1652 // Verify Routes
1653 // =========================================================================
1654 // Internet route to IGW
1655 expect(_stack.internetRoute.state).toEqual("active");
1656 expect(_stack.internetRoute.gatewayId).toEqual(
1657 _stack.internetGateway.internetGatewayId,
1658 );
1659
1660 // NAT routes
1661 expect(_stack.natRoute1.state).toEqual("active");
1662 expect(_stack.natRoute1.natGatewayId).toEqual(
1663 _stack.natGateway1.natGatewayId,
1664 );
1665
1666 expect(_stack.natRoute2.state).toEqual("active");
1667 expect(_stack.natRoute2.natGatewayId).toEqual(
1668 _stack.natGateway2.natGatewayId,
1669 );
1670
1671 // Verify public route table has internet route
1672 const publicRtResult = yield* EC2.describeRouteTables({
1673 RouteTableIds: [_stack.publicRouteTable.routeTableId],
1674 });
1675 const publicRoutes = publicRtResult.RouteTables?.[0]?.Routes ?? [];
1676 const publicInternetRoute = publicRoutes.find(
1677 (r) => r.DestinationCidrBlock === "0.0.0.0/0",
1678 );
1679 expect(publicInternetRoute?.GatewayId).toEqual(
1680 _stack.internetGateway.internetGatewayId,
1681 );
1682
1683 // Verify private route tables have NAT routes
1684 const private1RtResult = yield* EC2.describeRouteTables({
1685 RouteTableIds: [_stack.privateRouteTable1.routeTableId],
1686 });
1687 const private1Routes = private1RtResult.RouteTables?.[0]?.Routes ?? [];
1688 const private1NatRoute = private1Routes.find(
1689 (r) => r.DestinationCidrBlock === "0.0.0.0/0",
1690 );
1691 expect(private1NatRoute?.NatGatewayId).toEqual(
1692 _stack.natGateway1.natGatewayId,
1693 );
1694
1695 const private2RtResult = yield* EC2.describeRouteTables({
1696 RouteTableIds: [_stack.privateRouteTable2.routeTableId],
1697 });
1698 const private2Routes = private2RtResult.RouteTables?.[0]?.Routes ?? [];
1699 const private2NatRoute = private2Routes.find(
1700 (r) => r.DestinationCidrBlock === "0.0.0.0/0",
1701 );
1702 expect(private2NatRoute?.NatGatewayId).toEqual(
1703 _stack.natGateway2.natGatewayId,
1704 );
1705
1706 // =========================================================================
1707 // Verify Route Table Associations
1708 // =========================================================================
1709 expect(_stack.publicSubnet1Association.associationId).toMatch(
1710 /^rtbassoc-/,
1711 );
1712 expect(_stack.publicSubnet2Association.associationId).toMatch(
1713 /^rtbassoc-/,
1714 );
1715 expect(_stack.privateSubnet1Association.associationId).toMatch(
1716 /^rtbassoc-/,
1717 );
1718 expect(_stack.privateSubnet2Association.associationId).toMatch(
1719 /^rtbassoc-/,
1720 );
1721
1722 // Both public subnets share the same route table
1723 expect(_stack.publicSubnet1Association.routeTableId).toEqual(
1724 _stack.publicSubnet2Association.routeTableId,
1725 );
1726
1727 // Private subnets have their own route tables (for HA NAT)
1728 expect(_stack.privateSubnet1Association.routeTableId).not.toEqual(
1729 _stack.privateSubnet2Association.routeTableId,
1730 );
1731
1732 // =========================================================================
1733 // Verify Security Groups
1734 // =========================================================================
1735 expect(_stack.webSecurityGroup.groupId).toMatch(/^sg-/);
1736 expect(_stack.webSecurityGroup.vpcId).toEqual(_stack.myVpc.vpcId);
1737 expect(_stack.webSecurityGroup.ingressRules).toHaveLength(2);
1738 // TODO(sam): why is it 2 when we only have 1? is it a default or egress only ingress?
1739 expect(_stack.webSecurityGroup.egressRules).toHaveLength(2);
1740
1741 expect(_stack.appSecurityGroup.groupId).toMatch(/^sg-/);
1742 expect(_stack.appSecurityGroup.vpcId).toEqual(_stack.myVpc.vpcId);
1743 expect(_stack.appSecurityGroup.ingressRules).toHaveLength(1);
1744 expect(
1745 _stack.appSecurityGroup.ingressRules?.[0]?.referencedGroupId,
1746 ).toEqual(_stack.webSecurityGroup.groupId);
1747
1748 expect(_stack.dbSecurityGroup.groupId).toMatch(/^sg-/);
1749 expect(_stack.dbSecurityGroup.vpcId).toEqual(_stack.myVpc.vpcId);
1750 expect(_stack.dbSecurityGroup.ingressRules).toHaveLength(1);
1751 expect(
1752 _stack.dbSecurityGroup.ingressRules?.[0]?.referencedGroupId,
1753 ).toEqual(_stack.appSecurityGroup.groupId);
1754
1755 // Verify security groups in AWS
1756 const sgResult = yield* EC2.describeSecurityGroups({
1757 Filters: [{ Name: "vpc-id", Values: [_stack.myVpc.vpcId] }],
1758 });
1759 // 4 security groups: default + web + app + db
1760 expect(sgResult.SecurityGroups).toHaveLength(4);
1761
1762 // =========================================================================
1763 // Verify Network ACL
1764 // =========================================================================
1765 expect(_stack.privateNetworkAcl.networkAclId).toMatch(/^acl-/);
1766 expect(_stack.privateNetworkAcl.vpcId).toEqual(_stack.myVpc.vpcId);
1767 expect(_stack.privateNetworkAcl.isDefault).toEqual(false);
1768
1769 // Verify Network ACL in AWS
1770 const naclResult = yield* EC2.describeNetworkAcls({
1771 NetworkAclIds: [_stack.privateNetworkAcl.networkAclId],
1772 });
1773 expect(naclResult.NetworkAcls).toHaveLength(1);
1774 expect(naclResult.NetworkAcls?.[0]?.VpcId).toEqual(_stack.myVpc.vpcId);
1775
1776 // =========================================================================
1777 // Verify Network ACL Entries
1778 // =========================================================================
1779 expect(_stack.privateNaclIngressVpc.networkAclId).toEqual(
1780 _stack.privateNetworkAcl.networkAclId,
1781 );
1782 expect(_stack.privateNaclIngressVpc.ruleNumber).toEqual(100);
1783 expect(_stack.privateNaclIngressVpc.egress).toEqual(false);
1784 expect(_stack.privateNaclIngressVpc.ruleAction).toEqual("allow");
1785
1786 expect(_stack.privateNaclIngressEphemeral.ruleNumber).toEqual(200);
1787 expect(_stack.privateNaclIngressEphemeral.portRange).toEqual({
1788 from: 1024,
1789 to: 65535,
1790 });
1791
1792 expect(_stack.privateNaclEgressAll.egress).toEqual(true);
1793 expect(_stack.privateNaclEgressAll.ruleNumber).toEqual(100);
1794
1795 // =========================================================================
1796 // Verify Network ACL Associations
1797 // =========================================================================
1798 expect(_stack.privateSubnet1NaclAssoc.associationId).toMatch(
1799 /^aclassoc-/,
1800 );
1801 expect(_stack.privateSubnet1NaclAssoc.networkAclId).toEqual(
1802 _stack.privateNetworkAcl.networkAclId,
1803 );
1804 expect(_stack.privateSubnet1NaclAssoc.subnetId).toEqual(
1805 _stack.privateSubnet1.subnetId,
1806 );
1807
1808 expect(_stack.privateSubnet2NaclAssoc.associationId).toMatch(
1809 /^aclassoc-/,
1810 );
1811 expect(_stack.privateSubnet2NaclAssoc.subnetId).toEqual(
1812 _stack.privateSubnet2.subnetId,
1813 );
1814
1815 // =========================================================================
1816 // Verify VPC Endpoint for S3
1817 // =========================================================================
1818 expect(_stack.s3Endpoint.vpcEndpointId).toMatch(/^vpce-/);
1819 expect(_stack.s3Endpoint.vpcEndpointType).toEqual("Gateway");
1820 expect(_stack.s3Endpoint.vpcId).toEqual(_stack.myVpc.vpcId);
1821 expect(_stack.s3Endpoint.state).toEqual("available");
1822 expect(_stack.s3Endpoint.routeTableIds).toContain(
1823 _stack.privateRouteTable1.routeTableId,
1824 );
1825 expect(_stack.s3Endpoint.routeTableIds).toContain(
1826 _stack.privateRouteTable2.routeTableId,
1827 );
1828
1829 // Verify VPC Endpoint in AWS
1830 const vpceResult = yield* EC2.describeVpcEndpoints({
1831 VpcEndpointIds: [_stack.s3Endpoint.vpcEndpointId],
1832 });
1833 expect(vpceResult.VpcEndpoints).toHaveLength(1);
1834 expect(vpceResult.VpcEndpoints?.[0]?.VpcEndpointType).toEqual("Gateway");
1835
1836 // =========================================================================
1837 // Verify Tags
1838 // =========================================================================
1839 yield* assertVpcTags(_stack.myVpc.vpcId, {
1840 Name: "comprehensive-vpc",
1841 Environment: "test",
1842 });
1843
1844 // =========================================================================
1845 // Idempotency check - apply again and verify no changes
1846 // =========================================================================
1847 yield* Effect.log("=== Idempotency Check: Re-applying stack ===");
1848 const stack2 = yield* stack.deploy(
1849 Effect.gen(function* () {
1850 // VPC with DNS enabled and IPv6 for egress-only IGW
1851 const myVpc = yield* Vpc("MyVpc", {
1852 cidrBlock: "10.0.0.0/16",
1853 enableDnsSupport: true,
1854 enableDnsHostnames: true,
1855 amazonProvidedIpv6CidrBlock: true,
1856 tags: {
1857 Name: "comprehensive-vpc",
1858 Environment: "test",
1859 },
1860 });
1861
1862 // Internet Gateway for public internet access
1863 const internetGateway = yield* InternetGateway("InternetGateway", {
1864 vpcId: myVpc.vpcId,
1865 tags: { Name: "comprehensive-igw" },
1866 });
1867
1868 // Egress-Only Internet Gateway for IPv6 outbound traffic from private subnets
1869 const egressOnlyIgw = yield* EgressOnlyInternetGateway(
1870 "EgressOnlyIgw",
1871 {
1872 vpcId: myVpc.vpcId,
1873 tags: { Name: "comprehensive-eigw" },
1874 },
1875 );
1876
1877 // Public Subnets in two AZs
1878 const publicSubnet1 = yield* Subnet("PublicSubnet1", {
1879 vpcId: myVpc.vpcId,
1880 cidrBlock: "10.0.1.0/24",
1881 availabilityZone: az1,
1882 mapPublicIpOnLaunch: true,
1883 tags: { Name: "public-1a", Tier: "public" },
1884 });
1885
1886 const publicSubnet2 = yield* Subnet("PublicSubnet2", {
1887 vpcId: myVpc.vpcId,
1888 cidrBlock: "10.0.2.0/24",
1889 availabilityZone: az2,
1890 mapPublicIpOnLaunch: true,
1891 tags: { Name: "public-1b", Tier: "public" },
1892 });
1893
1894 // Private Subnets in two AZs
1895 const privateSubnet1 = yield* Subnet("PrivateSubnet1", {
1896 vpcId: myVpc.vpcId,
1897 cidrBlock: "10.0.10.0/24",
1898 availabilityZone: az1,
1899 tags: { Name: "private-1a", Tier: "private" },
1900 });
1901
1902 const privateSubnet2 = yield* Subnet("PrivateSubnet2", {
1903 vpcId: myVpc.vpcId,
1904 cidrBlock: "10.0.11.0/24",
1905 availabilityZone: az2,
1906 tags: { Name: "private-1b", Tier: "private" },
1907 });
1908
1909 // Route Tables
1910 const publicRouteTable = yield* RouteTable("PublicRouteTable", {
1911 vpcId: myVpc.vpcId,
1912 tags: { Name: "public-rt" },
1913 });
1914
1915 const privateRouteTable1 = yield* RouteTable("PrivateRouteTable1", {
1916 vpcId: myVpc.vpcId,
1917 tags: { Name: "private-rt-1" },
1918 });
1919
1920 const privateRouteTable2 = yield* RouteTable("PrivateRouteTable2", {
1921 vpcId: myVpc.vpcId,
1922 tags: { Name: "private-rt-2" },
1923 });
1924
1925 // Internet route for public subnets
1926 const internetRoute = yield* Route("InternetRoute", {
1927 routeTableId: publicRouteTable.routeTableId,
1928 destinationCidrBlock: "0.0.0.0/0",
1929 gatewayId: internetGateway.internetGatewayId,
1930 });
1931
1932 // NAT Gateway with EIP for AZ1
1933 const natEip1 = yield* EIP("NatEip1", {
1934 tags: { Name: "nat-eip-1" },
1935 });
1936
1937 const natGateway1 = yield* NatGateway("NatGateway1", {
1938 subnetId: publicSubnet1.subnetId,
1939 allocationId: natEip1.allocationId,
1940 tags: { Name: "nat-gateway-1" },
1941 });
1942
1943 // NAT Gateway with EIP for AZ2
1944 const natEip2 = yield* EIP("NatEip2", {
1945 tags: { Name: "nat-eip-2" },
1946 });
1947
1948 const natGateway2 = yield* NatGateway("NatGateway2", {
1949 subnetId: publicSubnet2.subnetId,
1950 allocationId: natEip2.allocationId,
1951 tags: { Name: "nat-gateway-2" },
1952 });
1953
1954 // NAT routes for private subnets (each AZ routes to its own NAT)
1955 const natRoute1 = yield* Route("NatRoute1", {
1956 routeTableId: privateRouteTable1.routeTableId,
1957 destinationCidrBlock: "0.0.0.0/0",
1958 natGatewayId: natGateway1.natGatewayId,
1959 });
1960
1961 const natRoute2 = yield* Route("NatRoute2", {
1962 routeTableId: privateRouteTable2.routeTableId,
1963 destinationCidrBlock: "0.0.0.0/0",
1964 natGatewayId: natGateway2.natGatewayId,
1965 });
1966
1967 // Route Table Associations
1968 const publicSubnet1Association = yield* RouteTableAssociation(
1969 "PublicSubnet1Association",
1970 {
1971 routeTableId: publicRouteTable.routeTableId,
1972 subnetId: publicSubnet1.subnetId,
1973 },
1974 );
1975
1976 const publicSubnet2Association = yield* RouteTableAssociation(
1977 "PublicSubnet2Association",
1978 {
1979 routeTableId: publicRouteTable.routeTableId,
1980 subnetId: publicSubnet2.subnetId,
1981 },
1982 );
1983
1984 const privateSubnet1Association = yield* RouteTableAssociation(
1985 "PrivateSubnet1Association",
1986 {
1987 routeTableId: privateRouteTable1.routeTableId,
1988 subnetId: privateSubnet1.subnetId,
1989 },
1990 );
1991
1992 const privateSubnet2Association = yield* RouteTableAssociation(
1993 "PrivateSubnet2Association",
1994 {
1995 routeTableId: privateRouteTable2.routeTableId,
1996 subnetId: privateSubnet2.subnetId,
1997 },
1998 );
1999
2000 // Security Groups
2001 const webSecurityGroup = yield* SecurityGroup("WebSecurityGroup", {
2002 vpcId: myVpc.vpcId,
2003 description: "Web tier security group",
2004 ingress: [
2005 {
2006 ipProtocol: "tcp",
2007 fromPort: 80,
2008 toPort: 80,
2009 cidrIpv4: "0.0.0.0/0",
2010 description: "Allow HTTP",
2011 },
2012 {
2013 ipProtocol: "tcp",
2014 fromPort: 443,
2015 toPort: 443,
2016 cidrIpv4: "0.0.0.0/0",
2017 description: "Allow HTTPS",
2018 },
2019 ],
2020 egress: [
2021 {
2022 ipProtocol: "-1",
2023 cidrIpv4: "0.0.0.0/0",
2024 description: "Allow all outbound",
2025 },
2026 ],
2027 tags: { Name: "web-sg" },
2028 });
2029
2030 const appSecurityGroup = yield* SecurityGroup("AppSecurityGroup", {
2031 vpcId: myVpc.vpcId,
2032 description: "Application tier security group",
2033 ingress: [
2034 {
2035 ipProtocol: "tcp",
2036 fromPort: 8080,
2037 toPort: 8080,
2038 referencedGroupId: webSecurityGroup.groupId,
2039 description: "Allow from web tier",
2040 },
2041 ],
2042 egress: [
2043 {
2044 ipProtocol: "-1",
2045 cidrIpv4: "0.0.0.0/0",
2046 description: "Allow all outbound",
2047 },
2048 ],
2049 tags: { Name: "app-sg" },
2050 });
2051
2052 const dbSecurityGroup = yield* SecurityGroup("DbSecurityGroup", {
2053 vpcId: myVpc.vpcId,
2054 description: "Database tier security group",
2055 ingress: [
2056 {
2057 ipProtocol: "tcp",
2058 fromPort: 5432,
2059 toPort: 5432,
2060 referencedGroupId: appSecurityGroup.groupId,
2061 description: "Allow PostgreSQL from app tier",
2062 },
2063 ],
2064 egress: [
2065 {
2066 ipProtocol: "-1",
2067 cidrIpv4: "0.0.0.0/0",
2068 description: "Allow all outbound",
2069 },
2070 ],
2071 tags: { Name: "db-sg" },
2072 });
2073
2074 // Network ACL for private subnets with custom rules
2075 const privateNetworkAcl = yield* NetworkAcl("PrivateNetworkAcl", {
2076 vpcId: myVpc.vpcId,
2077 tags: { Name: "private-nacl" },
2078 });
2079
2080 // Network ACL Entries (rules)
2081 // Allow inbound traffic from VPC CIDR
2082 const privateNaclIngressVpc = yield* NetworkAclEntry(
2083 "PrivateNaclIngressVpc",
2084 {
2085 networkAclId: privateNetworkAcl.networkAclId,
2086 ruleNumber: 100,
2087 protocol: "-1", // All protocols
2088 ruleAction: "allow",
2089 egress: false,
2090 cidrBlock: "10.0.0.0/16",
2091 },
2092 );
2093
2094 // Allow inbound ephemeral ports (for NAT return traffic)
2095 const privateNaclIngressEphemeral = yield* NetworkAclEntry(
2096 "PrivateNaclIngressEphemeral",
2097 {
2098 networkAclId: privateNetworkAcl.networkAclId,
2099 ruleNumber: 200,
2100 protocol: "6", // TCP
2101 ruleAction: "allow",
2102 egress: false,
2103 cidrBlock: "0.0.0.0/0",
2104 portRange: { from: 1024, to: 65535 },
2105 },
2106 );
2107
2108 // Allow all outbound traffic
2109 const privateNaclEgressAll = yield* NetworkAclEntry(
2110 "PrivateNaclEgressAll",
2111 {
2112 networkAclId: privateNetworkAcl.networkAclId,
2113 ruleNumber: 100,
2114 protocol: "-1", // All protocols
2115 ruleAction: "allow",
2116 egress: true,
2117 cidrBlock: "0.0.0.0/0",
2118 },
2119 );
2120
2121 // Network ACL Associations - associate private subnets with the custom NACL
2122 const privateSubnet1NaclAssoc = yield* NetworkAclAssociation(
2123 "PrivateSubnet1NaclAssoc",
2124 {
2125 networkAclId: privateNetworkAcl.networkAclId,
2126 subnetId: privateSubnet1.subnetId,
2127 },
2128 );
2129
2130 const privateSubnet2NaclAssoc = yield* NetworkAclAssociation(
2131 "PrivateSubnet2NaclAssoc",
2132 {
2133 networkAclId: privateNetworkAcl.networkAclId,
2134 subnetId: privateSubnet2.subnetId,
2135 },
2136 );
2137
2138 // VPC Gateway Endpoint for S3 (reduces NAT costs and improves latency)
2139 const s3Endpoint = yield* VpcEndpoint("S3Endpoint", {
2140 vpcId: myVpc.vpcId,
2141 serviceName: `com.amazonaws.${
2142 (yield* EC2.describeAvailabilityZones({})).AvailabilityZones?.[0]
2143 ?.RegionName
2144 }.s3`,
2145 vpcEndpointType: "Gateway",
2146 routeTableIds: [
2147 privateRouteTable1.routeTableId,
2148 privateRouteTable2.routeTableId,
2149 ],
2150 tags: { Name: "s3-endpoint" },
2151 });
2152
2153 return {
2154 myVpc,
2155 internetGateway,
2156 egressOnlyIgw,
2157 publicSubnet1,
2158 publicSubnet2,
2159 privateSubnet1,
2160 privateSubnet2,
2161 publicRouteTable,
2162 privateRouteTable1,
2163 privateRouteTable2,
2164 internetRoute,
2165 natEip1,
2166 natEip2,
2167 natGateway1,
2168 natGateway2,
2169 natRoute1,
2170 natRoute2,
2171 publicSubnet1Association,
2172 publicSubnet2Association,
2173 privateSubnet1Association,
2174 privateSubnet2Association,
2175 webSecurityGroup,
2176 appSecurityGroup,
2177 dbSecurityGroup,
2178 privateNetworkAcl,
2179 privateNaclIngressVpc,
2180 privateNaclIngressEphemeral,
2181 privateNaclEgressAll,
2182 privateSubnet1NaclAssoc,
2183 privateSubnet2NaclAssoc,
2184 s3Endpoint,
2185 };
2186 }),
2187 );
2188
2189 // All IDs should remain the same
2190 expect(stack2.myVpc.vpcId).toEqual(_stack.myVpc.vpcId);
2191 expect(stack2.internetGateway.internetGatewayId).toEqual(
2192 _stack.internetGateway.internetGatewayId,
2193 );
2194 expect(stack2.egressOnlyIgw.egressOnlyInternetGatewayId).toEqual(
2195 _stack.egressOnlyIgw.egressOnlyInternetGatewayId,
2196 );
2197 expect(stack2.natGateway1.natGatewayId).toEqual(
2198 _stack.natGateway1.natGatewayId,
2199 );
2200 expect(stack2.natGateway2.natGatewayId).toEqual(
2201 _stack.natGateway2.natGatewayId,
2202 );
2203 expect(stack2.privateNetworkAcl.networkAclId).toEqual(
2204 _stack.privateNetworkAcl.networkAclId,
2205 );
2206 expect(stack2.s3Endpoint.vpcEndpointId).toEqual(
2207 _stack.s3Endpoint.vpcEndpointId,
2208 );
2209
2210 // =========================================================================
2211 // Cleanup
2212 // =========================================================================
2213 yield* Effect.log("=== Cleanup: Destroying all resources ===");
2214 const capturedVpcId = _stack.myVpc.vpcId;
2215
2216 yield* stack.destroy();
2217
2218 // Verify VPC is deleted
2219 yield* EC2.describeVpcs({ VpcIds: [capturedVpcId] }).pipe(
2220 Effect.flatMap(() => Effect.fail(new Error("VPC still exists"))),
2221 Effect.catchTag("InvalidVpcID.NotFound", () => Effect.void),
2222 );
2223
2224 yield* Effect.log(
2225 "=== Comprehensive VPC test completed successfully! ===",
2226 );
2227 }).pipe(logLevel),
2228 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 1_000_000 },
2229);
2230
2231// ============================================================================
2232// Eventually Consistent Check Utilities
2233// ============================================================================
2234
2235class TagsNotPropagated extends Data.TaggedError("TagsNotPropagated")<{
2236 readonly expected: Record<string, string>;
2237 readonly actual: Record<string, string | undefined>;
2238}> {}
2239
2240/**
2241 * Asserts that a VPC has the expected tags, retrying until eventually consistent.
2242 */
2243const assertVpcTags = Effect.fn(function* (
2244 vpcId: string,
2245 expectedTags: Record<string, string>,
2246) {
2247 yield* EC2.describeVpcs({ VpcIds: [vpcId] }).pipe(
2248 Effect.flatMap((result) => {
2249 const tags = result.Vpcs?.[0]?.Tags ?? [];
2250 const actual: Record<string, string | undefined> = {};
2251
2252 for (const key of Object.keys(expectedTags)) {
2253 actual[key] = tags.find((t) => t.Key === key)?.Value;
2254 }
2255
2256 const allMatch = Object.entries(expectedTags).every(
2257 ([key, value]) => actual[key] === value,
2258 );
2259
2260 return allMatch
2261 ? Effect.succeed(result)
2262 : Effect.fail(
2263 new TagsNotPropagated({ expected: expectedTags, actual }),
2264 );
2265 }),
2266 Effect.tapError(Effect.log),
2267 Effect.retry({
2268 while: (e) => e._tag === "TagsNotPropagated",
2269 schedule: Schedule.max([Schedule.fixed(1000), Schedule.recurs(10)]),
2270 }),
2271 );
2272});