Skip to content
Source
Sign inSign up

Vpc.ts

720 lines24.0 KB

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