1import * as Lambda from "@/AWS/Lambda";
2import * as Route53Resolver from "@/AWS/Route53Resolver";
3import * as EC2 from "@distilled.cloud/aws/ec2";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10import { getDefaultVpc } from "../DefaultVpc.ts";
11
12const main = path.resolve(import.meta.dirname, "handler.ts");
13
14export class Route53ResolverBindingsFunction extends Lambda.Function<Lambda.Function>()(
15 "Route53ResolverBindingsFunction",
16) {}
17
18/**
19 * Resolve two default-for-AZ subnets plus the default security group from
20 * the account's default VPC — the network the resolver endpoint's interfaces
21 * land in. Runtime-guarded: the Lambda runtime re-executes this props effect
22 * on cold start with no ec2:Describe* permission, so return empty values
23 * there (network config is deploy-time only).
24 */
25const resolveNetwork = Effect.gen(function* () {
26 if (globalThis.__ALCHEMY_RUNTIME__) {
27 return { subnetIds: [] as string[], securityGroupIds: [] as string[] };
28 }
29 const vpc = yield* getDefaultVpc;
30 const subnets = yield* EC2.describeSubnets({
31 Filters: [
32 { Name: "vpc-id", Values: [vpc.vpcId] },
33 { Name: "default-for-az", Values: ["true"] },
34 ],
35 });
36 const subnetIds = (subnets.Subnets ?? [])
37 .map((s) => s.SubnetId)
38 .filter((id): id is string => id !== undefined)
39 .sort()
40 .slice(0, 2);
41 const groups = yield* EC2.describeSecurityGroups({
42 Filters: [
43 { Name: "vpc-id", Values: [vpc.vpcId] },
44 { Name: "group-name", Values: ["default"] },
45 ],
46 });
47 const securityGroupId = groups.SecurityGroups?.[0]?.GroupId;
48 return {
49 subnetIds,
50 securityGroupIds: securityGroupId ? [securityGroupId] : [],
51 };
52}).pipe(
53 // Deploy-time lookup only; a failure here is a fixture defect, not a typed
54 // error the Function impl contract can carry.
55 Effect.orDie,
56);
57
58/**
59 * Binding fixture: an OUTBOUND resolver endpoint in the default VPC, a
60 * FORWARD rule through it, and a Lambda bound to all five Route 53 Resolver
61 * runtime bindings, one HTTP route per behavior.
62 */
63export default Route53ResolverBindingsFunction.make(
64 {
65 main,
66 functionUrl: true,
67 timeout: Duration.seconds(120),
68 },
69 Effect.gen(function* () {
70 const net = yield* resolveNetwork;
71 const endpoint = yield* Route53Resolver.ResolverEndpoint(
72 "BindingsEndpoint",
73 {
74 direction: "OUTBOUND",
75 securityGroupIds: net.securityGroupIds,
76 ipAddresses: net.subnetIds.map((subnetId) => ({ subnetId })),
77 },
78 );
79 const rule = yield* Route53Resolver.ResolverRule("BindingsRule", {
80 domainName: "bindings.alchemy-r53r-test.internal",
81 resolverEndpointId: endpoint.resolverEndpointId,
82 targetIps: [{ ip: "10.100.0.10" }],
83 });
84
85 const bound = {
86 getResolverEndpoint: yield* Route53Resolver.GetResolverEndpoint(endpoint),
87 listResolverEndpointIpAddresses:
88 yield* Route53Resolver.ListResolverEndpointIpAddresses(endpoint),
89 getResolverRule: yield* Route53Resolver.GetResolverRule(rule),
90 updateResolverRule: yield* Route53Resolver.UpdateResolverRule(rule),
91 listResolverRuleAssociations:
92 yield* Route53Resolver.ListResolverRuleAssociations(rule),
93 };
94
95 return {
96 fetch: Effect.gen(function* () {
97 const request = yield* HttpServerRequest;
98 const url = new URL(request.originalUrl);
99 const pathname = url.pathname;
100
101 if (request.method === "GET" && pathname === "/bindings") {
102 return yield* HttpServerResponse.json({
103 bound: Object.keys(bound),
104 });
105 }
106
107 // Endpoint-scoped read — the endpoint ID is injected.
108 if (request.method === "GET" && pathname === "/endpoint") {
109 const response = yield* bound.getResolverEndpoint();
110 return yield* HttpServerResponse.json({
111 status: response.ResolverEndpoint?.Status ?? "",
112 direction: response.ResolverEndpoint?.Direction ?? "",
113 ipAddressCount: response.ResolverEndpoint?.IpAddressCount ?? 0,
114 });
115 }
116
117 // Endpoint IP discovery — the endpoint ID is injected.
118 if (request.method === "GET" && pathname === "/endpoint/ips") {
119 const response = yield* bound.listResolverEndpointIpAddresses();
120 return yield* HttpServerResponse.json({
121 ips: (response.IpAddresses ?? []).map((ip) => ({
122 ip: ip.Ip ?? "",
123 status: ip.Status ?? "",
124 })),
125 });
126 }
127
128 // Rule-scoped read — the rule ID is injected.
129 if (request.method === "GET" && pathname === "/rule") {
130 const response = yield* bound.getResolverRule();
131 return yield* HttpServerResponse.json({
132 domainName: response.ResolverRule?.DomainName ?? "",
133 ruleType: response.ResolverRule?.RuleType ?? "",
134 targetIps: (response.ResolverRule?.TargetIps ?? []).map(
135 (t) => t.Ip ?? "",
136 ),
137 });
138 }
139
140 // Runtime failover: swap the rule's target IP.
141 if (request.method === "POST" && pathname === "/rule/target") {
142 const ip = url.searchParams.get("ip") ?? "";
143 const response = yield* bound.updateResolverRule({
144 Config: { TargetIps: [{ Ip: ip, Port: 53 }] },
145 });
146 return yield* HttpServerResponse.json({
147 status: response.ResolverRule?.Status ?? "",
148 targetIps: (response.ResolverRule?.TargetIps ?? []).map(
149 (t) => t.Ip ?? "",
150 ),
151 });
152 }
153
154 // Which VPCs is the rule live in? (filtered to the bound rule)
155 if (request.method === "GET" && pathname === "/rule/associations") {
156 const response = yield* bound.listResolverRuleAssociations();
157 return yield* HttpServerResponse.json({
158 count: (response.ResolverRuleAssociations ?? []).length,
159 });
160 }
161
162 return yield* HttpServerResponse.json(
163 { error: "Not found", method: request.method, pathname },
164 { status: 404 },
165 );
166 }).pipe(Effect.orDie),
167 };
168 }).pipe(
169 Effect.provide(
170 Layer.mergeAll(
171 Route53Resolver.GetResolverEndpointHttp,
172 Route53Resolver.ListResolverEndpointIpAddressesHttp,
173 Route53Resolver.GetResolverRuleHttp,
174 Route53Resolver.UpdateResolverRuleHttp,
175 Route53Resolver.ListResolverRuleAssociationsHttp,
176 ),
177 ),
178 ),
179);