1import * as r53r from "@distilled.cloud/aws/route53resolver";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 fetchResolverTags,
15 syncResolverTags,
16 toResolverTagList,
17} from "./internal.ts";
18
19export interface ResolverRuleTargetIp {
20 /**
21 * IPv4 address of the DNS resolver on your network that queries are
22 * forwarded to.
23 */
24 ip?: string;
25 /**
26 * IPv6 address of the DNS resolver on your network.
27 */
28 ipv6?: string;
29 /**
30 * Port the target resolver listens on.
31 * @default 53
32 */
33 port?: number;
34 /**
35 * Protocol used to reach the target.
36 * @default "Do53"
37 */
38 protocol?: "Do53" | "DoH";
39}
40
41export interface ResolverRuleProps {
42 /**
43 * Friendly name of the rule. Also used as the `CreatorRequestId`.
44 * Changing it forces replacement.
45 * @default ${app}-${stage}-${id}
46 */
47 name?: string;
48 /**
49 * How Resolver handles queries for `domainName`. Only `FORWARD` rules
50 * can carry `targetIps`. Changing it forces replacement.
51 * @default "FORWARD"
52 */
53 ruleType?: "FORWARD" | "SYSTEM" | "RECURSIVE" | "DELEGATE";
54 /**
55 * Domain name that DNS queries are matched against (longest suffix
56 * match). Changing it forces replacement.
57 */
58 domainName: string;
59 /**
60 * IP addresses of the DNS resolvers on your network that matching
61 * queries are forwarded to. Updatable in place.
62 */
63 targetIps?: ResolverRuleTargetIp[];
64 /**
65 * ID of the OUTBOUND resolver endpoint the forwarded queries pass
66 * through. Updatable in place.
67 */
68 resolverEndpointId?: string;
69 /**
70 * Tags to apply to the rule. Merged with internal Alchemy tags.
71 */
72 tags?: Record<string, string>;
73}
74
75export interface ResolverRule extends Resource<
76 "AWS.Route53Resolver.ResolverRule",
77 ResolverRuleProps,
78 {
79 /**
80 * ID of the resolver rule (e.g. `rslvr-rr-...`).
81 */
82 resolverRuleId: string;
83 /**
84 * ARN of the resolver rule.
85 */
86 resolverRuleArn: string;
87 /**
88 * Name of the rule.
89 */
90 name: string;
91 /**
92 * Domain name the rule matches (as stored by Route 53 Resolver, with a
93 * trailing dot).
94 */
95 domainName: string;
96 },
97 never,
98 Providers
99> {}
100
101/**
102 * A Route 53 Resolver rule — tells Resolver how to handle DNS queries for a
103 * domain that originate in your VPCs.
104 *
105 * A `FORWARD` rule sends matching queries through an OUTBOUND
106 * `ResolverEndpoint` to the DNS resolvers on your network listed in
107 * `targetIps`. The rule takes effect in a VPC once attached with a
108 * `ResolverRuleAssociation`.
109 * ### Forwarding Rules
110 * **Example:** Forward a Domain to On-Prem Resolvers
111 * ```typescript
112 * import * as Route53Resolver from "alchemy/AWS/Route53Resolver";
113 *
114 * const rule = yield* Route53Resolver.ResolverRule("CorpForward", {
115 * domainName: "corp.example.com",
116 * resolverEndpointId: outbound.resolverEndpointId,
117 * targetIps: [{ ip: "192.168.1.10" }, { ip: "192.168.1.11", port: 53 }],
118 * });
119 * ```
120 *
121 * ### Attaching to VPCs
122 * **Example:** Associate the Rule with a VPC
123 * ```typescript
124 * const association = yield* Route53Resolver.ResolverRuleAssociation(
125 * "CorpForwardAssoc",
126 * {
127 * resolverRuleId: rule.resolverRuleId,
128 * vpcId: vpc.vpcId,
129 * },
130 * );
131 * ```
132 *
133 * @resource
134 */
135export const ResolverRule = Resource<ResolverRule>(
136 "AWS.Route53Resolver.ResolverRule",
137);
138
139/**
140 * Creating a FORWARD rule can transiently fail while its OUTBOUND endpoint
141 * is still provisioning (`ResourceUnavailableException`), and recreating a
142 * rule whose deterministic `CreatorRequestId` is held by a `DELETING`
143 * predecessor raises `ResourceExistsException`. Both are bounded races —
144 * retry (~2 min).
145 *
146 * Explicitly typed: inlining `Effect.retry` in provider lifecycle code can
147 * widen the provider layer to `unknown` in declaration emit.
148 *
149 * @internal
150 */
151const retryRuleCreateRaces = <A, E extends { _tag: string }, R>(
152 self: Effect.Effect<A, E, R>,
153): Effect.Effect<A, E, R> =>
154 Effect.retry(self, {
155 while: (e) =>
156 e._tag === "ResourceUnavailableException" ||
157 e._tag === "ResourceExistsException",
158 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(24)]),
159 });
160
161/**
162 * A rule cannot be deleted while it is still associated with VPCs
163 * (`ResourceInUseException`). Association teardown is asynchronous, so
164 * retry deletion on a bounded schedule (~60s) while associations drain.
165 *
166 * @internal
167 */
168const retryRuleInUse = <A, E extends { _tag: string }, R>(
169 self: Effect.Effect<A, E, R>,
170): Effect.Effect<A, E, R> =>
171 Effect.retry(self, {
172 while: (e) =>
173 e._tag === "ResourceInUseException" ||
174 e._tag === "InvalidRequestException",
175 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(30)]),
176 });
177
178/**
179 * Route 53 Resolver stores domain names lowercased with a trailing dot;
180 * normalize both sides before comparing.
181 *
182 * @internal
183 */
184const normalizeDomain = (domain: string) =>
185 domain.toLowerCase().replace(/\.+$/, "");
186
187export const ResolverRuleProvider = () =>
188 Provider.effect(
189 ResolverRule,
190 Effect.gen(function* () {
191 const createName = Effect.fn(function* (
192 id: string,
193 props: { name?: string | undefined },
194 ) {
195 return props.name ?? (yield* createPhysicalName({ id, maxLength: 64 }));
196 });
197
198 const getRule = (ruleId: string) =>
199 r53r.getResolverRule({ ResolverRuleId: ruleId }).pipe(
200 Effect.map((r) => r.ResolverRule),
201 Effect.catchTag("ResourceNotFoundException", () =>
202 Effect.succeed(undefined),
203 ),
204 );
205
206 const observe = Effect.fn(function* (
207 name: string,
208 ruleId: string | undefined,
209 ) {
210 if (ruleId !== undefined) {
211 const byId = yield* getRule(ruleId);
212 if (byId !== undefined && byId.Status !== "DELETING") {
213 return byId;
214 }
215 }
216 return yield* r53r.listResolverRules
217 .items({
218 Filters: [{ Name: "CreatorRequestId", Values: [name] }],
219 })
220 .pipe(
221 Stream.filter((rule) => rule.Status !== "DELETING"),
222 Stream.runHead,
223 Effect.map(Option.getOrUndefined),
224 );
225 });
226
227 // Desired-vs-observed comparison of target IPs, tolerant of the
228 // defaults Route 53 Resolver fills in (port 53, protocol Do53).
229 const targetKey = (targets: readonly r53r.TargetAddress[]) =>
230 targets
231 .map(
232 (t) =>
233 `${t.Ip ?? ""}|${t.Ipv6 ?? ""}|${t.Port ?? 53}|${t.Protocol ?? "Do53"}`,
234 )
235 .sort()
236 .join(",");
237 const toWireTargets = (
238 targets: readonly ResolverRuleTargetIp[],
239 ): r53r.TargetAddress[] =>
240 targets.map((t) => ({
241 Ip: t.ip,
242 Ipv6: t.ipv6,
243 Port: t.port,
244 Protocol: t.protocol,
245 }));
246
247 return ResolverRule.Provider.of({
248 stables: ["resolverRuleId", "resolverRuleArn", "name", "domainName"],
249 // Top-level resource: enumerate every resolver rule in the ambient
250 // account/region (includes AWS-managed SYSTEM rules, which read as
251 // Unowned).
252 list: () =>
253 r53r.listResolverRules.pages({}).pipe(
254 Stream.runCollect,
255 Effect.map((chunk) =>
256 Array.from(chunk)
257 .flatMap((page) => page.ResolverRules ?? [])
258 .flatMap((rule) =>
259 rule.Id !== undefined &&
260 rule.Arn !== undefined &&
261 // Autodefined rules (e.g. the Internet Resolver rule for
262 // `.`, id `rslvr-autodefined-rr-internet-resolver`) are
263 // owned by Route 53 Resolver itself and can never be
264 // deleted — keep them out of enumeration for account-wide
265 // teardown (nuke).
266 !rule.Id.startsWith("rslvr-autodefined")
267 ? [
268 {
269 resolverRuleId: rule.Id,
270 resolverRuleArn: rule.Arn,
271 name: rule.Name ?? "",
272 domainName: rule.DomainName ?? "",
273 },
274 ]
275 : [],
276 ),
277 ),
278 ),
279 read: Effect.fn(function* ({ id, olds, output }) {
280 const name = output?.name ?? (yield* createName(id, olds ?? {}));
281 const rule = yield* observe(name, output?.resolverRuleId);
282 if (rule?.Id === undefined || rule.Arn === undefined) {
283 return undefined;
284 }
285 const attrs = {
286 resolverRuleId: rule.Id,
287 resolverRuleArn: rule.Arn,
288 name: rule.Name ?? name,
289 domainName: rule.DomainName ?? "",
290 };
291 const tags = yield* fetchResolverTags(rule.Arn);
292 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
293 }),
294 // Replacement detection: domain name and rule type are immutable;
295 // target IPs and the endpoint reference update in place.
296 diff: Effect.fn(function* ({ id, news, olds }) {
297 if (!isResolved(news)) return undefined;
298 const oldName = yield* createName(id, olds);
299 const newName = yield* createName(id, news);
300 if (oldName !== newName) return { action: "replace" } as const;
301 if ((olds.ruleType ?? "FORWARD") !== (news.ruleType ?? "FORWARD")) {
302 return { action: "replace" } as const;
303 }
304 if (
305 normalizeDomain(olds.domainName) !==
306 normalizeDomain(news.domainName)
307 ) {
308 return { action: "replace" } as const;
309 }
310 }),
311 reconcile: Effect.fn(function* ({ id, news, output, session }) {
312 const name = output?.name ?? (yield* createName(id, news));
313 const internalTags = yield* createInternalTags(id);
314 const desiredTags = { ...news.tags, ...internalTags };
315
316 // 1. OBSERVE
317 let rule = yield* observe(name, output?.resolverRuleId);
318
319 // 2. ENSURE — create if missing, riding out the bounded races
320 // (endpoint still provisioning, DELETING predecessor).
321 if (rule === undefined) {
322 yield* session.note(`creating resolver rule ${name}`);
323 rule = yield* retryRuleCreateRaces(
324 r53r.createResolverRule({
325 CreatorRequestId: name,
326 Name: name,
327 RuleType: news.ruleType ?? "FORWARD",
328 DomainName: news.domainName,
329 TargetIps: news.targetIps
330 ? toWireTargets(news.targetIps)
331 : undefined,
332 ResolverEndpointId: news.resolverEndpointId,
333 Tags: toResolverTagList(desiredTags),
334 }),
335 ).pipe(Effect.map((r) => r.ResolverRule!));
336 }
337 const ruleId = rule.Id!;
338
339 // 3. SYNC — name, target IPs, and the endpoint reference are
340 // mutable via UpdateResolverRule. Diff observed against
341 // desired; skip the API on no-op.
342 const desiredTargets = news.targetIps
343 ? toWireTargets(news.targetIps)
344 : undefined;
345 const targetsDelta =
346 desiredTargets !== undefined &&
347 targetKey(rule.TargetIps ?? []) !== targetKey(desiredTargets);
348 const endpointDelta =
349 news.resolverEndpointId !== undefined &&
350 rule.ResolverEndpointId !== news.resolverEndpointId;
351 const nameDelta = rule.Name !== name;
352 if (targetsDelta || endpointDelta || nameDelta) {
353 rule = yield* r53r
354 .updateResolverRule({
355 ResolverRuleId: ruleId,
356 Config: {
357 ...(nameDelta ? { Name: name } : {}),
358 ...(targetsDelta ? { TargetIps: desiredTargets } : {}),
359 ...(endpointDelta
360 ? { ResolverEndpointId: news.resolverEndpointId }
361 : {}),
362 },
363 })
364 .pipe(Effect.map((r) => r.ResolverRule ?? rule!));
365 }
366
367 // 3b. SYNC TAGS — diff against observed cloud tags.
368 const arn = rule.Arn!;
369 yield* syncResolverTags(arn, desiredTags);
370
371 yield* session.note(ruleId);
372 return {
373 resolverRuleId: ruleId,
374 resolverRuleArn: arn,
375 name: rule.Name ?? name,
376 domainName: rule.DomainName ?? news.domainName,
377 };
378 }),
379 delete: Effect.fn(function* ({ output }) {
380 // A rule associated with VPCs cannot be deleted — disassociate all
381 // associations first (idempotent: a vanished association is
382 // already gone).
383 const associations = yield* r53r.listResolverRuleAssociations
384 .pages({
385 Filters: [
386 { Name: "ResolverRuleId", Values: [output.resolverRuleId] },
387 ],
388 })
389 .pipe(
390 Stream.runCollect,
391 Effect.map((chunk) =>
392 Array.from(chunk).flatMap(
393 (page) => page.ResolverRuleAssociations ?? [],
394 ),
395 ),
396 Effect.catch(() =>
397 Effect.succeed([] as r53r.ResolverRuleAssociation[]),
398 ),
399 );
400 yield* Effect.forEach(
401 associations,
402 (assoc) =>
403 assoc.VPCId
404 ? r53r
405 .disassociateResolverRule({
406 ResolverRuleId: output.resolverRuleId,
407 VPCId: assoc.VPCId,
408 })
409 .pipe(
410 Effect.catchTag("ResourceNotFoundException", () =>
411 Effect.succeed(undefined),
412 ),
413 Effect.asVoid,
414 )
415 : Effect.void,
416 { discard: true },
417 );
418 yield* retryRuleInUse(
419 r53r.deleteResolverRule({
420 ResolverRuleId: output.resolverRuleId,
421 }),
422 ).pipe(
423 Effect.asVoid,
424 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
425 );
426 }),
427 });
428 }),
429 );