1import * as route53 from "@distilled.cloud/aws/route-53";
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 QueryLoggingConfigProps {
11 /**
12 * ID of the public hosted zone to log DNS queries for. Changing this forces
13 * replacement.
14 */
15 hostedZoneId: string;
16 /**
17 * ARN of the CloudWatch Logs log group Route 53 publishes query logs to.
18 * The log group **must live in `us-east-1`** (Route 53 is a global service
19 * and only delivers query logs to that region), and a CloudWatch Logs
20 * resource policy in `us-east-1` must grant `route53.amazonaws.com`
21 * permission to `logs:CreateLogStream` and `logs:PutLogEvents` on it (see
22 * `AWS.Logs.ResourcePolicy`). Changing this forces replacement.
23 */
24 cloudWatchLogsLogGroupArn: string;
25}
26
27export interface QueryLoggingConfig extends Resource<
28 "AWS.Route53.QueryLoggingConfig",
29 QueryLoggingConfigProps,
30 {
31 /**
32 * ID of the query logging configuration.
33 */
34 id: string;
35 /**
36 * Hosted zone the configuration logs queries for.
37 */
38 hostedZoneId: string;
39 /**
40 * ARN of the destination CloudWatch Logs log group.
41 */
42 cloudWatchLogsLogGroupArn: string;
43 },
44 never,
45 Providers
46> {}
47
48/**
49 * DNS query logging for a Route 53 public hosted zone.
50 *
51 * Route 53 publishes query logs to a CloudWatch Logs log group in
52 * `us-east-1`. The log group needs a CloudWatch Logs resource policy (also in
53 * `us-east-1`) that allows the `route53.amazonaws.com` service principal to
54 * create log streams and put log events — model it with
55 * `AWS.Logs.ResourcePolicy`.
56 *
57 * A hosted zone can have at most one query logging configuration, and the
58 * configuration is immutable — changing either property replaces it.
59 * ### Enabling Query Logging
60 * **Example:** Log Queries for a Hosted Zone
61 * ```typescript
62 * // Both the log group and the resource policy must live in us-east-1.
63 * const policy = yield* Logs.ResourcePolicy("Route53QueryLogging", {
64 * policyName: "route53-query-logging",
65 * policyDocument: {
66 * Version: "2012-10-17",
67 * Statement: [
68 * {
69 * Effect: "Allow",
70 * Principal: { Service: "route53.amazonaws.com" },
71 * Action: ["logs:CreateLogStream", "logs:PutLogEvents"],
72 * Resource: `arn:aws:logs:us-east-1:${accountId}:log-group:/aws/route53/*`,
73 * },
74 * ],
75 * },
76 * });
77 *
78 * const logging = yield* QueryLoggingConfig("ZoneQueryLogging", {
79 * hostedZoneId: zone.id,
80 * cloudWatchLogsLogGroupArn: logGroup.logGroupArn,
81 * });
82 * ```
83 *
84 * @resource
85 */
86export const QueryLoggingConfig = Resource<QueryLoggingConfig>(
87 "AWS.Route53.QueryLoggingConfig",
88);
89
90const toAttrs = (
91 config: route53.QueryLoggingConfig,
92): QueryLoggingConfig["Attributes"] => ({
93 id: config.Id,
94 hostedZoneId: config.HostedZoneId,
95 cloudWatchLogsLogGroupArn: config.CloudWatchLogsLogGroupArn,
96});
97
98/**
99 * `createQueryLoggingConfig` validates the CloudWatch Logs resource policy
100 * at call time. A policy that was just put (same deploy) can take a few
101 * seconds to become visible to Route 53, surfacing as
102 * `InsufficientCloudWatchLogsResourcePolicy` — retry it (and
103 * `ConcurrentModification`) on a bounded schedule (~40s).
104 *
105 * Explicitly typed: inlining `Effect.retry` with options in provider
106 * lifecycle code can widen the provider layer to `unknown` in declaration
107 * emit.
108 *
109 * @internal
110 */
111const retryQueryLoggingCreate = <A, E extends { _tag: string }, R>(
112 self: Effect.Effect<A, E, R>,
113): Effect.Effect<A, E, R> =>
114 Effect.retry(self, {
115 while: (e) =>
116 e._tag === "InsufficientCloudWatchLogsResourcePolicy" ||
117 e._tag === "ConcurrentModification",
118 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(20)]),
119 });
120
121export const QueryLoggingConfigProvider = () =>
122 Provider.effect(
123 QueryLoggingConfig,
124 Effect.gen(function* () {
125 // A hosted zone has at most one query logging configuration, so
126 // observing by zone id is exact.
127 const observeByZone = Effect.fn(function* (hostedZoneId: string) {
128 const response = yield* route53
129 .listQueryLoggingConfigs({
130 HostedZoneId: hostedZoneId,
131 MaxResults: 1,
132 })
133 .pipe(
134 Effect.catchTag("NoSuchHostedZone", () =>
135 Effect.succeed({ QueryLoggingConfigs: [] }),
136 ),
137 );
138 return (response.QueryLoggingConfigs ?? []).at(0);
139 });
140
141 const observeById = Effect.fn(function* (id: string) {
142 const response = yield* route53
143 .getQueryLoggingConfig({ Id: id })
144 .pipe(
145 Effect.catchTag("NoSuchQueryLoggingConfig", () =>
146 Effect.succeed(undefined),
147 ),
148 );
149 return response?.QueryLoggingConfig;
150 });
151
152 const deleteById = Effect.fn(function* (id: string) {
153 yield* route53
154 .deleteQueryLoggingConfig({ Id: id })
155 .pipe(Effect.catchTag("NoSuchQueryLoggingConfig", () => Effect.void));
156 });
157
158 return {
159 stables: ["id", "hostedZoneId", "cloudWatchLogsLogGroupArn"],
160 list: () =>
161 route53.listQueryLoggingConfigs.pages({}).pipe(
162 Stream.runCollect,
163 Effect.map((chunk) =>
164 Array.from(chunk).flatMap((page) =>
165 (page.QueryLoggingConfigs ?? []).map(toAttrs),
166 ),
167 ),
168 ),
169 // The configuration is immutable (no update API) — any property
170 // change replaces it.
171 diff: Effect.fn(function* ({ olds, news }) {
172 if (!isResolved(news)) return undefined;
173 if (
174 olds.hostedZoneId !== news.hostedZoneId ||
175 olds.cloudWatchLogsLogGroupArn !== news.cloudWatchLogsLogGroupArn
176 ) {
177 return { action: "replace" } as const;
178 }
179 }),
180 read: Effect.fn(function* ({ olds, output }) {
181 // Prefer the stored config id; fall back to the zone lookup
182 // (state-loss recovery). A half-created state row can't round-trip
183 // an Output-valued `hostedZoneId` — report "not found".
184 const config = output?.id
185 ? yield* observeById(output.id)
186 : olds?.hostedZoneId !== undefined
187 ? yield* observeByZone(olds.hostedZoneId)
188 : undefined;
189 if (!config) {
190 return undefined;
191 }
192 return toAttrs(config);
193 }),
194 reconcile: Effect.fn(function* ({ news, session }) {
195 // Observe — the zone's (at most one) live configuration.
196 let observed = yield* observeByZone(news.hostedZoneId);
197
198 // Sync — the configuration is immutable; an adopted/drifted config
199 // pointing at a different log group is converged by delete +
200 // recreate (routine prop changes replace via `diff` instead).
201 if (
202 observed &&
203 observed.CloudWatchLogsLogGroupArn !==
204 news.cloudWatchLogsLogGroupArn
205 ) {
206 yield* deleteById(observed.Id);
207 observed = undefined;
208 }
209
210 // Ensure — create when missing. `QueryLoggingConfigAlreadyExists`
211 // is a race with a peer reconciler; re-observe.
212 if (!observed) {
213 observed = yield* retryQueryLoggingCreate(
214 route53.createQueryLoggingConfig({
215 HostedZoneId: news.hostedZoneId,
216 CloudWatchLogsLogGroupArn: news.cloudWatchLogsLogGroupArn,
217 }),
218 ).pipe(
219 Effect.map((response) => response.QueryLoggingConfig),
220 Effect.catchTag("QueryLoggingConfigAlreadyExists", () =>
221 observeByZone(news.hostedZoneId).pipe(
222 Effect.flatMap((existing) =>
223 existing
224 ? Effect.succeed(existing)
225 : Effect.die(
226 `Route53 query logging config for zone '${news.hostedZoneId}' already exists but could not be observed`,
227 ),
228 ),
229 ),
230 ),
231 );
232 }
233
234 yield* session.note(observed.Id);
235 return toAttrs(observed);
236 }),
237 delete: Effect.fn(function* ({ output }) {
238 yield* deleteById(output.id);
239 }),
240 };
241 }),
242 );