1import * as EC2 from "@distilled.cloud/aws/ec2";
2import * as ec2 from "@distilled.cloud/aws/ec2";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import type { ScopedPlanStatusSession } from "../../Report.ts";
8import { isResolved, somePropsAreDifferent } from "../../Diff.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts";
12import type { AccountID } from "../Environment.ts";
13import { AWSEnvironment } from "../Environment.ts";
14import type { Providers } from "../Providers.ts";
15import type { RegionID } from "../Region.ts";
16
17export type VpcId = `vpc-${string}`;
18export const VpcId = <const S extends string>(value: S): S & VpcId =>
19 value as S & VpcId;
20
21export type VpcArn = `arn:aws:ec2:${RegionID}:${AccountID}:vpc/${VpcId}`;
22
23export interface VpcProps {
24 /**
25 * The IPv4 network range for the VPC, in CIDR notation.
26 * Required unless using IPAM.
27 * @example "10.0.0.0/16"
28 */
29 cidrBlock?: string;
30
31 /**
32 * The ID of an IPv4 IPAM pool you want to use for allocating this VPC's CIDR.
33 */
34 ipv4IpamPoolId?: string;
35
36 /**
37 * The netmask length of the IPv4 CIDR you want to allocate to this VPC from an IPAM pool.
38 */
39 ipv4NetmaskLength?: number;
40
41 /**
42 * The ID of an IPv6 IPAM pool which will be used to allocate this VPC an IPv6 CIDR.
43 */
44 ipv6IpamPoolId?: string;
45
46 /**
47 * The netmask length of the IPv6 CIDR you want to allocate to this VPC from an IPAM pool.
48 */
49 ipv6NetmaskLength?: number;
50
51 /**
52 * Requests an Amazon-provided IPv6 CIDR block with a /56 prefix length for the VPC.
53 */
54 ipv6CidrBlock?: string;
55
56 /**
57 * The ID of an IPv6 address pool from which to allocate the IPv6 CIDR block.
58 */
59 ipv6Pool?: string;
60
61 /**
62 * The Availability Zone or Local Zone Group name for the IPv6 CIDR block.
63 */
64 ipv6CidrBlockNetworkBorderGroup?: string;
65
66 /**
67 * The tenancy options for instances launched into the VPC.
68 * @default "default"
69 */
70 instanceTenancy?: EC2.Tenancy;
71
72 /**
73 * Whether DNS resolution is supported for the VPC.
74 * @default true
75 */
76 enableDnsSupport?: boolean;
77
78 /**
79 * Whether instances launched in the VPC get DNS hostnames.
80 * @default true
81 */
82 enableDnsHostnames?: boolean;
83
84 /**
85 * Requests an Amazon-provided IPv6 CIDR block with a /56 prefix length for the VPC.
86 */
87 amazonProvidedIpv6CidrBlock?: boolean;
88
89 /**
90 * Tags to assign to the VPC.
91 * These will be merged with alchemy auto-tags (alchemy::stack, alchemy::stage, alchemy::id).
92 */
93 tags?: Record<string, string>;
94}
95
96export interface Vpc extends Resource<
97 "AWS.EC2.VPC",
98 VpcProps,
99 {
100 /**
101 * The ID of the VPC.
102 */
103 vpcId: VpcId;
104
105 /**
106 * The Amazon Resource Name (ARN) of the VPC.
107 */
108 vpcArn: VpcArn;
109
110 /**
111 * The primary IPv4 CIDR block for the VPC.
112 */
113 cidrBlock: string;
114
115 /**
116 * The ID of the set of DHCP options associated with the VPC.
117 */
118 dhcpOptionsId: string;
119
120 /**
121 * The current state of the VPC.
122 */
123 state: EC2.VpcState;
124
125 /**
126 * Whether the VPC is the default VPC.
127 */
128 isDefault: boolean;
129
130 /**
131 * The ID of the AWS account that owns the VPC.
132 */
133 ownerId?: string;
134
135 /**
136 * Information about the IPv4 CIDR blocks associated with the VPC.
137 */
138 cidrBlockAssociationSet?: Array<{
139 associationId: string;
140 cidrBlock: string;
141 cidrBlockState: {
142 state: EC2.VpcCidrBlockStateCode;
143 statusMessage?: string;
144 };
145 }>;
146
147 /**
148 * Information about the IPv6 CIDR blocks associated with the VPC.
149 */
150 ipv6CidrBlockAssociationSet?: Array<{
151 associationId: string;
152 ipv6CidrBlock: string;
153 ipv6CidrBlockState: {
154 state: EC2.VpcCidrBlockStateCode;
155 statusMessage?: string;
156 };
157 networkBorderGroup?: string;
158 ipv6Pool?: string;
159 }>;
160
161 /**
162 * The tags currently assigned to the VPC, including alchemy auto-tags.
163 */
164 tags?: Record<string, string>;
165 },
166 never,
167 Providers
168> {}
169/**
170 * An Amazon VPC (Virtual Private Cloud) — an isolated virtual network that is
171 * the root of any custom AWS networking topology. Subnets, route tables,
172 * gateways, security groups, and instances are all created inside a VPC.
173 *
174 * Changing the `cidrBlock`, `instanceTenancy`, or an IPAM/IPv6 pool replaces
175 * the VPC.
176 *
177 * ### Creating a VPC
178 * A VPC is defined by a private IPv4 address range (`cidrBlock`). Pick a block
179 * from the RFC 1918 private space (e.g. `10.0.0.0/16`) that is large enough to
180 * subdivide into subnets across your Availability Zones.
181 *
182 * **Example:** Basic VPC
183 * ```typescript
184 * const vpc = yield* AWS.EC2.Vpc("MyVpc", {
185 * cidrBlock: "10.0.0.0/16",
186 * });
187 * ```
188 *
189 * A `/16` gives you 65,536 addresses to carve into subnets — enough headroom
190 * for a multi-AZ, multi-tier network. This is the minimal config every other
191 * networking resource builds on.
192 *
193 * **Example:** Allocating IPv4 from an IPAM pool
194 * ```typescript
195 * const vpc = yield* AWS.EC2.Vpc("MyVpc", {
196 * ipv4IpamPoolId: "ipam-pool-0123456789abcdef0",
197 * ipv4NetmaskLength: 16,
198 * });
199 * ```
200 *
201 * Instead of hard-coding `cidrBlock`, let AWS IPAM hand out a non-overlapping
202 * range of the requested size. Use this when an organization centrally manages
203 * address space to avoid CIDR collisions between accounts.
204 *
205 * ### DNS Resolution
206 * Two independent toggles control DNS behavior inside the VPC. `enableDnsSupport`
207 * lets instances resolve names via the Amazon DNS server; `enableDnsHostnames`
208 * additionally assigns public DNS hostnames to instances with public IPs.
209 *
210 * **Example:** Enable DNS support and hostnames
211 * ```typescript
212 * const vpc = yield* AWS.EC2.Vpc("MyVpc", {
213 * cidrBlock: "10.0.0.0/16",
214 * enableDnsSupport: true,
215 * enableDnsHostnames: true,
216 * });
217 * ```
218 *
219 * Enable both when instances need public DNS names or when you rely on private
220 * hosted zones and VPC endpoints, which require DNS resolution to function.
221 *
222 * ### Instance Tenancy
223 * **Example:** Dedicated tenancy
224 * ```typescript
225 * const vpc = yield* AWS.EC2.Vpc("MyVpc", {
226 * cidrBlock: "10.0.0.0/16",
227 * instanceTenancy: "dedicated",
228 * });
229 * ```
230 *
231 * Forcing `"dedicated"` tenancy ensures every instance launched in the VPC runs
232 * on single-tenant hardware — required by some compliance regimes, but more
233 * expensive than the `"default"` shared tenancy. This property cannot be
234 * changed after creation without replacing the VPC.
235 *
236 * ### IPv6 Addressing
237 * A VPC can carry an IPv6 `/56` block alongside its IPv4 range. The block can
238 * come from Amazon's pool, an IPAM pool, or your own BYOIP pool
239 * (`ipv6CidrBlock` + `ipv6Pool`, optionally scoped to a
240 * `ipv6CidrBlockNetworkBorderGroup`).
241 *
242 * **Example:** Amazon-provided IPv6 block
243 * ```typescript
244 * const vpc = yield* AWS.EC2.Vpc("MyVpc", {
245 * cidrBlock: "10.0.0.0/16",
246 * amazonProvidedIpv6CidrBlock: true,
247 * });
248 * ```
249 *
250 * Requests an Amazon-assigned IPv6 `/56`, the simplest way to make a VPC
251 * dual-stack. Pair it with IPv6-enabled subnets and an egress-only internet
252 * gateway for outbound-only IPv6 connectivity.
253 *
254 * **Example:** IPv6 from an IPAM pool
255 * ```typescript
256 * const vpc = yield* AWS.EC2.Vpc("MyVpc", {
257 * cidrBlock: "10.0.0.0/16",
258 * ipv6IpamPoolId: "ipam-pool-0fedcba9876543210",
259 * ipv6NetmaskLength: 56,
260 * });
261 * ```
262 *
263 * Draws the IPv6 block from a centrally-managed IPAM pool instead of Amazon's
264 * pool, giving you deterministic, organization-governed IPv6 ranges.
265 *
266 * ### Composing a Network
267 * **Example:** VPC with a subnet
268 * ```typescript
269 * const vpc = yield* AWS.EC2.Vpc("MyVpc", {
270 * cidrBlock: "10.0.0.0/16",
271 * enableDnsSupport: true,
272 * enableDnsHostnames: true,
273 * });
274 *
275 * const subnet = yield* AWS.EC2.Subnet("PublicSubnet", {
276 * vpcId: vpc.vpcId,
277 * cidrBlock: "10.0.1.0/24",
278 * availabilityZone: "us-east-1a",
279 * mapPublicIpOnLaunch: true,
280 * });
281 * ```
282 *
283 * Passing `vpc.vpcId` into a `Subnet` is how you build out a topology — the
284 * subnet's CIDR must fall within the VPC's `cidrBlock`. Add route tables,
285 * gateways, and security groups the same way.
286 *
287 * ### Tagging
288 * **Example:** Tagging a VPC
289 * ```typescript
290 * const vpc = yield* AWS.EC2.Vpc("MyVpc", {
291 * cidrBlock: "10.0.0.0/16",
292 * tags: {
293 * Name: "production-vpc",
294 * Environment: "production",
295 * },
296 * });
297 * ```
298 *
299 * User tags are merged with alchemy's auto-tags (`alchemy::stack`,
300 * `alchemy::stage`, `alchemy::id`), which brand the VPC as managed by your
301 * stack. The `Name` tag is what surfaces in the EC2 console.
302 *
303 * @resource
304 */
305export const Vpc = Resource<Vpc>("AWS.EC2.VPC");
306
307/**
308 * Map a raw EC2 VPC (as returned by `describeVpcs`) plus its tags to the
309 * resource's `Attributes` shape. DNS support/hostnames are not part of the
310 * `Attributes` contract, so the full shape is derivable from the describe
311 * response alone — no per-VPC `describeVpcAttribute` call is required.
312 */
313const vpcToAttributes = (
314 vpc: EC2.Vpc,
315 region: RegionID,
316 accountId: AccountID,
317 tags: Record<string, string> | undefined,
318): Vpc["Attributes"] => {
319 const vpcId = vpc.VpcId! as VpcId;
320 return {
321 vpcId,
322 vpcArn: `arn:aws:ec2:${region}:${accountId}:vpc/${vpcId}` as VpcArn,
323 cidrBlock: vpc.CidrBlock!,
324 dhcpOptionsId: vpc.DhcpOptionsId!,
325 state: vpc.State!,
326 isDefault: vpc.IsDefault ?? false,
327 ownerId: vpc.OwnerId,
328 cidrBlockAssociationSet: vpc.CidrBlockAssociationSet?.map((assoc) => ({
329 associationId: assoc.AssociationId!,
330 cidrBlock: assoc.CidrBlock!,
331 cidrBlockState: {
332 state: assoc.CidrBlockState!.State!,
333 statusMessage: assoc.CidrBlockState!.StatusMessage,
334 },
335 })),
336 ipv6CidrBlockAssociationSet: vpc.Ipv6CidrBlockAssociationSet?.map(
337 (assoc) => ({
338 associationId: assoc.AssociationId!,
339 ipv6CidrBlock: assoc.Ipv6CidrBlock!,
340 ipv6CidrBlockState: {
341 state: assoc.Ipv6CidrBlockState!.State!,
342 statusMessage: assoc.Ipv6CidrBlockState!.StatusMessage,
343 },
344 networkBorderGroup: assoc.NetworkBorderGroup,
345 ipv6Pool: assoc.Ipv6Pool,
346 }),
347 ),
348 tags,
349 };
350};
351
352/** Map the `Tags` array on a describe response to a plain record. */
353const tagsFromVpc = (vpc: EC2.Vpc): Record<string, string> =>
354 Object.fromEntries(
355 (vpc.Tags ?? []).map((tag: EC2.Tag) => [tag.Key!, tag.Value!]),
356 );
357
358export const VpcProvider = () =>
359 Provider.effect(
360 Vpc,
361 Effect.gen(function* () {
362 const createTags = Effect.fn(function* (
363 id: string,
364 tags?: Record<string, string>,
365 ) {
366 return {
367 Name: id,
368 ...(yield* createInternalTags(id)),
369 ...tags,
370 };
371 });
372
373 return {
374 stables: ["vpcId", "vpcArn", "ownerId", "isDefault"],
375 diff: Effect.fn(function* ({ news, olds }) {
376 if (!isResolved(news)) return;
377 if (
378 somePropsAreDifferent(olds, news, [
379 "cidrBlock",
380 "instanceTenancy",
381 "ipv4IpamPoolId",
382 "ipv6IpamPoolId",
383 "ipv6CidrBlock",
384 ])
385 ) {
386 return { action: "replace" };
387 }
388 }),
389
390 reconcile: Effect.fn(function* ({ id, news = {}, output, session }) {
391 const { accountId, region } = yield* AWSEnvironment.current;
392 const desiredTags = yield* createTags(id, news.tags);
393
394 // Observe — find the VPC if we already have its id, else describe
395 // returns empty and we fall through to create. We don't trust
396 // `output.vpcId` blindly: a VPC deleted out of band shows up as
397 // missing and we recreate.
398 let vpc: EC2.Vpc | undefined;
399
400 if (output?.vpcId) {
401 const lookup = yield* ec2
402 .describeVpcs({ VpcIds: [output.vpcId] })
403 .pipe(
404 Effect.catchTag("InvalidVpcID.NotFound", () =>
405 Effect.succeed({ Vpcs: [] }),
406 ),
407 );
408 vpc = lookup.Vpcs?.[0];
409 }
410
411 if (vpc === undefined) {
412 const createResult = yield* ec2
413 .createVpc({
414 // TODO(sam): add all properties
415 AmazonProvidedIpv6CidrBlock: news.amazonProvidedIpv6CidrBlock,
416 InstanceTenancy: news.instanceTenancy,
417 CidrBlock: news.cidrBlock,
418 Ipv4IpamPoolId: news.ipv4IpamPoolId,
419 Ipv4NetmaskLength: news.ipv4NetmaskLength,
420 Ipv6Pool: news.ipv6Pool,
421 Ipv6CidrBlock: news.ipv6CidrBlock,
422 Ipv6IpamPoolId: news.ipv6IpamPoolId,
423 Ipv6NetmaskLength: news.ipv6NetmaskLength,
424 Ipv6CidrBlockNetworkBorderGroup:
425 news.ipv6CidrBlockNetworkBorderGroup,
426 TagSpecifications: [
427 {
428 ResourceType: "vpc",
429 Tags: createTagsList(desiredTags),
430 },
431 ],
432 DryRun: false,
433 })
434 .pipe(
435 // The per-region VPC quota (default 5) is a shared pool;
436 // concurrent deploys transiently exhaust it while their VPCs
437 // are being torn down. Ride out the burst with a bounded
438 // spaced retry (~90s) before surfacing the quota error.
439 Effect.retry({
440 while: (e) => e._tag === "VpcLimitExceeded",
441 schedule: Schedule.max([
442 Schedule.spaced("10 seconds"),
443 Schedule.recurs(9),
444 ]),
445 }),
446 );
447 const newVpcId = createResult.Vpc!.VpcId! as VpcId;
448 yield* session.note(`VPC created: ${newVpcId}`);
449 vpc = yield* waitForVpcAvailable(newVpcId, session);
450 }
451
452 const vpcId = vpc.VpcId! as VpcId;
453
454 // Sync DNS attributes — observed cloud state vs desired. The default
455 // values DNS support/hostnames default to differ across new vs
456 // existing VPCs, so we always read first and only modify on a real
457 // delta to avoid pointless API calls.
458 const desiredDnsSupport = news.enableDnsSupport ?? true;
459 const desiredDnsHostnames = news.enableDnsHostnames ?? false;
460
461 const dnsSupportResult = yield* ec2.describeVpcAttribute({
462 VpcId: vpcId,
463 Attribute: "enableDnsSupport",
464 });
465 const currentDnsSupport =
466 dnsSupportResult.EnableDnsSupport?.Value ?? true;
467 if (currentDnsSupport !== desiredDnsSupport) {
468 yield* ec2.modifyVpcAttribute({
469 VpcId: vpcId,
470 EnableDnsSupport: { Value: desiredDnsSupport },
471 });
472 yield* session.note(`Updated DNS support: ${desiredDnsSupport}`);
473 }
474
475 const dnsHostnamesResult = yield* ec2.describeVpcAttribute({
476 VpcId: vpcId,
477 Attribute: "enableDnsHostnames",
478 });
479 const currentDnsHostnames =
480 dnsHostnamesResult.EnableDnsHostnames?.Value ?? false;
481 if (currentDnsHostnames !== desiredDnsHostnames) {
482 yield* ec2.modifyVpcAttribute({
483 VpcId: vpcId,
484 EnableDnsHostnames: { Value: desiredDnsHostnames },
485 });
486 yield* session.note(
487 `Updated DNS hostnames: ${desiredDnsHostnames}`,
488 );
489 }
490
491 // Sync tags — observed cloud tags vs desired.
492 const currentTags = Object.fromEntries(
493 (vpc.Tags ?? []).map((tag: EC2.Tag) => [tag.Key!, tag.Value!]),
494 ) as Record<string, string>;
495 const { removed, upsert } = diffTags(currentTags, desiredTags);
496 if (removed.length > 0) {
497 yield* ec2.deleteTags({
498 Resources: [vpcId],
499 Tags: removed.map((key) => ({ Key: key })),
500 DryRun: false,
501 });
502 }
503 if (upsert.length > 0) {
504 yield* ec2.createTags({
505 Resources: [vpcId],
506 Tags: upsert,
507 DryRun: false,
508 });
509 }
510
511 // Re-read final state so the returned attributes reflect what's
512 // actually in the cloud after all sync steps.
513 const final = yield* ec2.describeVpcs({ VpcIds: [vpcId] });
514 const finalVpc = final.Vpcs?.[0];
515 if (!finalVpc) {
516 return yield* Effect.fail(
517 new Error(`VPC ${vpcId} disappeared during reconcile`),
518 );
519 }
520
521 return vpcToAttributes(finalVpc, region, accountId, desiredTags);
522 }),
523
524 list: () =>
525 Effect.gen(function* () {
526 const { accountId, region } = yield* AWSEnvironment.current;
527 // Enumerate every VPC in this account/region, paginating
528 // exhaustively. Each item is hydrated to the full `Attributes`
529 // shape via the shared mapper so it's directly usable by `delete`.
530 return yield* ec2.describeVpcs.pages({}).pipe(
531 Stream.runCollect,
532 Effect.map((chunk) =>
533 Array.from(chunk).flatMap((page) =>
534 (page.Vpcs ?? [])
535 // The default VPC is account furniture AWS provisions (and
536 // test/AWS/DefaultVpc.ts recreates); never census/nuke it.
537 .filter((vpc) => !vpc.IsDefault)
538 .map((vpc) =>
539 vpcToAttributes(vpc, region, accountId, tagsFromVpc(vpc)),
540 ),
541 ),
542 ),
543 );
544 }),
545
546 delete: Effect.fn(function* ({ output, session }) {
547 const vpcId = output.vpcId;
548
549 yield* session.note(`Deleting VPC: ${vpcId}`);
550
551 // 1. Attempt to delete VPC
552 yield* ec2
553 .deleteVpc({
554 VpcId: vpcId,
555 DryRun: false,
556 })
557 .pipe(
558 Effect.tapError(Effect.logDebug),
559 Effect.catchTag("InvalidVpcID.NotFound", () => Effect.void),
560 // Retry on dependency violations (resources still being deleted)
561 Effect.retry({
562 while: (e) => {
563 // DependencyViolation means there are still dependent resources
564 // This can happen if subnets/IGW are being deleted concurrently
565 return (
566 e._tag === "DependencyViolation" ||
567 (e._tag === "ValidationError" &&
568 e.message?.includes("DependencyViolation"))
569 );
570 },
571 schedule: Schedule.fixed(5000).pipe(
572 Schedule.upTo({ duration: "5 minutes" }),
573 Schedule.tap(({ attempt }) =>
574 session.note(
575 `Waiting for dependencies to clear... (attempt ${attempt})`,
576 ),
577 ),
578 ),
579 }),
580 // Log the actual error for debugging
581 Effect.tapError((e) =>
582 session.note(`VPC delete failed: ${e._tag} - ${e.message}`),
583 ),
584 );
585
586 // 2. Wait for VPC to be fully deleted
587 yield* waitForVpcDeleted(vpcId, session);
588
589 yield* session.note(`VPC ${vpcId} deleted successfully`);
590 }),
591 };
592 }),
593 );
594
595// Retryable error: VPC is still pending
596class VpcPending extends Data.TaggedError("VpcPending")<{
597 vpcId: string;
598 state: string;
599}> {}
600
601// Retryable error: VPC still exists during deletion
602class VpcStillExists extends Data.TaggedError("VpcStillExists")<{
603 vpcId: string;
604 state: string | undefined;
605}> {
606 get message() {
607 return `VPC ${this.vpcId} is still ${this.state ?? "present"} after deletion`;
608 }
609}
610
611/**
612 * Wait for VPC to be in available state
613 */
614const waitForVpcAvailable = (
615 vpcId: string,
616 session?: ScopedPlanStatusSession,
617) =>
618 Effect.gen(function* () {
619 const result = yield* ec2.describeVpcs({ VpcIds: [vpcId] });
620 const vpc = result.Vpcs?.[0];
621
622 if (!vpc) {
623 return yield* Effect.fail(new Error(`VPC ${vpcId} not found`));
624 }
625
626 if (vpc.State === "available") {
627 return vpc;
628 }
629
630 // Still pending - this is the only retryable case
631 return yield* new VpcPending({ vpcId, state: vpc.State! });
632 }).pipe(
633 Effect.retry({
634 while: (e) => e instanceof VpcPending,
635 schedule: Schedule.max([Schedule.fixed(2000), Schedule.recurs(30)]).pipe(
636 Schedule.tap(({ attempt }) =>
637 session
638 ? session.note(
639 `Waiting for VPC to be available... (${attempt * 2}s)`,
640 )
641 : Effect.void,
642 ),
643 ),
644 }),
645 );
646
647/**
648 * Wait for VPC to be deleted
649 */
650const waitForVpcDeleted = (vpcId: string, session: ScopedPlanStatusSession) =>
651 Effect.gen(function* () {
652 const result = yield* ec2
653 .describeVpcs({ VpcIds: [vpcId] })
654 .pipe(
655 Effect.catchTag("InvalidVpcID.NotFound", () =>
656 Effect.succeed({ Vpcs: [] }),
657 ),
658 );
659
660 if (!result.Vpcs || result.Vpcs.length === 0) {
661 return; // Successfully deleted
662 }
663
664 // Still exists - this is the only retryable case
665 return yield* new VpcStillExists({ vpcId, state: result.Vpcs[0]?.State });
666 }).pipe(
667 Effect.retry({
668 while: (e) => e instanceof VpcStillExists,
669 schedule: Schedule.fixed(2000).pipe(
670 Schedule.upTo({ duration: "5 minutes" }),
671 Schedule.tap(({ attempt }) =>
672 session.note(`Waiting for VPC deletion... (${attempt * 2}s)`),
673 ),
674 ),
675 }),
676 );