1import * as dns from "@distilled.cloud/gcp/dns_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { ALCHEMY_LABEL_PREFIX } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11
12const MAX_NAME_LENGTH = 63;
13const DEFAULT_TTL = 300;
14
15export type ResponsePolicyRuleLocalData = {
16 /**
17 * DNS name for this record. Defaults to the rule's `dnsName`.
18 */
19 name?: string;
20 /**
21 * DNS record type (`A`, `AAAA`, `CNAME`, `TXT`, …). SOA and NS are
22 * not allowed.
23 */
24 type: string;
25 /**
26 * TTL in seconds.
27 * @default 300
28 */
29 ttl?: number;
30 /**
31 * Resource-record data.
32 */
33 rrdatas?: string[];
34};
35
36export type ResponsePolicyRuleBehavior = dns.ResponsePolicyRuleBehaviorEnum;
37
38export type ResponsePolicyRuleProps = {
39 /**
40 * Response policy name that owns this rule. Immutable — changing it
41 * replaces the rule.
42 */
43 responsePolicy: string;
44 /**
45 * User-assigned rule name, unique within the response policy. If
46 * omitted, a unique RFC1035 name is generated from the stack, stage,
47 * and logical id. Immutable — changing it replaces the rule.
48 */
49 ruleName?: string;
50 /**
51 * DNS name (wildcard or exact) this rule matches, for instance
52 * `"internal.example.com."` or `"*.example.com."`. A trailing dot is
53 * added if omitted.
54 */
55 dnsName: string;
56 /**
57 * Local DNS answers that override private zones, public DNS, and GCP
58 * internal DNS for the matched name. Mutually exclusive with
59 * `behavior`.
60 */
61 localData?: ResponsePolicyRuleLocalData[];
62 /**
63 * Answer with a behavior instead of DNS data. `bypassResponsePolicy`
64 * skips this response policy (and falls through to ordinary DNS) for
65 * the matched name. Mutually exclusive with `localData`.
66 */
67 behavior?: ResponsePolicyRuleBehavior;
68};
69
70export type ResponsePolicyRule = Resource<
71 "GCP.DNS.ResponsePolicyRule",
72 ResponsePolicyRuleProps,
73 {
74 /** Project id. */
75 project: string;
76 /** Parent response policy name. */
77 responsePolicy: string;
78 /** User-assigned rule name. */
79 ruleName: string;
80 /** DNS name selector, with trailing dot. */
81 dnsName: string;
82 /** Local override records, if this is not a behavior rule. */
83 localData: ReadonlyArray<ResponsePolicyRuleLocalData>;
84 /** Behavior, if this is not a local-data rule. */
85 behavior: string | undefined;
86 /** Server-reported kind (`dns#responsePolicyRule`). */
87 kind: string | undefined;
88 },
89 never,
90 Providers
91>;
92
93/**
94 * A Cloud DNS response policy rule.
95 *
96 * Identity is `(responsePolicy, ruleName)`. `dnsName`, `localData`, and
97 * `behavior` update in place. Rules have no labels; `list` / nuke
98 * discover them by enumerating response policies stamped with
99 * `alchemy-*` labels.
100 *
101 * ### Creating a Rule
102 * **Example:** Local A record override
103 * ```typescript
104 * const policy = yield* GCP.DNS.ResponsePolicy("Overrides", {
105 * networks: ["app-vpc"],
106 * });
107 * const rule = yield* GCP.DNS.ResponsePolicyRule("Internal", {
108 * responsePolicy: policy.responsePolicyName,
109 * dnsName: "app.internal.example.com.",
110 * localData: [{ type: "A", ttl: 300, rrdatas: ["10.0.0.10"] }],
111 * });
112 * ```
113 *
114 * **Example:** Bypass the response policy for a name
115 * ```typescript
116 * const passthrough = yield* GCP.DNS.ResponsePolicyRule("Passthrough", {
117 * responsePolicy: policy.responsePolicyName,
118 * dnsName: "cdn.example.com.",
119 * behavior: "bypassResponsePolicy",
120 * });
121 * ```
122 *
123 * @resource
124 * @category DNS
125 */
126export const ResponsePolicyRule = Resource<ResponsePolicyRule>(
127 "GCP.DNS.ResponsePolicyRule",
128);
129
130export class ResponsePolicyRuleNotResolved extends Data.TaggedError(
131 "GCP.DNS.ResponsePolicyRuleNotResolved",
132)<{
133 responsePolicy: string;
134 ruleName: string;
135}> {}
136
137export class ResponsePolicyRulePolicyNotFound extends Data.TaggedError(
138 "GCP.DNS.ResponsePolicyRulePolicyNotFound",
139)<{
140 responsePolicy: string;
141}> {}
142
143const withTrailingDot = (name: string) =>
144 name.endsWith(".") ? name : `${name}.`;
145
146const normalizeFqdn = (name: string) => withTrailingDot(name).toLowerCase();
147
148const toRuleName = (id: string, name: string | undefined, existing?: string) =>
149 Effect.gen(function* () {
150 if (name !== undefined) return name;
151 if (existing !== undefined) return existing;
152 const generated = yield* createPhysicalName({
153 id,
154 maxLength: MAX_NAME_LENGTH,
155 lowercase: true,
156 });
157 const rfc = generated
158 .replace(/[^a-z0-9-]/g, "-")
159 .replace(/^[^a-z]+/, "r")
160 .slice(0, MAX_NAME_LENGTH)
161 .replace(/-+$/g, "");
162 return rfc.length > 0 ? rfc : "r";
163 });
164
165const toLocalData = (
166 dnsName: string,
167 records: ResponsePolicyRuleLocalData[] | undefined,
168): ResponsePolicyRuleLocalData[] =>
169 (records ?? []).map((record) => ({
170 name: normalizeFqdn(record.name ?? dnsName),
171 type: record.type.toUpperCase(),
172 ttl: record.ttl ?? DEFAULT_TTL,
173 rrdatas: record.rrdatas ?? [],
174 }));
175
176const observedLocalData = (
177 rule: dns.ResponsePolicyRule,
178): ResponsePolicyRuleLocalData[] =>
179 (rule.localData?.localDatas ?? []).map((record) => ({
180 name: record.name ? normalizeFqdn(record.name) : "",
181 type: (record.type ?? "").toUpperCase(),
182 ttl: record.ttl ?? DEFAULT_TTL,
183 rrdatas: record.rrdatas ?? [],
184 }));
185
186const sameLocalData = (
187 left: ResponsePolicyRuleLocalData[],
188 right: ResponsePolicyRuleLocalData[],
189) => JSON.stringify(left) === JSON.stringify(right);
190
191const toBody = (props: {
192 ruleName: string;
193 dnsName: string;
194 localData: ResponsePolicyRuleLocalData[];
195 behavior: string | undefined;
196}): dns.ResponsePolicyRule => {
197 const body: dns.ResponsePolicyRule = {
198 ruleName: props.ruleName,
199 dnsName: props.dnsName,
200 };
201 if (props.localData.length > 0) {
202 body.localData = {
203 localDatas: props.localData.map((record) => ({
204 name: record.name,
205 type: record.type,
206 ttl: record.ttl,
207 rrdatas: record.rrdatas,
208 })),
209 };
210 } else if (props.behavior !== undefined) {
211 body.behavior = props.behavior;
212 }
213 return body;
214};
215
216const toAttrs = (
217 rule: dns.ResponsePolicyRule,
218 project: string,
219 responsePolicy: string,
220) => ({
221 project,
222 responsePolicy,
223 ruleName: rule.ruleName ?? "",
224 dnsName: rule.dnsName ? normalizeFqdn(rule.dnsName) : "",
225 localData: observedLocalData(rule),
226 behavior: rule.behavior,
227 kind: rule.kind,
228});
229
230const getPolicy = (project: string, responsePolicy: string) =>
231 dns
232 .getResponsePolicies({ project, responsePolicy })
233 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
234
235const getByName = (project: string, responsePolicy: string, ruleName: string) =>
236 dns
237 .getResponsePolicyRules({
238 project,
239 responsePolicy,
240 responsePolicyRule: ruleName,
241 })
242 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
243
244const listRules = (project: string, responsePolicy: string) =>
245 Effect.gen(function* () {
246 const found: dns.ResponsePolicyRule[] = [];
247 let pageToken: string | undefined;
248 for (let page = 0; page < 10; page++) {
249 const response = yield* dns.listResponsePolicyRules({
250 project,
251 responsePolicy,
252 maxResults: 1000,
253 pageToken,
254 });
255 found.push(...(response.responsePolicyRules ?? []));
256 pageToken = response.nextPageToken;
257 if (pageToken === undefined || pageToken === "") break;
258 }
259 return found;
260 }).pipe(
261 Effect.catchTag("NotFound", () =>
262 Effect.succeed([] as dns.ResponsePolicyRule[]),
263 ),
264 );
265
266const listAlchemyResponsePolicies = (project: string) =>
267 Effect.gen(function* () {
268 const found: dns.ResponsePolicy[] = [];
269 let pageToken: string | undefined;
270 for (let page = 0; page < 10; page++) {
271 const response = yield* dns.listResponsePolicies({
272 project,
273 maxResults: 1000,
274 pageToken,
275 });
276 for (const policy of response.responsePolicies ?? []) {
277 if (
278 Object.keys(policy.labels ?? {}).some((key) =>
279 key.startsWith(ALCHEMY_LABEL_PREFIX),
280 )
281 ) {
282 found.push(policy);
283 }
284 }
285 pageToken = response.nextPageToken;
286 if (pageToken === undefined || pageToken === "") break;
287 }
288 return found;
289 });
290
291export const ResponsePolicyRuleProvider = () =>
292 Provider.succeed(ResponsePolicyRule, {
293 stables: ["project", "responsePolicy", "ruleName"],
294
295 diff: Effect.fn(function* ({ news, olds, output }) {
296 if (!isResolved(news)) return undefined;
297 const previousPolicy = olds?.responsePolicy ?? output?.responsePolicy;
298 const previousName = olds?.ruleName ?? output?.ruleName;
299 const nextName = news.ruleName ?? previousName;
300 const policyChanged =
301 previousPolicy !== undefined && news.responsePolicy !== previousPolicy;
302 const nameChanged =
303 previousName !== undefined &&
304 nextName !== undefined &&
305 nextName !== previousName;
306 if (!policyChanged && !nameChanged) return undefined;
307 return { action: "replace" as const, deleteFirst: false };
308 }),
309
310 read: Effect.fn(function* ({ id, olds, output }) {
311 const env = yield* GcpEnvironment.current;
312 const responsePolicy = output?.responsePolicy ?? olds?.responsePolicy;
313 if (responsePolicy === undefined) return undefined;
314 const ruleName = yield* toRuleName(id, olds?.ruleName, output?.ruleName);
315 const existing = yield* getByName(env.project, responsePolicy, ruleName);
316 if (existing === undefined) return undefined;
317 // Rules have no labels. Identity (policy + rule name) is
318 // ownership — same as Cloud DNS record sets.
319 return toAttrs(existing, env.project, responsePolicy);
320 }),
321
322 list: () =>
323 Effect.gen(function* () {
324 const env = yield* GcpEnvironment.current;
325 const policies = yield* listAlchemyResponsePolicies(env.project);
326 const pages = yield* Effect.forEach(
327 policies,
328 (policy) =>
329 policy.responsePolicyName
330 ? listRules(env.project, policy.responsePolicyName).pipe(
331 Effect.map((rules) =>
332 rules.map((rule) =>
333 toAttrs(
334 rule,
335 env.project,
336 policy.responsePolicyName ?? "",
337 ),
338 ),
339 ),
340 )
341 : Effect.succeed([] as ReturnType<typeof toAttrs>[]),
342 { concurrency: 4 },
343 );
344 return pages.flat();
345 }),
346
347 reconcile: Effect.fn(function* ({ id, news, output }) {
348 const env = yield* GcpEnvironment.current;
349 const policy = yield* getPolicy(env.project, news.responsePolicy);
350 if (policy === undefined || policy.responsePolicyName === undefined) {
351 return yield* new ResponsePolicyRulePolicyNotFound({
352 responsePolicy: news.responsePolicy,
353 });
354 }
355 const responsePolicy = policy.responsePolicyName;
356 const ruleName = yield* toRuleName(id, news.ruleName, output?.ruleName);
357 const dnsName = normalizeFqdn(news.dnsName);
358 const localData = toLocalData(dnsName, news.localData);
359 const behavior = localData.length > 0 ? undefined : news.behavior;
360 const desired = toBody({
361 ruleName,
362 dnsName,
363 localData,
364 behavior,
365 });
366
367 let current = yield* getByName(env.project, responsePolicy, ruleName);
368
369 if (current === undefined) {
370 const created = yield* dns
371 .createResponsePolicyRules({
372 project: env.project,
373 responsePolicy,
374 body: desired,
375 })
376 .pipe(
377 Effect.catchTag("Conflict", () =>
378 getByName(env.project, responsePolicy, ruleName),
379 ),
380 );
381 current = created ?? undefined;
382 }
383
384 if (current === undefined) {
385 return yield* new ResponsePolicyRuleNotResolved({
386 responsePolicy,
387 ruleName,
388 });
389 }
390
391 const dnsChanged = normalizeFqdn(current.dnsName ?? "") !== dnsName;
392 const localChanged = !sameLocalData(
393 observedLocalData(current),
394 localData,
395 );
396 const behaviorChanged = (current.behavior ?? undefined) !== behavior;
397
398 if (dnsChanged || localChanged || behaviorChanged) {
399 const patched = yield* dns.patchResponsePolicyRules({
400 project: env.project,
401 responsePolicy,
402 responsePolicyRule: ruleName,
403 body: desired,
404 });
405 current =
406 patched.responsePolicyRule ??
407 (yield* getByName(env.project, responsePolicy, ruleName)) ??
408 current;
409 }
410
411 return toAttrs(current, env.project, responsePolicy);
412 }),
413
414 delete: Effect.fn(function* ({ output }) {
415 yield* dns
416 .deleteResponsePolicyRules({
417 project: output.project,
418 responsePolicy: output.responsePolicy,
419 responsePolicyRule: output.ruleName,
420 })
421 .pipe(Effect.catchTag("NotFound", () => Effect.void));
422 }),
423 });