1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7
8import type { ScopedPlanStatusSession } from "../../Report.ts";
9import { isResolved, somePropsAreDifferent } from "../../Diff.ts";
10import { retryWhileLingeringEnis } from "./LingeringEnis.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import type { Providers } from "../Providers.ts";
14import {
15 createInternalTags,
16 createTagsList,
17 diffTags,
18 hasTags,
19} from "../../Tags.ts";
20import type { AccountID } from "../Environment.ts";
21import type { RegionID } from "../Region.ts";
22import type { VpcId } from "./Vpc.ts";
23
24export type SubnetId<ID extends string = string> = `subnet-${ID}`;
25export const SubnetId = <ID extends string>(id: ID): ID & SubnetId<ID> =>
26 `subnet-${id}` as ID & SubnetId<ID>;
27
28export type SubnetArn =
29 `arn:aws:ec2:${RegionID}:${AccountID}:subnet/${SubnetId}`;
30
31export interface SubnetProps {
32 /**
33 * The VPC to create the subnet in.
34 */
35 vpcId: VpcId;
36
37 /**
38 * The IPv4 network range for the subnet, in CIDR notation.
39 * Required unless using IPAM.
40 * @example "10.0.1.0/24"
41 */
42 cidrBlock?: string;
43
44 /**
45 * The IPv6 network range for the subnet, in CIDR notation.
46 */
47 ipv6CidrBlock?: string;
48
49 /**
50 * The Availability Zone for the subnet.
51 * @example "us-east-1a"
52 */
53 availabilityZone?: string;
54
55 /**
56 * The ID of the Availability Zone for the subnet.
57 */
58 availabilityZoneId?: string;
59
60 /**
61 * The ID of an IPv4 IPAM pool you want to use for allocating this subnet's CIDR.
62 */
63 ipv4IpamPoolId?: string;
64
65 /**
66 * The netmask length of the IPv4 CIDR you want to allocate to this subnet from an IPAM pool.
67 */
68 ipv4NetmaskLength?: number;
69
70 /**
71 * The ID of an IPv6 IPAM pool which will be used to allocate this subnet an IPv6 CIDR.
72 */
73 ipv6IpamPoolId?: string;
74
75 /**
76 * The netmask length of the IPv6 CIDR you want to allocate to this subnet from an IPAM pool.
77 */
78 ipv6NetmaskLength?: number;
79
80 /**
81 * Whether instances launched in the subnet get public IPv4 addresses.
82 * @default false
83 */
84 mapPublicIpOnLaunch?: boolean;
85
86 /**
87 * Whether instances launched in the subnet get IPv6 addresses.
88 * @default false
89 */
90 assignIpv6AddressOnCreation?: boolean;
91
92 /**
93 * Whether DNS queries made to the Amazon-provided DNS Resolver in this subnet should return
94 * synthetic IPv6 addresses for IPv4-only destinations.
95 * @default false
96 */
97 enableDns64?: boolean;
98
99 /**
100 * Whether to enable resource name DNS A record on launch.
101 * @default false
102 */
103 enableResourceNameDnsARecordOnLaunch?: boolean;
104
105 /**
106 * Whether to enable resource name DNS AAAA record on launch.
107 * @default false
108 */
109 enableResourceNameDnsAAAARecordOnLaunch?: boolean;
110
111 /**
112 * The hostname type for EC2 instances launched into this subnet.
113 */
114 hostnameType?: ec2.HostnameType;
115
116 /**
117 * Tags to assign to the subnet.
118 * These will be merged with alchemy auto-tags (alchemy::stack, alchemy::stage, alchemy::id).
119 */
120 tags?: Record<string, string>;
121}
122
123export interface Subnet extends Resource<
124 "AWS.EC2.Subnet",
125 SubnetProps,
126 {
127 /**
128 * The ID of the VPC the subnet is in.
129 */
130 vpcId: VpcId;
131
132 /**
133 * The ID of the subnet.
134 */
135 subnetId: SubnetId;
136
137 /**
138 * The Amazon Resource Name (ARN) of the subnet.
139 */
140 subnetArn: SubnetArn;
141
142 /**
143 * The IPv4 CIDR block for the subnet.
144 */
145 cidrBlock: string;
146
147 /**
148 * The Availability Zone of the subnet.
149 */
150 availabilityZone: string;
151
152 /**
153 * The ID of the Availability Zone of the subnet.
154 */
155 availabilityZoneId?: string;
156
157 /**
158 * The current state of the subnet.
159 */
160 state: ec2.SubnetState;
161
162 /**
163 * The number of available IPv4 addresses in the subnet.
164 */
165 availableIpAddressCount: number;
166
167 /**
168 * Whether instances launched in the subnet get public IPv4 addresses.
169 */
170 mapPublicIpOnLaunch: boolean;
171
172 /**
173 * Whether instances launched in the subnet get IPv6 addresses.
174 */
175 assignIpv6AddressOnCreation: boolean | undefined;
176
177 /**
178 * Whether the subnet is the default subnet for the Availability Zone.
179 */
180 defaultForAz: boolean;
181
182 /**
183 * The ID of the AWS account that owns the subnet.
184 */
185 ownerId?: string;
186
187 /**
188 * Information about the IPv6 CIDR blocks associated with the subnet.
189 */
190 ipv6CidrBlockAssociationSet?: Array<{
191 associationId: string;
192 ipv6CidrBlock: string;
193 ipv6CidrBlockState: {
194 state: ec2.SubnetCidrBlockStateCode;
195 statusMessage?: string;
196 };
197 }>;
198
199 /**
200 * Whether DNS64 is enabled for the subnet.
201 */
202 enableDns64?: boolean;
203
204 /**
205 * Whether this is an IPv6-only subnet.
206 */
207 ipv6Native?: boolean;
208
209 /**
210 * The private DNS name options on launch.
211 */
212 privateDnsNameOptionsOnLaunch?: {
213 hostnameType?: ec2.HostnameType;
214 enableResourceNameDnsARecord?: boolean;
215 enableResourceNameDnsAAAARecord?: boolean;
216 };
217 },
218 never,
219 Providers
220> {}
221/**
222 * A subnet within a VPC — a range of IP addresses bound to a single
223 * Availability Zone where you place instances and other resources. Create
224 * public subnets (with `mapPublicIpOnLaunch`) for internet-facing resources
225 * and private subnets for internal ones.
226 *
227 * Changing the `vpcId`, `cidrBlock`, availability zone, or an IPAM/IPv6 pool
228 * replaces the subnet.
229 *
230 * ### Creating a Subnet
231 * A subnet carves a smaller CIDR range out of its parent VPC's block. The
232 * `cidrBlock` must be a subset of the VPC CIDR and must not overlap any sibling
233 * subnet. You can also let AWS IPAM allocate the range via `ipv4IpamPoolId` +
234 * `ipv4NetmaskLength`.
235 *
236 * **Example:** Basic Subnet
237 * ```typescript
238 * const subnet = yield* AWS.EC2.Subnet("TestSubnet", {
239 * vpcId: vpc.vpcId,
240 * cidrBlock: "10.0.1.0/24",
241 * });
242 * ```
243 *
244 * The minimal subnet: a `/24` (256 addresses) inside the VPC. Without an
245 * explicit `availabilityZone`, AWS picks one for you.
246 *
247 * ### Availability Zone Placement
248 * Each subnet lives in exactly one AZ. Pin it with `availabilityZone` (the zone
249 * name, e.g. `us-east-1a`) or `availabilityZoneId` (the stable zone ID, e.g.
250 * `use1-az1`) to spread tiers across zones for high availability.
251 *
252 * **Example:** Subnet pinned to an Availability Zone
253 * ```typescript
254 * const subnet = yield* AWS.EC2.Subnet("Az1Subnet", {
255 * vpcId: vpc.vpcId,
256 * cidrBlock: "10.0.1.0/24",
257 * availabilityZone: "us-east-1a",
258 * });
259 * ```
260 *
261 * Pinning the AZ lets you place a matching subnet in `us-east-1b` and run
262 * resources redundantly across zones. Use `availabilityZoneId` instead when you
263 * need the physical zone to line up across different AWS accounts.
264 *
265 * ### Public IP Assignment
266 * **Example:** Public subnet
267 * ```typescript
268 * const publicSubnet = yield* AWS.EC2.Subnet("PublicSubnet", {
269 * vpcId: vpc.vpcId,
270 * cidrBlock: "10.0.1.0/24",
271 * availabilityZone: "us-east-1a",
272 * mapPublicIpOnLaunch: true,
273 * tags: { Name: "public-1a", Tier: "public" },
274 * });
275 * ```
276 *
277 * `mapPublicIpOnLaunch: true` makes this a "public" subnet — instances launched
278 * here automatically get a public IPv4 address. Combine it with an internet
279 * gateway route so those instances can reach the internet.
280 *
281 * ### IPv6 Subnets
282 * For dual-stack VPCs, give the subnet an IPv6 `cidrBlock`, auto-assign IPv6
283 * addresses on launch with `assignIpv6AddressOnCreation`, and optionally enable
284 * `enableDns64` so the Amazon DNS resolver synthesizes IPv6 addresses for
285 * IPv4-only destinations (NAT64).
286 *
287 * **Example:** IPv6-enabled subnet
288 * ```typescript
289 * const subnet = yield* AWS.EC2.Subnet("Ipv6Subnet", {
290 * vpcId: vpc.vpcId,
291 * cidrBlock: "10.0.1.0/24",
292 * ipv6CidrBlock: "2600:1f18:abcd:1234::/64",
293 * assignIpv6AddressOnCreation: true,
294 * enableDns64: true,
295 * });
296 * ```
297 *
298 * Instances launched here receive an IPv6 address automatically, and `enableDns64`
299 * lets them reach IPv4-only services through a NAT gateway. The IPv6 `/64` must
300 * come from the parent VPC's IPv6 block.
301 *
302 * ### DNS Hostname Options
303 * Control what hostnames instances receive on launch. `hostnameType` chooses
304 * between IP-based names (`ip-name`) and resource-based names (`resource-name`),
305 * and the `enableResourceNameDnsARecordOnLaunch` /
306 * `enableResourceNameDnsAAAARecordOnLaunch` flags register A / AAAA records for
307 * resource-name hosts.
308 *
309 * **Example:** Resource-name DNS hostnames
310 * ```typescript
311 * const subnet = yield* AWS.EC2.Subnet("ResourceNameSubnet", {
312 * vpcId: vpc.vpcId,
313 * cidrBlock: "10.0.1.0/24",
314 * hostnameType: "resource-name",
315 * enableResourceNameDnsARecordOnLaunch: true,
316 * enableResourceNameDnsAAAARecordOnLaunch: true,
317 * });
318 * ```
319 *
320 * Resource-name hostnames are derived from the instance ID rather than its IP,
321 * so they stay stable across stop/start. Enabling the A/AAAA records makes those
322 * names resolvable over IPv4 and IPv6.
323 *
324 * ### Public & Private Tiers
325 * **Example:** A public and a private subnet in one VPC
326 * ```typescript
327 * const vpc = yield* AWS.EC2.Vpc("MyVpc", {
328 * cidrBlock: "10.0.0.0/16",
329 * enableDnsSupport: true,
330 * enableDnsHostnames: true,
331 * });
332 *
333 * const publicSubnet = yield* AWS.EC2.Subnet("PublicSubnet", {
334 * vpcId: vpc.vpcId,
335 * cidrBlock: "10.0.1.0/24",
336 * availabilityZone: "us-east-1a",
337 * mapPublicIpOnLaunch: true,
338 * });
339 *
340 * const privateSubnet = yield* AWS.EC2.Subnet("PrivateSubnet", {
341 * vpcId: vpc.vpcId,
342 * cidrBlock: "10.0.10.0/24",
343 * availabilityZone: "us-east-1a",
344 * });
345 * ```
346 *
347 * The canonical two-tier pattern: a public subnet (auto public IPs, routed to an
348 * internet gateway) for load balancers and a private subnet (no public IPs) for
349 * application and database instances. Both share the same AZ here, but in
350 * production you'd replicate the pair across AZs.
351 *
352 * @resource
353 */
354export const Subnet = Resource<Subnet>("AWS.EC2.Subnet");
355
356export const SubnetProvider = () =>
357 Provider.effect(
358 Subnet,
359 Effect.gen(function* () {
360 return {
361 stables: ["subnetId", "subnetArn", "ownerId", "vpcId"],
362 diff: Effect.fn(function* ({ news, olds }) {
363 if (!isResolved(news)) return;
364 if (
365 somePropsAreDifferent(olds, news, [
366 "vpcId",
367 "cidrBlock",
368 "availabilityZone",
369 "availabilityZoneId",
370 "ipv6CidrBlock",
371 "ipv4IpamPoolId",
372 "ipv6IpamPoolId",
373 ])
374 ) {
375 return { action: "replace" };
376 }
377 }),
378
379 reconcile: Effect.fn(function* ({ id, news, output, session }) {
380 const alchemyTags = yield* createInternalTags(id);
381 const desiredTags = { ...alchemyTags, ...news.tags };
382
383 // Observe — find the subnet via cached id, else fall through to
384 // create.
385 let subnet: ec2.Subnet | undefined;
386 if (output?.subnetId) {
387 const lookup = yield* ec2
388 .describeSubnets({ SubnetIds: [output.subnetId] })
389 .pipe(
390 Effect.catchTag("InvalidSubnetID.NotFound", () =>
391 Effect.succeed({ Subnets: [] }),
392 ),
393 );
394 subnet = lookup.Subnets?.[0];
395 }
396
397 // Ensure — create the subnet when missing.
398 if (subnet === undefined) {
399 const newSubnetId = yield* ec2
400 .createSubnet({
401 VpcId: news.vpcId,
402 CidrBlock: news.cidrBlock,
403 Ipv6CidrBlock: news.ipv6CidrBlock,
404 AvailabilityZone: news.availabilityZone,
405 AvailabilityZoneId: news.availabilityZoneId,
406 Ipv4IpamPoolId: news.ipv4IpamPoolId,
407 Ipv4NetmaskLength: news.ipv4NetmaskLength,
408 Ipv6IpamPoolId: news.ipv6IpamPoolId,
409 Ipv6NetmaskLength: news.ipv6NetmaskLength,
410 Ipv6Native: false,
411 TagSpecifications: [
412 {
413 ResourceType: "subnet",
414 Tags: createTagsList(desiredTags),
415 },
416 ],
417 DryRun: false,
418 })
419 .pipe(
420 Effect.retry({
421 while: (e) => e._tag === "InvalidVpcID.NotFound",
422 schedule: Schedule.exponential(100),
423 }),
424 Effect.map((createResult) => {
425 const newSubnetId = createResult.Subnet!
426 .SubnetId! as SubnetId;
427 return newSubnetId;
428 }),
429 Effect.tap((newSubnetId) =>
430 session.note(`Subnet created: ${newSubnetId}`),
431 ),
432 Effect.catchTag("InvalidSubnet.Conflict", (error) =>
433 Effect.gen(function* () {
434 // The CIDR is occupied. A prior interrupted run may have
435 // created this subnet but failed to persist state —
436 // observe by VPC + CIDR and take it over only when it
437 // carries our ownership tags; a foreign subnet re-fails
438 // with the typed conflict.
439 if (news.cidrBlock === undefined) {
440 return yield* Effect.fail(error);
441 }
442 const existing = yield* ec2.describeSubnets
443 .items({
444 Filters: [
445 { Name: "vpc-id", Values: [news.vpcId] },
446 { Name: "cidr-block", Values: [news.cidrBlock] },
447 ],
448 })
449 .pipe(
450 Stream.filter((s) =>
451 hasTags(
452 alchemyTags,
453 Object.fromEntries(
454 (s.Tags ?? []).map((t) => [t.Key ?? "", t.Value]),
455 ),
456 ),
457 ),
458 Stream.runHead,
459 Effect.map(Option.getOrUndefined),
460 );
461 if (existing?.SubnetId === undefined) {
462 return yield* Effect.fail(error);
463 }
464 const existingId = existing.SubnetId as SubnetId;
465 yield* session.note(
466 `Recovered subnet ${existingId} at ${news.cidrBlock} owned by this stack (left by an interrupted run)`,
467 );
468 return existingId;
469 }),
470 ),
471 );
472 subnet = yield* waitForSubnetAvailable(newSubnetId, session);
473 }
474
475 const subnetId = subnet.SubnetId! as SubnetId;
476
477 // Sync subnet attributes — diff observed cloud state against desired
478 // and only call modifySubnetAttribute on real drift.
479 const desiredMapPublicIp = news.mapPublicIpOnLaunch ?? false;
480 if ((subnet.MapPublicIpOnLaunch ?? false) !== desiredMapPublicIp) {
481 yield* ec2.modifySubnetAttribute({
482 SubnetId: subnetId,
483 MapPublicIpOnLaunch: { Value: desiredMapPublicIp },
484 });
485 yield* session.note(
486 `Updated map public IP on launch: ${desiredMapPublicIp}`,
487 );
488 }
489
490 const desiredAssignIpv6 = news.assignIpv6AddressOnCreation ?? false;
491 if (
492 (subnet.AssignIpv6AddressOnCreation ?? false) !== desiredAssignIpv6
493 ) {
494 yield* ec2.modifySubnetAttribute({
495 SubnetId: subnetId,
496 AssignIpv6AddressOnCreation: { Value: desiredAssignIpv6 },
497 });
498 yield* session.note(
499 `Updated assign IPv6 address on creation: ${desiredAssignIpv6}`,
500 );
501 }
502
503 const desiredEnableDns64 = news.enableDns64 ?? false;
504 if ((subnet.EnableDns64 ?? false) !== desiredEnableDns64) {
505 yield* ec2.modifySubnetAttribute({
506 SubnetId: subnetId,
507 EnableDns64: { Value: desiredEnableDns64 },
508 });
509 yield* session.note(`Updated DNS64 setting: ${desiredEnableDns64}`);
510 }
511
512 const observedHostnameType =
513 subnet.PrivateDnsNameOptionsOnLaunch?.HostnameType;
514 const observedDnsA =
515 subnet.PrivateDnsNameOptionsOnLaunch?.EnableResourceNameDnsARecord;
516 const observedDnsAAAA =
517 subnet.PrivateDnsNameOptionsOnLaunch
518 ?.EnableResourceNameDnsAAAARecord;
519 if (
520 observedHostnameType !== news.hostnameType ||
521 observedDnsA !== news.enableResourceNameDnsARecordOnLaunch ||
522 observedDnsAAAA !== news.enableResourceNameDnsAAAARecordOnLaunch
523 ) {
524 if (
525 news.enableResourceNameDnsARecordOnLaunch !== undefined ||
526 news.enableResourceNameDnsAAAARecordOnLaunch !== undefined ||
527 news.hostnameType !== undefined
528 ) {
529 yield* ec2.modifySubnetAttribute({
530 SubnetId: subnetId,
531 PrivateDnsHostnameTypeOnLaunch: news.hostnameType,
532 EnableResourceNameDnsARecordOnLaunch:
533 news.enableResourceNameDnsARecordOnLaunch !== undefined
534 ? { Value: news.enableResourceNameDnsARecordOnLaunch }
535 : undefined,
536 EnableResourceNameDnsAAAARecordOnLaunch:
537 news.enableResourceNameDnsAAAARecordOnLaunch !== undefined
538 ? { Value: news.enableResourceNameDnsAAAARecordOnLaunch }
539 : undefined,
540 });
541 yield* session.note("Updated private DNS hostname settings");
542 }
543 }
544
545 // Sync tags — observed cloud tags vs desired.
546 const currentTags = Object.fromEntries(
547 (subnet.Tags ?? []).map((t) => [t.Key!, t.Value!]),
548 ) as Record<string, string>;
549 const { removed, upsert } = diffTags(currentTags, desiredTags);
550 if (removed.length > 0) {
551 yield* ec2.deleteTags({
552 Resources: [subnetId],
553 Tags: removed.map((key) => ({ Key: key })),
554 DryRun: false,
555 });
556 }
557 if (upsert.length > 0) {
558 yield* ec2.createTags({
559 Resources: [subnetId],
560 Tags: upsert,
561 DryRun: false,
562 });
563 }
564
565 // Re-read final state.
566 const finalLookup = yield* ec2.describeSubnets({
567 SubnetIds: [subnetId],
568 });
569 const final = finalLookup.Subnets?.[0];
570 if (!final) {
571 return yield* Effect.fail(
572 new Error(`Subnet ${subnetId} disappeared during reconcile`),
573 );
574 }
575 return toSubnetAttributes(final);
576 }),
577
578 // Enumerate every subnet in the ambient account/region, paginating
579 // exhaustively. Each item is mapped to the full Attributes shape
580 // produced by `reconcile`/`read`.
581 list: () =>
582 ec2.describeSubnets.pages({}).pipe(
583 Stream.runCollect,
584 Effect.map((chunk) =>
585 Array.from(chunk).flatMap((page) =>
586 (page.Subnets ?? [])
587 // Per-AZ default subnets are default-VPC furniture AWS
588 // provisions; never census/nuke them. Custom subnets created
589 // inside the default VPC have DefaultForAz=false and are
590 // still listed.
591 .filter((subnet) => !subnet.DefaultForAz)
592 .map(toSubnetAttributes),
593 ),
594 ),
595 ),
596
597 delete: Effect.fn(function* ({ output, session }) {
598 const subnetId = output.subnetId;
599
600 yield* session.note(`Deleting subnet: ${subnetId}`);
601
602 // 1. Attempt to delete subnet. DependencyViolation means resources
603 // still occupy it — ENIs from a just-deleted ALB, CloudFront VPC
604 // origin, or a VPC-attached Lambda can take minutes to release
605 // after the owning resource is gone. Lambda Hyperplane ENIs are
606 // reaped explicitly between attempts (they otherwise linger up to
607 // ~20 minutes and used to force a second `destroy` run).
608 yield* retryWhileLingeringEnis(
609 ec2
610 .deleteSubnet({
611 SubnetId: subnetId,
612 DryRun: false,
613 })
614 .pipe(
615 Effect.tapError(Effect.logDebug),
616 Effect.catchTag("InvalidSubnetID.NotFound", () => Effect.void),
617 ),
618 {
619 scope: { name: "subnet-id", value: subnetId },
620 isDependencyViolation: (e) => e._tag === "DependencyViolation",
621 session,
622 },
623 );
624
625 // 2. Wait for subnet to be fully deleted
626 yield* waitForSubnetDeleted(subnetId, session);
627
628 yield* session.note(`Subnet ${subnetId} deleted successfully`);
629 }),
630 };
631 }),
632 );
633
634/**
635 * Map a distilled `ec2.Subnet` to the full Subnet `Attributes` shape that
636 * `reconcile`/`read` produce.
637 */
638const toSubnetAttributes = (subnet: ec2.Subnet): Subnet["Attributes"] => ({
639 subnetId: subnet.SubnetId! as SubnetId,
640 subnetArn: subnet.SubnetArn! as SubnetArn,
641 cidrBlock: subnet.CidrBlock!,
642 vpcId: subnet.VpcId! as VpcId,
643 availabilityZone: subnet.AvailabilityZone!,
644 availabilityZoneId: subnet.AvailabilityZoneId,
645 state: subnet.State!,
646 availableIpAddressCount: subnet.AvailableIpAddressCount ?? 0,
647 mapPublicIpOnLaunch: subnet.MapPublicIpOnLaunch ?? false,
648 assignIpv6AddressOnCreation: subnet.AssignIpv6AddressOnCreation ?? false,
649 defaultForAz: subnet.DefaultForAz ?? false,
650 ownerId: subnet.OwnerId,
651 ipv6CidrBlockAssociationSet: subnet.Ipv6CidrBlockAssociationSet?.map(
652 (assoc) => ({
653 associationId: assoc.AssociationId!,
654 ipv6CidrBlock: assoc.Ipv6CidrBlock!,
655 ipv6CidrBlockState: {
656 state: assoc.Ipv6CidrBlockState!.State!,
657 statusMessage: assoc.Ipv6CidrBlockState!.StatusMessage,
658 },
659 }),
660 ),
661 enableDns64: subnet.EnableDns64,
662 ipv6Native: subnet.Ipv6Native,
663 privateDnsNameOptionsOnLaunch: subnet.PrivateDnsNameOptionsOnLaunch
664 ? {
665 hostnameType: subnet.PrivateDnsNameOptionsOnLaunch.HostnameType,
666 enableResourceNameDnsARecord:
667 subnet.PrivateDnsNameOptionsOnLaunch.EnableResourceNameDnsARecord,
668 enableResourceNameDnsAAAARecord:
669 subnet.PrivateDnsNameOptionsOnLaunch.EnableResourceNameDnsAAAARecord,
670 }
671 : undefined,
672});
673
674// Retryable error: Subnet is still pending
675class SubnetPending extends Data.TaggedError("SubnetPending")<{
676 subnetId: string;
677 state: string;
678}> {}
679
680// Retryable error: Subnet still exists during deletion
681class SubnetStillExists extends Data.TaggedError("SubnetStillExists")<{
682 subnetId: string;
683}> {}
684
685/**
686 * Wait for subnet to be in available state
687 */
688const waitForSubnetAvailable = (
689 subnetId: string,
690 session?: ScopedPlanStatusSession,
691) =>
692 Effect.gen(function* () {
693 const result = yield* ec2.describeSubnets({ SubnetIds: [subnetId] });
694 const subnet = result.Subnets?.[0];
695
696 if (!subnet) {
697 return yield* Effect.fail(new Error(`Subnet ${subnetId} not found`));
698 }
699
700 if (subnet.State === "available") {
701 return subnet;
702 }
703
704 // Still pending - this is the only retryable case
705 return yield* new SubnetPending({ subnetId, state: subnet.State! });
706 }).pipe(
707 Effect.retry({
708 while: (e) => e instanceof SubnetPending,
709 schedule: Schedule.max([Schedule.fixed(2000), Schedule.recurs(30)]).pipe(
710 Schedule.tap(({ attempt }) =>
711 session
712 ? session.note(
713 `Waiting for subnet to be available... (${attempt * 2}s)`,
714 )
715 : Effect.void,
716 ),
717 ),
718 }),
719 );
720
721/**
722 * Wait for subnet to be deleted
723 */
724const waitForSubnetDeleted = (
725 subnetId: string,
726 session: ScopedPlanStatusSession,
727) =>
728 Effect.gen(function* () {
729 const result = yield* ec2
730 .describeSubnets({ SubnetIds: [subnetId] })
731 .pipe(
732 Effect.catchTag("InvalidSubnetID.NotFound", () =>
733 Effect.succeed({ Subnets: [] }),
734 ),
735 );
736
737 if (!result.Subnets || result.Subnets.length === 0) {
738 return; // Successfully deleted
739 }
740
741 // Still exists - this is the only retryable case
742 return yield* new SubnetStillExists({ subnetId });
743 }).pipe(
744 Effect.retry({
745 while: (e) => e instanceof SubnetStillExists,
746 schedule: Schedule.max([Schedule.fixed(2000), Schedule.recurs(15)]).pipe(
747 Schedule.tap(({ attempt }) =>
748 session.note(`Waiting for subnet deletion... (${attempt * 2}s)`),
749 ),
750 ),
751 }),
752 );