1import * as route53 from "@distilled.cloud/aws/route-53";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9export interface ZoneVpcAssociationProps {
10 /**
11 * ID of the private hosted zone to associate the VPC with. Changing this
12 * forces replacement.
13 */
14 hostedZoneId: string;
15 /**
16 * ID of the VPC to associate. The VPC must have DNS support and DNS
17 * hostnames enabled. Changing this forces replacement.
18 */
19 vpcId: string;
20 /**
21 * Region the VPC lives in. Changing this forces replacement.
22 */
23 vpcRegion: string;
24 /**
25 * Optional comment recorded on the association request.
26 */
27 comment?: string;
28}
29
30export interface ZoneVpcAssociation extends Resource<
31 "AWS.Route53.ZoneVpcAssociation",
32 ZoneVpcAssociationProps,
33 {
34 /**
35 * ID of the private hosted zone.
36 */
37 hostedZoneId: string;
38 /**
39 * ID of the associated VPC.
40 */
41 vpcId: string;
42 /**
43 * Region of the associated VPC.
44 */
45 vpcRegion: string;
46 },
47 never,
48 Providers
49> {}
50
51/**
52 * An association between an additional VPC and a private hosted zone.
53 *
54 * A private hosted zone is created with one initial VPC (see
55 * `HostedZone.vpc`); `ZoneVpcAssociation` attaches further VPCs so their DNS
56 * resolvers can answer from the zone. For a VPC in a different account, the
57 * zone owner must first create a `VpcAssociationAuthorization` for it.
58 *
59 * The initial VPC of a private zone cannot be modeled with this resource —
60 * Route 53 refuses to disassociate the last VPC from a private zone.
61 * ### Associating VPCs
62 * **Example:** Attach a Second VPC
63 * ```typescript
64 * const zone = yield* HostedZone("InternalZone", {
65 * name: "internal.example.com",
66 * privateZone: true,
67 * vpc: { vpcId: primary.vpcId, vpcRegion: "us-west-2" },
68 * });
69 *
70 * const association = yield* ZoneVpcAssociation("SecondaryVpc", {
71 * hostedZoneId: zone.id,
72 * vpcId: secondary.vpcId,
73 * vpcRegion: "us-west-2",
74 * });
75 * ```
76 *
77 * @resource
78 */
79export const ZoneVpcAssociation = Resource<ZoneVpcAssociation>(
80 "AWS.Route53.ZoneVpcAssociation",
81);
82
83/**
84 * Route 53 zone-change submissions can collide with other in-flight changes
85 * on the same zone (`PriorRequestNotComplete`) — retry on a bounded schedule
86 * (~40s).
87 *
88 * Explicitly typed: inlining `Effect.retry` with options in provider
89 * lifecycle code can widen the provider layer to `unknown` in declaration
90 * emit.
91 *
92 * @internal
93 */
94const retryPriorRequest = <A, E extends { _tag: string }, R>(
95 self: Effect.Effect<A, E, R>,
96): Effect.Effect<A, E, R> =>
97 Effect.retry(self, {
98 while: (e) => e._tag === "PriorRequestNotComplete",
99 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(20)]),
100 });
101
102export const ZoneVpcAssociationProvider = () =>
103 Provider.effect(
104 ZoneVpcAssociation,
105 Effect.gen(function* () {
106 // Observe via the zone detail — `getHostedZone` returns every
107 // associated VPC for a private zone.
108 const observe = Effect.fn(function* (
109 hostedZoneId: string,
110 vpcId: string,
111 ) {
112 const detail = yield* route53
113 .getHostedZone({ Id: hostedZoneId })
114 .pipe(
115 Effect.catchTag("NoSuchHostedZone", () =>
116 Effect.succeed(undefined),
117 ),
118 );
119 return (detail?.VPCs ?? []).find((vpc) => vpc.VPCId === vpcId);
120 });
121
122 return {
123 stables: ["hostedZoneId", "vpcId", "vpcRegion"],
124 // Sub-resource: associations are keyed by their parent hosted zone;
125 // there is no account-wide enumeration API.
126 list: () => Effect.succeed([]),
127 // Existence-only resource — every identifying property change
128 // replaces the association.
129 diff: Effect.fn(function* ({ olds, news }) {
130 if (!isResolved(news)) return undefined;
131 if (
132 olds.hostedZoneId !== news.hostedZoneId ||
133 olds.vpcId !== news.vpcId ||
134 olds.vpcRegion !== news.vpcRegion
135 ) {
136 return { action: "replace" } as const;
137 }
138 }),
139 read: Effect.fn(function* ({ olds, output }) {
140 const hostedZoneId = output?.hostedZoneId ?? olds?.hostedZoneId;
141 const vpcId = output?.vpcId ?? olds?.vpcId;
142 if (hostedZoneId === undefined || vpcId === undefined) {
143 return undefined;
144 }
145 const observed = yield* observe(hostedZoneId, vpcId);
146 if (!observed) {
147 return undefined;
148 }
149 return {
150 hostedZoneId,
151 vpcId,
152 vpcRegion:
153 observed.VPCRegion ?? output?.vpcRegion ?? olds?.vpcRegion ?? "",
154 };
155 }),
156 // Existence-only: observe → if missing, associate. There is no sync
157 // step (an association has no mutable aspects).
158 reconcile: Effect.fn(function* ({ news, session }) {
159 const observed = yield* observe(news.hostedZoneId, news.vpcId);
160 if (!observed) {
161 yield* retryPriorRequest(
162 route53.associateVPCWithHostedZone({
163 HostedZoneId: news.hostedZoneId,
164 VPC: {
165 VPCId: news.vpcId,
166 VPCRegion: news.vpcRegion as route53.VPCRegion,
167 },
168 Comment: news.comment,
169 }),
170 );
171 }
172 yield* session.note(`${news.hostedZoneId}/${news.vpcId}`);
173 return {
174 hostedZoneId: news.hostedZoneId,
175 vpcId: news.vpcId,
176 vpcRegion: news.vpcRegion,
177 };
178 }),
179 delete: Effect.fn(function* ({ output }) {
180 yield* route53
181 .disassociateVPCFromHostedZone({
182 HostedZoneId: output.hostedZoneId,
183 VPC: {
184 VPCId: output.vpcId,
185 VPCRegion: output.vpcRegion as route53.VPCRegion,
186 },
187 })
188 .pipe(
189 Effect.asVoid,
190 Effect.catchTag("VPCAssociationNotFound", () => Effect.void),
191 Effect.catchTag("NoSuchHostedZone", () => Effect.void),
192 );
193 }),
194 };
195 }),
196 );