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 sameStringSet,
16 syncResolverTags,
17 toResolverTagList,
18} from "./internal.ts";
19
20export interface ResolverEndpointIpAddress {
21 /**
22 * ID of the subnet to place a resolver network interface in. Changing the
23 * set of subnets forces replacement.
24 */
25 subnetId: string;
26 /**
27 * Optional fixed IPv4 address within the subnet. If omitted, Resolver
28 * picks an available address.
29 */
30 ip?: string;
31 /**
32 * Optional fixed IPv6 address within the subnet (dual-stack/IPv6
33 * endpoints only).
34 */
35 ipv6?: string;
36}
37
38export interface ResolverEndpointProps {
39 /**
40 * Friendly name of the endpoint. Also used as the `CreatorRequestId`.
41 * Changing it forces replacement.
42 * @default ${app}-${stage}-${id}
43 */
44 name?: string;
45 /**
46 * Whether the endpoint accepts DNS queries from your network (`INBOUND`)
47 * or forwards queries from your VPC to your network (`OUTBOUND`).
48 * Changing it forces replacement.
49 */
50 direction: "INBOUND" | "OUTBOUND";
51 /**
52 * IP addresses (one per subnet, minimum 2 subnets in different AZs) that
53 * the resolver endpoint's network interfaces are created in. Changing the
54 * subnet set forces replacement.
55 */
56 ipAddresses: ResolverEndpointIpAddress[];
57 /**
58 * IDs of the security groups controlling access to the endpoint's
59 * network interfaces. Changing them forces replacement.
60 */
61 securityGroupIds: string[];
62 /**
63 * The IP family of the endpoint.
64 * @default "IPV4"
65 */
66 resolverEndpointType?: "IPV4" | "IPV6" | "DUALSTACK";
67 /**
68 * DNS protocols the endpoint answers/forwards with.
69 * @default ["Do53"]
70 */
71 protocols?: ("Do53" | "DoH" | "DoH-FIPS")[];
72 /**
73 * Tags to apply to the endpoint. Merged with internal Alchemy tags.
74 */
75 tags?: Record<string, string>;
76}
77
78export interface ResolverEndpoint extends Resource<
79 "AWS.Route53Resolver.ResolverEndpoint",
80 ResolverEndpointProps,
81 {
82 /**
83 * ID of the resolver endpoint (e.g. `rslvr-in-...` / `rslvr-out-...`).
84 */
85 resolverEndpointId: string;
86 /**
87 * ARN of the resolver endpoint.
88 */
89 resolverEndpointArn: string;
90 /**
91 * Name of the endpoint.
92 */
93 name: string;
94 /**
95 * `INBOUND` or `OUTBOUND`.
96 */
97 direction: string;
98 /**
99 * ID of the VPC the endpoint's network interfaces live in.
100 */
101 hostVpcId: string;
102 },
103 never,
104 Providers
105> {}
106
107/**
108 * A Route 53 Resolver endpoint — the set of elastic network interfaces that
109 * connect your VPC's `.2` resolver to DNS resolvers on your own network.
110 *
111 * An `INBOUND` endpoint lets DNS resolvers on your network forward queries
112 * to Route 53 Resolver; an `OUTBOUND` endpoint lets Resolver forward queries
113 * from your VPCs to your network (paired with FORWARD `ResolverRule`s).
114 *
115 * Endpoint provisioning is asynchronous (typically 1-2 minutes); the
116 * provider waits (bounded) for the endpoint to become `OPERATIONAL` so
117 * dependent resolver rules can use it immediately.
118 * ### Creating Endpoints
119 * **Example:** Inbound Endpoint
120 * ```typescript
121 * import * as Route53Resolver from "alchemy/AWS/Route53Resolver";
122 *
123 * const inbound = yield* Route53Resolver.ResolverEndpoint("Inbound", {
124 * direction: "INBOUND",
125 * securityGroupIds: [sg.securityGroupId],
126 * ipAddresses: [
127 * { subnetId: subnetA.subnetId },
128 * { subnetId: subnetB.subnetId },
129 * ],
130 * });
131 * ```
132 *
133 * **Example:** Outbound Endpoint with Fixed IPs
134 * ```typescript
135 * const outbound = yield* Route53Resolver.ResolverEndpoint("Outbound", {
136 * direction: "OUTBOUND",
137 * securityGroupIds: [sg.securityGroupId],
138 * ipAddresses: [
139 * { subnetId: subnetA.subnetId, ip: "10.0.0.10" },
140 * { subnetId: subnetB.subnetId, ip: "10.0.1.10" },
141 * ],
142 * });
143 * ```
144 *
145 * ### Forwarding Queries
146 * **Example:** Forward a Domain through an Outbound Endpoint
147 * ```typescript
148 * const rule = yield* Route53Resolver.ResolverRule("CorpForward", {
149 * domainName: "corp.example.com",
150 * resolverEndpointId: outbound.resolverEndpointId,
151 * targetIps: [{ ip: "192.168.1.10" }],
152 * });
153 * ```
154 *
155 * @resource
156 */
157export const ResolverEndpoint = Resource<ResolverEndpoint>(
158 "AWS.Route53Resolver.ResolverEndpoint",
159);
160
161/**
162 * Recreating an endpoint whose deterministic `CreatorRequestId` is still
163 * attached to a `DELETING` predecessor raises `ResourceExistsException`
164 * until the old endpoint is fully gone (~1 min). Retry on a bounded
165 * schedule (~2 min).
166 *
167 * Explicitly typed: inlining `Effect.retry` in provider lifecycle code can
168 * widen the provider layer to `unknown` in declaration emit.
169 *
170 * @internal
171 */
172const retryEndpointExists = <A, E extends { _tag: string }, R>(
173 self: Effect.Effect<A, E, R>,
174): Effect.Effect<A, E, R> =>
175 Effect.retry(self, {
176 while: (e) => e._tag === "ResourceExistsException",
177 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(24)]),
178 });
179
180/**
181 * Deleting an endpoint that still has resolver rules attached (their own
182 * deletion may still be propagating) surfaces `InvalidRequestException` —
183 * retry briefly so the delete converges.
184 *
185 * @internal
186 */
187const retryEndpointInUse = <A, E extends { _tag: string }, R>(
188 self: Effect.Effect<A, E, R>,
189): Effect.Effect<A, E, R> =>
190 Effect.retry(self, {
191 while: (e) => e._tag === "InvalidRequestException",
192 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(15)]),
193 });
194
195/**
196 * Bounded wait (~3.5 min) for an endpoint to leave its transitional state
197 * (`CREATING`/`UPDATING`). If the budget is exhausted the last observation
198 * is returned and downstream consumers retry on their own typed errors.
199 *
200 * @internal
201 */
202const untilEndpointSettled = <E, R>(
203 self: Effect.Effect<r53r.ResolverEndpoint | undefined, E, R>,
204): Effect.Effect<r53r.ResolverEndpoint | undefined, E, R> =>
205 self.pipe(
206 Effect.repeat({
207 schedule: Schedule.fixed("5 seconds"),
208 until: (endpoint) =>
209 endpoint === undefined ||
210 (endpoint.Status !== "CREATING" && endpoint.Status !== "UPDATING"),
211 times: 42,
212 }),
213 );
214
215export const ResolverEndpointProvider = () =>
216 Provider.effect(
217 ResolverEndpoint,
218 Effect.gen(function* () {
219 const createName = Effect.fn(function* (
220 id: string,
221 props: { name?: string | undefined },
222 ) {
223 return props.name ?? (yield* createPhysicalName({ id, maxLength: 64 }));
224 });
225
226 const getEndpoint = (endpointId: string) =>
227 r53r.getResolverEndpoint({ ResolverEndpointId: endpointId }).pipe(
228 Effect.map((r) => r.ResolverEndpoint),
229 Effect.catchTag("ResourceNotFoundException", () =>
230 Effect.succeed(undefined),
231 ),
232 );
233
234 // Observe by the cached endpoint ID first, falling back to the
235 // deterministic CreatorRequestId (set to the physical name at create).
236 // Endpoints in DELETING state are treated as missing so a reconcile
237 // after an out-of-band delete recreates.
238 const observe = Effect.fn(function* (
239 name: string,
240 endpointId: string | undefined,
241 ) {
242 if (endpointId !== undefined) {
243 const byId = yield* getEndpoint(endpointId);
244 if (byId !== undefined && byId.Status !== "DELETING") {
245 return byId;
246 }
247 }
248 return yield* r53r.listResolverEndpoints
249 .items({
250 Filters: [{ Name: "CreatorRequestId", Values: [name] }],
251 })
252 .pipe(
253 Stream.filter((endpoint) => endpoint.Status !== "DELETING"),
254 Stream.runHead,
255 Effect.map(Option.getOrUndefined),
256 );
257 });
258
259 return ResolverEndpoint.Provider.of({
260 stables: [
261 "resolverEndpointId",
262 "resolverEndpointArn",
263 "name",
264 "direction",
265 "hostVpcId",
266 ],
267 // Top-level resource: enumerate every resolver endpoint in the
268 // ambient account/region.
269 list: () =>
270 r53r.listResolverEndpoints.pages({}).pipe(
271 Stream.runCollect,
272 Effect.map((chunk) =>
273 Array.from(chunk)
274 .flatMap((page) => page.ResolverEndpoints ?? [])
275 .flatMap((endpoint) =>
276 endpoint.Id !== undefined && endpoint.Arn !== undefined
277 ? [
278 {
279 resolverEndpointId: endpoint.Id,
280 resolverEndpointArn: endpoint.Arn,
281 name: endpoint.Name ?? "",
282 direction: endpoint.Direction ?? "",
283 hostVpcId: endpoint.HostVPCId ?? "",
284 },
285 ]
286 : [],
287 ),
288 ),
289 ),
290 read: Effect.fn(function* ({ id, olds, output }) {
291 const name = output?.name ?? (yield* createName(id, olds ?? {}));
292 const endpoint = yield* observe(name, output?.resolverEndpointId);
293 if (endpoint?.Id === undefined || endpoint.Arn === undefined) {
294 return undefined;
295 }
296 const attrs = {
297 resolverEndpointId: endpoint.Id,
298 resolverEndpointArn: endpoint.Arn,
299 name: endpoint.Name ?? name,
300 direction: endpoint.Direction ?? "",
301 hostVpcId: endpoint.HostVPCId ?? "",
302 };
303 const tags = yield* fetchResolverTags(endpoint.Arn);
304 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
305 }),
306 // Replacement detection only. Direction, security groups, and the
307 // subnet placement of the network interfaces are immutable;
308 // protocols/endpoint type/tags update in place via reconcile.
309 diff: Effect.fn(function* ({ id, news, olds }) {
310 if (!isResolved(news)) return undefined;
311 const oldName = yield* createName(id, olds);
312 const newName = yield* createName(id, news);
313 if (oldName !== newName) return { action: "replace" } as const;
314 if (olds.direction !== news.direction) {
315 return { action: "replace" } as const;
316 }
317 if (!sameStringSet(olds.securityGroupIds, news.securityGroupIds)) {
318 return { action: "replace" } as const;
319 }
320 const ipKey = (ip: ResolverEndpointIpAddress) =>
321 `${ip.subnetId}:${ip.ip ?? ""}:${ip.ipv6 ?? ""}`;
322 if (
323 !sameStringSet(
324 olds.ipAddresses.map(ipKey),
325 news.ipAddresses.map(ipKey),
326 )
327 ) {
328 return { action: "replace" } as const;
329 }
330 }),
331 reconcile: Effect.fn(function* ({ id, news, output, session }) {
332 const name = output?.name ?? (yield* createName(id, news));
333 const internalTags = yield* createInternalTags(id);
334 const desiredTags = { ...news.tags, ...internalTags };
335
336 // 1. OBSERVE — cloud state is authoritative; output only caches
337 // the endpoint ID.
338 let endpoint = yield* observe(name, output?.resolverEndpointId);
339
340 // 2. ENSURE — create if missing. A DELETING predecessor holding
341 // the same CreatorRequestId surfaces ResourceExistsException,
342 // which is retried until the old endpoint drains.
343 if (endpoint === undefined) {
344 yield* session.note(`creating resolver endpoint ${name}`);
345 endpoint = yield* retryEndpointExists(
346 r53r.createResolverEndpoint({
347 CreatorRequestId: name,
348 Name: name,
349 Direction: news.direction,
350 SecurityGroupIds: news.securityGroupIds,
351 IpAddresses: news.ipAddresses.map((ip) => ({
352 SubnetId: ip.subnetId,
353 Ip: ip.ip,
354 Ipv6: ip.ipv6,
355 })),
356 ResolverEndpointType: news.resolverEndpointType,
357 Protocols: news.protocols,
358 Tags: toResolverTagList(desiredTags),
359 }),
360 ).pipe(Effect.map((r) => r.ResolverEndpoint!));
361 }
362 const endpointId = endpoint.Id!;
363
364 // Endpoint provisioning is asynchronous (~1-2 min). Wait (bounded)
365 // for it to settle so dependents (FORWARD rules) can use it.
366 yield* session.note(
367 `waiting for resolver endpoint ${endpointId} to become OPERATIONAL`,
368 );
369 const settled = yield* untilEndpointSettled(getEndpoint(endpointId));
370 endpoint = settled ?? endpoint;
371
372 // 3. SYNC — protocols and endpoint type are mutable in place.
373 // Diff observed cloud state against desired; skip the API on
374 // no-op.
375 const desiredProtocols = news.protocols;
376 const protocolsDelta =
377 desiredProtocols !== undefined &&
378 !sameStringSet(endpoint.Protocols ?? [], desiredProtocols);
379 const desiredType = news.resolverEndpointType;
380 const typeDelta =
381 desiredType !== undefined &&
382 endpoint.ResolverEndpointType !== desiredType;
383 if (protocolsDelta || typeDelta) {
384 endpoint = yield* r53r
385 .updateResolverEndpoint({
386 ResolverEndpointId: endpointId,
387 ...(protocolsDelta ? { Protocols: desiredProtocols } : {}),
388 ...(typeDelta ? { ResolverEndpointType: desiredType } : {}),
389 })
390 .pipe(Effect.map((r) => r.ResolverEndpoint ?? endpoint!));
391 }
392
393 // 3b. SYNC TAGS — diff against observed cloud tags.
394 const arn = endpoint.Arn!;
395 yield* syncResolverTags(arn, desiredTags);
396
397 yield* session.note(endpointId);
398 return {
399 resolverEndpointId: endpointId,
400 resolverEndpointArn: arn,
401 name: endpoint.Name ?? name,
402 direction: endpoint.Direction ?? news.direction,
403 hostVpcId: endpoint.HostVPCId ?? "",
404 };
405 }),
406 delete: Effect.fn(function* ({ output }) {
407 yield* retryEndpointInUse(
408 r53r.deleteResolverEndpoint({
409 ResolverEndpointId: output.resolverEndpointId,
410 }),
411 ).pipe(
412 Effect.asVoid,
413 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
414 );
415 }),
416 });
417 }),
418 );