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