1import * as dns from "@distilled.cloud/gcp/dns_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 createInternalLabels,
13 diffLabels,
14 hasAlchemyLabels,
15 stripInternalLabels,
16 toLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19import {
20 type LongRunningOperation,
21 waitForOperation as waitForLongRunning,
22} from "../Operation.ts";
23
24const MAX_NAME_LENGTH = 63;
25const DEFAULT_VISIBILITY = "public";
26const GENERATED_DNS_SUFFIX = ".alchemy-gcp-test.";
27
28export type ManagedZoneVisibility = "public" | "private";
29
30export type ManagedZoneProps = {
31 /**
32 * User-assigned zone name, unique within the project. If omitted, a
33 * unique RFC1035 name is generated from the stack, stage, and logical
34 * id. Must be 1-63 characters, begin with a letter, end with a letter
35 * or digit, and contain only lowercase letters, digits, or dashes.
36 * Immutable — changing it replaces the zone.
37 */
38 zoneName?: string;
39 /**
40 * DNS name of the zone, for instance `"example.com."`. A trailing dot
41 * is added if omitted. If the prop is omitted entirely, a unique name
42 * is derived from `zoneName` (`{zoneName}.alchemy-gcp-test.`).
43 * Immutable — changing it replaces the zone.
44 */
45 dnsName?: string;
46 /**
47 * Human-readable description (max 1024 characters).
48 */
49 description?: string;
50 /**
51 * Zone visibility. Public zones are exposed to the Internet; private
52 * zones are visible only to VPC resources listed in `networks`.
53 * Immutable — changing it replaces the zone.
54 * @default "public"
55 */
56 visibility?: ManagedZoneVisibility;
57 /**
58 * VPC networks that can query a private zone. Each value may be a
59 * network name, a `projects/.../global/networks/...` path, or a full
60 * compute URL. Ignored for public zones. Updates in place.
61 */
62 networks?: string[];
63 /**
64 * Enable Cloud DNS query logging. Public zones only.
65 * @default false
66 */
67 enableLogging?: boolean;
68 /**
69 * User labels. Alchemy ownership labels are merged in automatically.
70 */
71 labels?: Record<string, string>;
72 /**
73 * Delete all non-SOA/NS record sets before destroying the zone.
74 * @default false
75 */
76 forceDestroy?: boolean;
77};
78
79export type ManagedZone = Resource<
80 "GCP.DNS.ManagedZone",
81 ManagedZoneProps,
82 {
83 /** User-assigned zone name. */
84 zoneName: string;
85 /** DNS name, including the trailing dot. */
86 dnsName: string;
87 /** Project id. */
88 project: string;
89 /** Server-assigned numeric id. */
90 id: string | undefined;
91 /** `public` or `private`. */
92 visibility: string;
93 /** User description. */
94 description: string | undefined;
95 /** User labels (Alchemy ownership labels stripped). */
96 labels: Record<string, string>;
97 /** Whether query logging is enabled. */
98 enableLogging: boolean;
99 /** VPC network URLs for a private zone. */
100 networks: ReadonlyArray<string>;
101 /** Authoritative name servers (output only). */
102 nameServers: ReadonlyArray<string>;
103 /** RFC3339 creation timestamp. */
104 creationTime: string | undefined;
105 },
106 never,
107 Providers
108>;
109
110/**
111 * A Cloud DNS managed zone.
112 *
113 * Name, DNS name, and visibility are identity — changing them replaces
114 * the zone. Description, labels, query logging, and the private-zone
115 * network list update in place.
116 *
117 * ### Creating a Zone
118 * **Example:** Generated name (public)
119 * ```typescript
120 * const zone = yield* GCP.DNS.ManagedZone("Public", {
121 * forceDestroy: true,
122 * });
123 * ```
124 *
125 * **Example:** Explicit name, DNS name, and labels
126 * ```typescript
127 * const zone = yield* GCP.DNS.ManagedZone("Public", {
128 * zoneName: "app-public",
129 * dnsName: "app.example.com.",
130 * description: "application public zone",
131 * labels: { env: "prod" },
132 * enableLogging: true,
133 * forceDestroy: true,
134 * });
135 * ```
136 *
137 * ### Private Zone
138 * **Example:** Private zone visible from one VPC
139 * ```typescript
140 * const zone = yield* GCP.DNS.ManagedZone("Internal", {
141 * dnsName: "internal.example.com.",
142 * visibility: "private",
143 * networks: ["app-vpc"],
144 * forceDestroy: true,
145 * });
146 * ```
147 *
148 * @resource
149 * @category DNS
150 */
151export const ManagedZone = Resource<ManagedZone>("GCP.DNS.ManagedZone");
152
153export class ManagedZoneNotResolved extends Data.TaggedError(
154 "GCP.DNS.ManagedZoneNotResolved",
155)<{
156 zoneName: string;
157}> {}
158
159const userLabels = (
160 labels: Record<string, string | undefined> | null | undefined,
161): Record<string, string> => stripInternalLabels(tagRecord(labels));
162
163const lastSegment = (value: string) => {
164 const trimmed = value.replace(/\/+$/, "");
165 const parts = trimmed.split("/");
166 return parts[parts.length - 1] || trimmed;
167};
168
169const isDone = (status: string | undefined) =>
170 (status ?? "").toLowerCase() === "done";
171
172const normalizeDnsName = (value: string) =>
173 value.endsWith(".") ? value : `${value}.`;
174
175const normalizeVisibility = (value: string | undefined) =>
176 (value ?? DEFAULT_VISIBILITY).toLowerCase();
177
178const toZoneName = (id: string, name: string | undefined, existing?: string) =>
179 Effect.gen(function* () {
180 if (name !== undefined) return name;
181 if (existing !== undefined) return existing;
182 const generated = yield* createPhysicalName({
183 id,
184 maxLength: MAX_NAME_LENGTH,
185 lowercase: true,
186 });
187 return /^[a-z]/.test(generated)
188 ? generated
189 : `a${generated}`.slice(0, MAX_NAME_LENGTH);
190 });
191
192const toDnsName = (
193 zoneName: string,
194 dnsName: string | undefined,
195 existing?: string,
196) =>
197 dnsName !== undefined
198 ? normalizeDnsName(dnsName)
199 : existing !== undefined
200 ? normalizeDnsName(existing)
201 : `${zoneName}${GENERATED_DNS_SUFFIX}`;
202
203const toNetworkUrl = (project: string, network: string) => {
204 if (network.startsWith("https://") || network.startsWith("http://")) {
205 return network;
206 }
207 if (network.includes("/")) {
208 const path = network.replace(/^\//, "");
209 return path.startsWith("compute/")
210 ? `https://www.googleapis.com/${path}`
211 : `https://www.googleapis.com/compute/v1/${path}`;
212 }
213 return `https://www.googleapis.com/compute/v1/projects/${project}/global/networks/${network}`;
214};
215
216const desiredNetworks = (project: string, networks: string[] | undefined) =>
217 [
218 ...new Set(
219 (networks ?? []).map((network) => toNetworkUrl(project, network)),
220 ),
221 ].sort((left, right) => lastSegment(left).localeCompare(lastSegment(right)));
222
223const observedNetworks = (zone: dns.ManagedZone) =>
224 (zone.privateVisibilityConfig?.networks ?? [])
225 .map((network) => network.networkUrl ?? "")
226 .filter((url) => url.length > 0)
227 .sort((left, right) => lastSegment(left).localeCompare(lastSegment(right)));
228
229const sameNetworks = (left: string[], right: string[]) =>
230 left.length === right.length &&
231 left.every(
232 (url, index) => lastSegment(url) === lastSegment(right[index] ?? ""),
233 );
234
235const toAttrs = (zone: dns.ManagedZone, project: string) => ({
236 zoneName: zone.name ?? "",
237 dnsName: zone.dnsName ?? "",
238 project,
239 id: zone.id,
240 visibility: zone.visibility ?? DEFAULT_VISIBILITY,
241 description:
242 zone.description && zone.description.length > 0
243 ? zone.description
244 : undefined,
245 labels: userLabels(zone.labels),
246 enableLogging: zone.cloudLoggingConfig?.enableLogging === true,
247 networks: observedNetworks(zone),
248 nameServers: zone.nameServers ?? [],
249 creationTime: zone.creationTime,
250});
251
252const getByName = (project: string, zoneName: string) =>
253 dns
254 .getManagedZones({ project, managedZone: zoneName })
255 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
256
257/** Cloud DNS operations and changes report `status: "pending" | "done"`. */
258const asLongRunning = (value: {
259 id?: string;
260 status?: string;
261}): LongRunningOperation => ({ name: value.id, done: isDone(value.status) });
262
263/** Zone updates and record changes propagate within a few minutes. */
264const DNS_BUDGET = "5 minutes";
265
266const waitForOperation = (
267 project: string,
268 zoneName: string,
269 operation: dns.Operation,
270) =>
271 waitForLongRunning(
272 asLongRunning(operation),
273 (operationId) =>
274 dns
275 .getManagedZoneOperations({
276 project,
277 managedZone: zoneName,
278 operation: operationId,
279 })
280 .pipe(Effect.map(asLongRunning)),
281 { budget: DNS_BUDGET, interval: "1 second" },
282 );
283
284const waitForChange = (project: string, zoneName: string, change: dns.Change) =>
285 waitForLongRunning(
286 asLongRunning(change),
287 (changeId) =>
288 dns
289 .getChanges({ project, managedZone: zoneName, changeId })
290 .pipe(Effect.map(asLongRunning)),
291 { budget: DNS_BUDGET, interval: "1 second" },
292 );
293
294const listRrsets = (project: string, zoneName: string) =>
295 Effect.gen(function* () {
296 const found: dns.ResourceRecordSet[] = [];
297 let pageToken: string | undefined;
298 for (let page = 0; page < 10; page++) {
299 const response = yield* dns.listResourceRecordSets({
300 project,
301 managedZone: zoneName,
302 maxResults: 1000,
303 pageToken,
304 });
305 found.push(...(response.rrsets ?? []));
306 pageToken = response.nextPageToken;
307 if (pageToken === undefined || pageToken === "") break;
308 }
309 return found;
310 }).pipe(
311 Effect.catchTag("NotFound", () =>
312 Effect.succeed([] as dns.ResourceRecordSet[]),
313 ),
314 );
315
316const emptyZone = (
317 project: string,
318 zoneName: string,
319 dnsName: string | undefined,
320) =>
321 Effect.gen(function* () {
322 const apex = dnsName ? normalizeDnsName(dnsName) : undefined;
323 const extra = (yield* listRrsets(project, zoneName)).filter((rrset) => {
324 const type = rrset.type ?? "";
325 const name = rrset.name ?? "";
326 if (
327 apex !== undefined &&
328 name === apex &&
329 (type === "NS" || type === "SOA")
330 ) {
331 return false;
332 }
333 return true;
334 });
335 if (extra.length === 0) return;
336 const change = yield* dns.createChanges({
337 project,
338 managedZone: zoneName,
339 body: { deletions: extra },
340 });
341 yield* waitForChange(project, zoneName, change);
342 });
343
344const privateVisibilityConfig = (networks: string[]) =>
345 networks.length === 0
346 ? undefined
347 : {
348 networks: networks.map((networkUrl) => ({ networkUrl })),
349 };
350
351export const ManagedZoneProvider = () =>
352 Provider.succeed(ManagedZone, {
353 stables: [
354 "zoneName",
355 "dnsName",
356 "project",
357 "id",
358 "visibility",
359 "nameServers",
360 "creationTime",
361 ],
362
363 diff: Effect.fn(function* ({ news, olds, output }) {
364 if (!isResolved(news)) return undefined;
365
366 const previousName = olds?.zoneName ?? output?.zoneName;
367 const nextName = news.zoneName ?? previousName;
368 const nameChanged =
369 previousName !== undefined &&
370 nextName !== undefined &&
371 nextName !== previousName;
372
373 const previousDns = olds?.dnsName ?? output?.dnsName;
374 const nextDns =
375 news.dnsName !== undefined
376 ? normalizeDnsName(news.dnsName)
377 : previousDns !== undefined
378 ? normalizeDnsName(previousDns)
379 : undefined;
380 const dnsChanged =
381 previousDns !== undefined &&
382 nextDns !== undefined &&
383 normalizeDnsName(previousDns) !== nextDns;
384
385 const previousVisibility = normalizeVisibility(
386 olds?.visibility ?? output?.visibility,
387 );
388 const nextVisibility = normalizeVisibility(
389 news.visibility ?? previousVisibility,
390 );
391 const visibilityChanged = previousVisibility !== nextVisibility;
392
393 if (!nameChanged && !dnsChanged && !visibilityChanged) {
394 return undefined;
395 }
396 return {
397 action: "replace" as const,
398 deleteFirst:
399 previousName !== undefined &&
400 nextName !== undefined &&
401 nextName === previousName,
402 };
403 }),
404
405 read: Effect.fn(function* ({ id, olds, output }) {
406 const env = yield* GcpEnvironment.current;
407 const zoneName = yield* toZoneName(id, olds?.zoneName, output?.zoneName);
408 const existing = yield* getByName(env.project, zoneName);
409 if (existing === undefined) return undefined;
410 const attrs = toAttrs(existing, env.project);
411 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
412 ? attrs
413 : Unowned(attrs);
414 }),
415
416 list: () =>
417 Effect.gen(function* () {
418 const env = yield* GcpEnvironment.current;
419 const found: ReturnType<typeof toAttrs>[] = [];
420 let pageToken: string | undefined;
421 for (let page = 0; page < 10; page++) {
422 const response = yield* dns.listManagedZones({
423 project: env.project,
424 maxResults: 1000,
425 pageToken,
426 });
427 for (const zone of response.managedZones ?? []) {
428 if (
429 Object.keys(zone.labels ?? {}).some((key) =>
430 key.startsWith("alchemy-"),
431 )
432 ) {
433 found.push(toAttrs(zone, env.project));
434 }
435 }
436 pageToken = response.nextPageToken;
437 if (pageToken === undefined || pageToken === "") break;
438 }
439 return found;
440 }),
441
442 reconcile: Effect.fn(function* ({ id, news, output }) {
443 const env = yield* GcpEnvironment.current;
444 const zoneName = yield* toZoneName(id, news.zoneName, output?.zoneName);
445 const dnsName = toDnsName(zoneName, news.dnsName, output?.dnsName);
446 const visibility = normalizeVisibility(
447 news.visibility ?? output?.visibility,
448 );
449 const description = news.description ?? "";
450 const enableLogging = news.enableLogging === true;
451 const networks =
452 visibility === "private"
453 ? desiredNetworks(env.project, news.networks)
454 : [];
455 const desiredLabels = {
456 ...toLabels(news.labels),
457 ...(yield* createInternalLabels(id)),
458 };
459
460 let current = yield* getByName(env.project, zoneName);
461
462 if (current === undefined) {
463 const created = yield* dns
464 .createManagedZones({
465 project: env.project,
466 body: {
467 name: zoneName,
468 dnsName,
469 description,
470 visibility,
471 labels: desiredLabels,
472 cloudLoggingConfig: { enableLogging },
473 privateVisibilityConfig: privateVisibilityConfig(networks),
474 },
475 })
476 .pipe(
477 Effect.catchTag("Conflict", () => getByName(env.project, zoneName)),
478 );
479 current = created ?? undefined;
480 }
481
482 if (current === undefined) {
483 return yield* new ManagedZoneNotResolved({ zoneName });
484 }
485
486 const observedLabels = tagRecord(current.labels);
487 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
488 const labelsChanged = upsert.length > 0 || removed.length > 0;
489 const descriptionChanged = (current.description ?? "") !== description;
490 const loggingChanged =
491 (current.cloudLoggingConfig?.enableLogging === true) !== enableLogging;
492 const networksChanged =
493 visibility === "private" &&
494 !sameNetworks(observedNetworks(current), networks);
495
496 if (
497 labelsChanged ||
498 descriptionChanged ||
499 loggingChanged ||
500 networksChanged
501 ) {
502 const body: dns.ManagedZone = {};
503 if (labelsChanged) body.labels = desiredLabels;
504 if (descriptionChanged) body.description = description;
505 if (loggingChanged) {
506 body.cloudLoggingConfig = { enableLogging };
507 }
508 if (networksChanged) {
509 body.privateVisibilityConfig = privateVisibilityConfig(networks);
510 }
511 const operation = yield* dns.patchManagedZones({
512 project: env.project,
513 managedZone: zoneName,
514 body,
515 });
516 yield* waitForOperation(env.project, zoneName, operation);
517 current = (yield* getByName(env.project, zoneName)) ?? current;
518 }
519
520 return toAttrs(current, env.project);
521 }),
522
523 delete: Effect.fn(function* ({ olds, output, force }) {
524 const mayEmpty = olds.forceDestroy === true || force === true;
525 const project = output.project;
526 const zoneName = output.zoneName;
527 if (mayEmpty) {
528 yield* emptyZone(project, zoneName, output.dnsName);
529 }
530 const attempt = dns
531 .deleteManagedZones({ project, managedZone: zoneName })
532 .pipe(Effect.catchTag("NotFound", () => Effect.void));
533 yield* attempt.pipe(
534 Effect.catchIf(
535 (error) =>
536 mayEmpty &&
537 (error._tag === "Conflict" || error._tag === "BadRequest"),
538 () =>
539 emptyZone(project, zoneName, output.dnsName).pipe(
540 Effect.andThen(attempt),
541 ),
542 ),
543 );
544 }),
545 });