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 DEFAULT_TTL = 300;
13const MAX_LABEL_LENGTH = 63;
14const SYSTEM_TYPES = new Set(["SOA", "NS"]);
15
16export type ResourceRecordSetRoutingPolicy = dns.RRSetRoutingPolicy;
17
18export type ResourceRecordSetProps = {
19 /**
20 * Managed zone name or numeric id that owns this record set. Immutable —
21 * changing it replaces the record set.
22 */
23 managedZone: string;
24 /**
25 * Fully qualified DNS name (e.g. `"www.example.com."`). A trailing dot
26 * is added if omitted. Relative names are qualified with the zone's
27 * DNS name. If omitted entirely, a unique subdomain of the zone is
28 * generated. Immutable — changing it replaces the record set.
29 */
30 name?: string;
31 /**
32 * DNS record type (`A`, `AAAA`, `CNAME`, `MX`, `TXT`, `NS`, …).
33 * Immutable — changing it replaces the record set.
34 */
35 type: string;
36 /**
37 * TTL in seconds.
38 * @default 300
39 */
40 ttl?: number;
41 /**
42 * Resource-record data. For `A` records this is a list of IPv4
43 * addresses; for `TXT`, quoted strings. Mutually exclusive with
44 * `routingPolicy`.
45 */
46 rrdatas?: string[];
47 /**
48 * Dynamic routing policy (geo / weighted-round-robin / primary-backup).
49 * A record set has either `rrdatas` or `routingPolicy`, not both.
50 */
51 routingPolicy?: ResourceRecordSetRoutingPolicy;
52};
53
54export type ResourceRecordSet = Resource<
55 "GCP.DNS.ResourceRecordSet",
56 ResourceRecordSetProps,
57 {
58 /** Project id. */
59 project: string;
60 /** Managed zone name. */
61 managedZone: string;
62 /** Fully qualified DNS name, with trailing dot. */
63 name: string;
64 /** DNS record type. */
65 type: string;
66 /** TTL in seconds. */
67 ttl: number;
68 /** Resource-record data. */
69 rrdatas: string[];
70 /** Dynamic routing policy, if this is not a static record. */
71 routingPolicy: ResourceRecordSetRoutingPolicy | undefined;
72 /** DNSSEC signature records, if present. */
73 signatureRrdatas: string[] | undefined;
74 /** Server-reported kind (`dns#resourceRecordSet`). */
75 kind: string | undefined;
76 },
77 never,
78 Providers
79>;
80
81/**
82 * A Cloud DNS resource record set inside a managed zone.
83 *
84 * Identity is `(managedZone, name, type)`. TTL, `rrdatas`, and
85 * `routingPolicy` are mutable. Record sets have no labels; `list` / nuke
86 * discover them by enumerating managed zones stamped with `alchemy-*`
87 * labels and skipping the zone's apex `SOA`/`NS` records.
88 *
89 * ### Creating a Record Set
90 * **Example:** A record
91 * ```typescript
92 * const zone = yield* GCP.DNS.ManagedZone("Public", { forceDestroy: true });
93 * const www = yield* GCP.DNS.ResourceRecordSet("Www", {
94 * managedZone: zone.zoneName,
95 * name: "www",
96 * type: "A",
97 * ttl: 300,
98 * rrdatas: ["203.0.113.10"],
99 * });
100 * ```
101 *
102 * **Example:** TXT record
103 * ```typescript
104 * const verify = yield* GCP.DNS.ResourceRecordSet("Verify", {
105 * managedZone: zone.zoneName,
106 * name: zone.dnsName,
107 * type: "TXT",
108 * ttl: 60,
109 * rrdatas: ['"v=spf1 -all"'],
110 * });
111 * ```
112 *
113 * **Example:** Generated subdomain
114 * ```typescript
115 * const record = yield* GCP.DNS.ResourceRecordSet("Probe", {
116 * managedZone: zone.zoneName,
117 * type: "A",
118 * rrdatas: ["203.0.113.20"],
119 * });
120 * ```
121 *
122 * @resource
123 * @category DNS
124 */
125export const ResourceRecordSet = Resource<ResourceRecordSet>(
126 "GCP.DNS.ResourceRecordSet",
127);
128
129export class ResourceRecordSetNotResolved extends Data.TaggedError(
130 "GCP.DNS.ResourceRecordSetNotResolved",
131)<{
132 managedZone: string;
133 name: string;
134 type: string;
135}> {}
136
137export class ResourceRecordSetZoneNotFound extends Data.TaggedError(
138 "GCP.DNS.ResourceRecordSetZoneNotFound",
139)<{
140 managedZone: string;
141}> {}
142
143const withTrailingDot = (name: string) =>
144 name.endsWith(".") ? name : `${name}.`;
145
146const normalizeFqdn = (name: string) => withTrailingDot(name).toLowerCase();
147
148const normalizeType = (type: string) => type.toUpperCase();
149
150const isRelativeName = (name: string) => {
151 const trimmed = name.endsWith(".") ? name.slice(0, -1) : name;
152 return !trimmed.includes(".");
153};
154
155const qualifyName = (name: string, dnsName: string) => {
156 const zone = normalizeFqdn(dnsName);
157 if (isRelativeName(name)) {
158 return `${name.replace(/\.$/, "").toLowerCase()}.${zone}`;
159 }
160 const fqdn = normalizeFqdn(name);
161 if (fqdn === zone || fqdn.endsWith(`.${zone}`)) return fqdn;
162 return `${name.replace(/\.$/, "").toLowerCase()}.${zone}`;
163};
164
165const dnsLabel = (value: string) => {
166 const cleaned = value.toLowerCase().replace(/[^a-z0-9-]/g, "-");
167 const trimmed = cleaned.replace(/^-+/, "").slice(0, MAX_LABEL_LENGTH);
168 const started = /^[a-z]/.test(trimmed) ? trimmed : `r${trimmed}`;
169 return started.replace(/-+$/g, "").slice(0, MAX_LABEL_LENGTH);
170};
171
172const sameRrdatas = (left: string[] | undefined, right: string[] | undefined) =>
173 JSON.stringify(left ?? []) === JSON.stringify(right ?? []);
174
175const sameRoutingPolicy = (
176 left: ResourceRecordSetRoutingPolicy | undefined,
177 right: ResourceRecordSetRoutingPolicy | undefined,
178) => JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
179
180const nameIdentityChanged = (
181 newsName: string | undefined,
182 previousName: string | undefined,
183) => {
184 if (newsName === undefined || previousName === undefined) return false;
185 const previous = normalizeFqdn(previousName);
186 if (isRelativeName(newsName)) {
187 const label = newsName.replace(/\.$/, "").toLowerCase();
188 return previous.split(".")[0] !== label;
189 }
190 return normalizeFqdn(newsName) !== previous;
191};
192
193const isApexSystemRecord = (
194 record: dns.ResourceRecordSet,
195 dnsName: string | undefined,
196) => {
197 const type = normalizeType(record.type ?? "");
198 if (!SYSTEM_TYPES.has(type)) return false;
199 if (dnsName === undefined || record.name === undefined) {
200 return type === "SOA";
201 }
202 return normalizeFqdn(record.name) === normalizeFqdn(dnsName);
203};
204
205const hasAlchemyZoneLabels = (
206 labels: Record<string, string | undefined> | null | undefined,
207) =>
208 Object.keys(labels ?? {}).some((key) => key.startsWith(ALCHEMY_LABEL_PREFIX));
209
210const toBody = (props: {
211 name: string;
212 type: string;
213 ttl: number;
214 rrdatas?: string[];
215 routingPolicy?: ResourceRecordSetRoutingPolicy;
216}): dns.ResourceRecordSet =>
217 props.routingPolicy !== undefined
218 ? {
219 name: props.name,
220 type: props.type,
221 ttl: props.ttl,
222 routingPolicy: props.routingPolicy,
223 }
224 : {
225 name: props.name,
226 type: props.type,
227 ttl: props.ttl,
228 rrdatas: props.rrdatas ?? [],
229 };
230
231const toAttrs = (
232 record: dns.ResourceRecordSet,
233 project: string,
234 managedZone: string,
235) => ({
236 project,
237 managedZone,
238 name: record.name ? normalizeFqdn(record.name) : "",
239 type: normalizeType(record.type ?? ""),
240 ttl: record.ttl ?? DEFAULT_TTL,
241 rrdatas: record.rrdatas ?? [],
242 routingPolicy: record.routingPolicy,
243 signatureRrdatas: record.signatureRrdatas,
244 kind: record.kind,
245});
246
247const getZone = (project: string, managedZone: string) =>
248 dns
249 .getManagedZones({ project, managedZone })
250 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
251
252const getByName = (
253 project: string,
254 managedZone: string,
255 name: string,
256 type: string,
257) =>
258 dns
259 .getResourceRecordSets({ project, managedZone, name, type })
260 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
261
262const toRecordName = (
263 id: string,
264 name: string | undefined,
265 existing: string | undefined,
266 dnsName: string,
267) =>
268 Effect.gen(function* () {
269 if (name !== undefined) return qualifyName(name, dnsName);
270 if (existing !== undefined) return normalizeFqdn(existing);
271 const generated = yield* createPhysicalName({
272 id,
273 maxLength: MAX_LABEL_LENGTH,
274 lowercase: true,
275 });
276 return `${dnsLabel(generated)}.${normalizeFqdn(dnsName)}`;
277 });
278
279const listRrsets = (project: string, zoneName: string) =>
280 Effect.gen(function* () {
281 const found: dns.ResourceRecordSet[] = [];
282 let pageToken: string | undefined;
283 for (let page = 0; page < 10; page++) {
284 const response = yield* dns.listResourceRecordSets({
285 project,
286 managedZone: zoneName,
287 maxResults: 1000,
288 pageToken,
289 });
290 found.push(...(response.rrsets ?? []));
291 pageToken = response.nextPageToken;
292 if (pageToken === undefined || pageToken === "") break;
293 }
294 return found;
295 }).pipe(
296 Effect.catchTag("NotFound", () =>
297 Effect.succeed([] as dns.ResourceRecordSet[]),
298 ),
299 );
300
301const listAlchemyZones = (project: string) =>
302 Effect.gen(function* () {
303 const found: dns.ManagedZone[] = [];
304 let pageToken: string | undefined;
305 for (let page = 0; page < 10; page++) {
306 const response = yield* dns.listManagedZones({
307 project,
308 maxResults: 1000,
309 pageToken,
310 });
311 for (const zone of response.managedZones ?? []) {
312 if (hasAlchemyZoneLabels(zone.labels)) found.push(zone);
313 }
314 pageToken = response.nextPageToken;
315 if (pageToken === undefined || pageToken === "") break;
316 }
317 return found;
318 });
319
320export const ResourceRecordSetProvider = () =>
321 Provider.succeed(ResourceRecordSet, {
322 stables: ["project", "managedZone", "name", "type"],
323
324 diff: Effect.fn(function* ({ news, olds, output }) {
325 if (!isResolved(news)) return undefined;
326 const previousZone = olds?.managedZone ?? output?.managedZone;
327 const previousName = olds?.name ?? output?.name;
328 const previousType = olds?.type ?? output?.type;
329 const nextType = normalizeType(news.type);
330 const zoneChanged =
331 previousZone !== undefined && news.managedZone !== previousZone;
332 const typeChanged =
333 previousType !== undefined && nextType !== normalizeType(previousType);
334 const nameChanged = nameIdentityChanged(news.name, previousName);
335 if (zoneChanged || typeChanged || nameChanged) {
336 return { action: "replace" as const, deleteFirst: false };
337 }
338 return undefined;
339 }),
340
341 read: Effect.fn(function* ({ id, olds, output }) {
342 const env = yield* GcpEnvironment.current;
343 const managedZone = output?.managedZone ?? olds?.managedZone;
344 const typeRaw = output?.type ?? olds?.type;
345 if (managedZone === undefined || typeRaw === undefined) {
346 return undefined;
347 }
348 const type = normalizeType(typeRaw);
349 const zone = yield* getZone(env.project, managedZone);
350 if (zone === undefined || zone.name === undefined) return undefined;
351 const name = yield* toRecordName(
352 id,
353 olds?.name,
354 output?.name,
355 zone.dnsName ?? zone.name,
356 );
357 const existing = yield* getByName(env.project, zone.name, name, type);
358 if (existing === undefined) return undefined;
359 // Record sets have no labels. Identity (zone + name + type) is
360 // ownership — same as Route 53 records.
361 return toAttrs(existing, env.project, zone.name);
362 }),
363
364 list: () =>
365 Effect.gen(function* () {
366 const env = yield* GcpEnvironment.current;
367 const zones = yield* listAlchemyZones(env.project);
368 const pages = yield* Effect.forEach(
369 zones,
370 (zone) =>
371 zone.name
372 ? listRrsets(env.project, zone.name).pipe(
373 Effect.map((records) =>
374 records
375 .filter(
376 (record) => !isApexSystemRecord(record, zone.dnsName),
377 )
378 .map((record) =>
379 toAttrs(record, env.project, zone.name ?? ""),
380 ),
381 ),
382 )
383 : Effect.succeed([] as ReturnType<typeof toAttrs>[]),
384 { concurrency: 4 },
385 );
386 return pages.flat();
387 }),
388
389 reconcile: Effect.fn(function* ({ id, news, output }) {
390 const env = yield* GcpEnvironment.current;
391 const zone = yield* getZone(env.project, news.managedZone);
392 if (zone === undefined || zone.name === undefined) {
393 return yield* new ResourceRecordSetZoneNotFound({
394 managedZone: news.managedZone,
395 });
396 }
397 const managedZone = zone.name;
398 const type = normalizeType(news.type);
399 const name = yield* toRecordName(
400 id,
401 news.name,
402 output?.name,
403 zone.dnsName ?? managedZone,
404 );
405 const ttl = news.ttl ?? DEFAULT_TTL;
406 const desired = toBody({
407 name,
408 type,
409 ttl,
410 rrdatas: news.rrdatas,
411 routingPolicy: news.routingPolicy,
412 });
413
414 let current = yield* getByName(env.project, managedZone, name, type);
415
416 if (current === undefined) {
417 const created = yield* dns
418 .createResourceRecordSets({
419 project: env.project,
420 managedZone,
421 body: desired,
422 })
423 .pipe(
424 Effect.catchTag("Conflict", () =>
425 getByName(env.project, managedZone, name, type),
426 ),
427 );
428 current = created ?? undefined;
429 }
430
431 if (current === undefined) {
432 return yield* new ResourceRecordSetNotResolved({
433 managedZone,
434 name,
435 type,
436 });
437 }
438
439 const ttlChanged = (current.ttl ?? DEFAULT_TTL) !== ttl;
440 const rrdatasChanged = !sameRrdatas(current.rrdatas, desired.rrdatas);
441 const routingChanged = !sameRoutingPolicy(
442 current.routingPolicy,
443 desired.routingPolicy,
444 );
445
446 if (ttlChanged || rrdatasChanged || routingChanged) {
447 current = yield* dns.patchResourceRecordSets({
448 project: env.project,
449 managedZone,
450 name,
451 type,
452 body: desired,
453 });
454 }
455
456 return toAttrs(current, env.project, managedZone);
457 }),
458
459 delete: Effect.fn(function* ({ output }) {
460 yield* dns
461 .deleteResourceRecordSets({
462 project: output.project,
463 managedZone: output.managedZone,
464 name: output.name,
465 type: output.type,
466 })
467 .pipe(Effect.catchTag("NotFound", () => Effect.void));
468 }),
469 });