1import * as r53r from "@distilled.cloud/aws/route53resolver";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9
10export interface ResolverRuleAssociationProps {
11 /**
12 * ID of the resolver rule to attach. Changing it forces replacement.
13 */
14 resolverRuleId: string;
15 /**
16 * ID of the VPC the rule takes effect in. Changing it forces
17 * replacement.
18 */
19 vpcId: string;
20 /**
21 * Optional friendly name recorded on the association. Changing it forces
22 * replacement (associations cannot be updated).
23 */
24 name?: string;
25}
26
27export interface ResolverRuleAssociation extends Resource<
28 "AWS.Route53Resolver.ResolverRuleAssociation",
29 ResolverRuleAssociationProps,
30 {
31 /**
32 * ID of the association (e.g. `rslvr-rrassoc-...`).
33 */
34 resolverRuleAssociationId: string;
35 /**
36 * ID of the associated resolver rule.
37 */
38 resolverRuleId: string;
39 /**
40 * ID of the VPC the rule is associated with.
41 */
42 vpcId: string;
43 },
44 never,
45 Providers
46> {}
47
48/**
49 * An association between a Route 53 Resolver rule and a VPC. Once
50 * associated, Resolver applies the rule to DNS queries that originate in
51 * that VPC.
52 * ### Associating Rules
53 * **Example:** Attach a Forwarding Rule to a VPC
54 * ```typescript
55 * import * as Route53Resolver from "alchemy/AWS/Route53Resolver";
56 *
57 * const association = yield* Route53Resolver.ResolverRuleAssociation(
58 * "CorpForwardAssoc",
59 * {
60 * resolverRuleId: rule.resolverRuleId,
61 * vpcId: vpc.vpcId,
62 * },
63 * );
64 * ```
65 *
66 * @resource
67 */
68export const ResolverRuleAssociation = Resource<ResolverRuleAssociation>(
69 "AWS.Route53Resolver.ResolverRuleAssociation",
70);
71
72/**
73 * Re-associating a (rule, VPC) pair whose previous association is still
74 * draining (`DELETING`) surfaces `InvalidRequestException` or
75 * `ResourceExistsException`; the rule itself may also still be settling
76 * (`ResourceUnavailableException`). All are bounded races — retry (~60s).
77 *
78 * Explicitly typed: inlining `Effect.retry` in provider lifecycle code can
79 * widen the provider layer to `unknown` in declaration emit.
80 *
81 * @internal
82 */
83const retryAssociateRaces = <A, E extends { _tag: string }, R>(
84 self: Effect.Effect<A, E, R>,
85): Effect.Effect<A, E, R> =>
86 Effect.retry(self, {
87 while: (e) =>
88 e._tag === "InvalidRequestException" ||
89 e._tag === "ResourceExistsException" ||
90 e._tag === "ResourceUnavailableException",
91 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(20)]),
92 });
93
94/**
95 * Best-effort bounded wait (~30s) for an association to leave `CREATING`.
96 * Associations routinely take 1-2 minutes to reach `COMPLETE`; the
97 * association is already effective for reconciliation purposes, so the
98 * reconciler does not block on the full transition.
99 *
100 * @internal
101 */
102const untilAssociationSettled = <E, R>(
103 self: Effect.Effect<r53r.ResolverRuleAssociation | undefined, E, R>,
104): Effect.Effect<r53r.ResolverRuleAssociation | undefined, E, R> =>
105 self.pipe(
106 Effect.repeat({
107 schedule: Schedule.fixed("3 seconds"),
108 until: (association) =>
109 association === undefined || association.Status !== "CREATING",
110 times: 10,
111 }),
112 );
113
114/**
115 * Bounded wait (~2 min) for an association to fully drain after
116 * disassociation. Deletion must converge before returning: the parent
117 * resolver rule cannot be deleted while any association (even `DELETING`)
118 * remains, so returning early cascades `ResourceInUseException` onto the
119 * rule's delete.
120 *
121 * @internal
122 */
123const untilAssociationGone = <A, E, R>(
124 self: Effect.Effect<A | undefined, E, R>,
125): Effect.Effect<A | undefined, E, R> =>
126 self.pipe(
127 Effect.repeat({
128 schedule: Schedule.fixed("5 seconds"),
129 until: (association) => association === undefined,
130 times: 24,
131 }),
132 );
133
134/**
135 * Disassociating an association that is still `CREATING` transiently fails
136 * with `InvalidRequestException` — retry briefly until it settles.
137 *
138 * @internal
139 */
140const retryDisassociateRaces = <A, E extends { _tag: string }, R>(
141 self: Effect.Effect<A, E, R>,
142): Effect.Effect<A, E, R> =>
143 Effect.retry(self, {
144 while: (e) => e._tag === "InvalidRequestException",
145 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(20)]),
146 });
147
148export const ResolverRuleAssociationProvider = () =>
149 Provider.effect(
150 ResolverRuleAssociation,
151 Effect.gen(function* () {
152 const listAssociations = (resolverRuleId: string, vpcId: string) =>
153 r53r
154 .listResolverRuleAssociations({
155 Filters: [
156 { Name: "ResolverRuleId", Values: [resolverRuleId] },
157 { Name: "VPCId", Values: [vpcId] },
158 ],
159 })
160 .pipe(Effect.map((r) => r.ResolverRuleAssociations ?? []));
161
162 // An association is identified by its (rule, VPC) pair; DELETING and
163 // FAILED associations count as missing so reconcile re-associates.
164 const observe = (resolverRuleId: string, vpcId: string) =>
165 listAssociations(resolverRuleId, vpcId).pipe(
166 Effect.map((associations) =>
167 associations.find(
168 (association) =>
169 association.Status !== "DELETING" &&
170 association.Status !== "FAILED",
171 ),
172 ),
173 );
174
175 // Any association record at all — used by delete to wait until the
176 // (rule, VPC) pair has fully drained, DELETING entries included.
177 const observeAny = (resolverRuleId: string, vpcId: string) =>
178 listAssociations(resolverRuleId, vpcId).pipe(
179 Effect.map((associations) => associations[0]),
180 );
181
182 return ResolverRuleAssociation.Provider.of({
183 stables: ["resolverRuleAssociationId", "resolverRuleId", "vpcId"],
184 // Top-level enumeration: associations are listable account-wide.
185 list: () =>
186 r53r.listResolverRuleAssociations.pages({}).pipe(
187 Stream.runCollect,
188 Effect.map((chunk) =>
189 Array.from(chunk)
190 .flatMap((page) => page.ResolverRuleAssociations ?? [])
191 .flatMap((association) =>
192 association.Id !== undefined &&
193 association.ResolverRuleId !== undefined &&
194 association.VPCId !== undefined &&
195 // AWS creates an Internet Resolver association for each
196 // VPC. It cannot be disassociated directly and disappears
197 // automatically with the VPC.
198 !association.Id.startsWith("rslvr-autodefined") &&
199 !association.ResolverRuleId.startsWith("rslvr-autodefined")
200 ? [
201 {
202 resolverRuleAssociationId: association.Id,
203 resolverRuleId: association.ResolverRuleId,
204 vpcId: association.VPCId,
205 },
206 ]
207 : [],
208 ),
209 ),
210 ),
211 read: Effect.fn(function* ({ olds, output }) {
212 const resolverRuleId = output?.resolverRuleId ?? olds?.resolverRuleId;
213 const vpcId = output?.vpcId ?? olds?.vpcId;
214 if (resolverRuleId === undefined || vpcId === undefined) {
215 return undefined;
216 }
217 const association = yield* observe(resolverRuleId, vpcId);
218 if (association?.Id === undefined) {
219 return undefined;
220 }
221 return {
222 resolverRuleAssociationId: association.Id,
223 resolverRuleId,
224 vpcId,
225 };
226 }),
227 // Existence-only resource — every identifying property change
228 // replaces the association.
229 diff: Effect.fn(function* ({ olds, news }) {
230 if (!isResolved(news)) return undefined;
231 if (
232 olds.resolverRuleId !== news.resolverRuleId ||
233 olds.vpcId !== news.vpcId ||
234 olds.name !== news.name
235 ) {
236 return { action: "replace" } as const;
237 }
238 }),
239 // Existence-only: observe → if missing, associate. There is no
240 // sync step (an association has no mutable aspects).
241 reconcile: Effect.fn(function* ({ news, session }) {
242 let association = yield* observe(news.resolverRuleId, news.vpcId);
243 if (association === undefined) {
244 association = yield* retryAssociateRaces(
245 r53r.associateResolverRule({
246 ResolverRuleId: news.resolverRuleId,
247 VPCId: news.vpcId,
248 Name: news.name,
249 }),
250 ).pipe(Effect.map((r) => r.ResolverRuleAssociation!));
251 }
252 // Association setup is asynchronous but quick; wait (bounded) so
253 // the rule is actually in effect when the deploy completes.
254 const settled = yield* untilAssociationSettled(
255 observe(news.resolverRuleId, news.vpcId),
256 );
257 association = settled ?? association;
258
259 yield* session.note(`${news.resolverRuleId}/${news.vpcId}`);
260 return {
261 resolverRuleAssociationId: association.Id!,
262 resolverRuleId: news.resolverRuleId,
263 vpcId: news.vpcId,
264 };
265 }),
266 delete: Effect.fn(function* ({ output }) {
267 yield* retryDisassociateRaces(
268 r53r.disassociateResolverRule({
269 ResolverRuleId: output.resolverRuleId,
270 VPCId: output.vpcId,
271 }),
272 ).pipe(
273 Effect.asVoid,
274 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
275 );
276 // Disassociation is asynchronous and the parent rule cannot be
277 // deleted until the association has fully drained — wait
278 // (bounded ~2 min) for it to disappear.
279 yield* untilAssociationGone(
280 observeAny(output.resolverRuleId, output.vpcId),
281 );
282 }),
283 });
284 }),
285 );