1import * as dns from "@distilled.cloud/gcp/dns_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 alchemyLabelKeys,
13 createInternalLabels,
14 hasAlchemyLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17
18const MAX_NAME_LENGTH = 63;
19
20export type PolicyAlternativeNameServer = {
21 /**
22 * IPv4 address of the target name server.
23 */
24 ipv4Address?: string;
25 /**
26 * IPv6 address of the target name server. Mutually exclusive with
27 * `ipv4Address`.
28 */
29 ipv6Address?: string;
30 /**
31 * Forwarding path. `default` uses address ranges (RFC1918 via the VPC,
32 * public via the internet). `private` always sends queries through the
33 * VPC.
34 */
35 forwardingPath?:
36 | dns.PolicyAlternativeNameServerConfigTargetNameServerForwardingPathEnum
37 | (string & {});
38};
39
40export type PolicyProps = {
41 /**
42 * User-assigned policy name, unique within the project. If omitted, a
43 * unique RFC1035 name is generated from the stack, stage, and logical
44 * id. Must be 1-63 characters, begin with a letter, end with a letter
45 * or digit, and contain only lowercase letters, digits, or dashes.
46 * Immutable — changing it replaces the policy.
47 */
48 policyName?: string;
49 /**
50 * Human-readable description (max 1024 characters). DNS policies have
51 * no labels field, so Alchemy ownership is stored in a `[alchemy …]`
52 * prefix and stripped from attributes.
53 */
54 description?: string;
55 /**
56 * Allow on-premises resolvers to query Cloud DNS using inbound
57 * forwarding VIPs allocated from each bound subnet.
58 * @default false
59 */
60 enableInboundForwarding?: boolean;
61 /**
62 * Enable Cloud DNS query logging for bound networks.
63 * @default false
64 */
65 enableLogging?: boolean;
66 /**
67 * VPC networks this policy applies to. Each value may be a network
68 * name, a `projects/.../global/networks/...` path, or a full compute
69 * URL. A network may belong to at most one DNS server policy. Updates
70 * in place.
71 */
72 networks?: string[];
73 /**
74 * Outbound forwarding targets. When set, all DNS queries from bound
75 * networks are forwarded to these name servers (Cloud DNS private
76 * zones such as `.internal` are not consulted).
77 */
78 alternativeNameServers?: PolicyAlternativeNameServer[];
79 /**
80 * Enable DNS64 (`dns64Config.scope.allQueries`) for bound networks.
81 * @default false
82 */
83 enableDns64?: boolean;
84};
85
86export type Policy = Resource<
87 "GCP.DNS.Policy",
88 PolicyProps,
89 {
90 /** User-assigned policy name. */
91 policyName: string;
92 /** Project id. */
93 project: string;
94 /** Server-assigned numeric id. */
95 id: string | undefined;
96 /** User description (Alchemy ownership marker stripped). */
97 description: string | undefined;
98 /** Whether inbound forwarding is enabled. */
99 enableInboundForwarding: boolean;
100 /** Whether query logging is enabled. */
101 enableLogging: boolean;
102 /** VPC network URLs bound to this policy. */
103 networks: ReadonlyArray<string>;
104 /** Outbound forwarding targets. */
105 alternativeNameServers: ReadonlyArray<PolicyAlternativeNameServer>;
106 /** Whether DNS64 is enabled for all queries. */
107 enableDns64: boolean;
108 /** Server-reported kind (`dns#policy`). */
109 kind: string | undefined;
110 },
111 never,
112 Providers
113>;
114
115/**
116 * A Cloud DNS server policy applied to one or more VPC networks.
117 *
118 * A policy controls inbound DNS forwarding, outbound forwarding to
119 * alternative name servers, query logging, and DNS64. Name is identity
120 * — changing it replaces the policy. A VPC network can belong to at
121 * most one server policy.
122 *
123 * DNS policies have no labels. Alchemy stamps
124 * `alchemy-stack` / `alchemy-stage` / `alchemy-id` into the description
125 * so `list` and `pnpm nuke:gcp` can identify owned policies.
126 *
127 * ### Creating a Policy
128 * **Example:** Generated name, logging enabled
129 * ```typescript
130 * const vpc = yield* GCP.Compute.Network("Vpc", {
131 * autoCreateSubnetworks: false,
132 * });
133 * const policy = yield* GCP.DNS.Policy("CorpDns", {
134 * enableLogging: true,
135 * networks: [vpc.networkName],
136 * });
137 * ```
138 *
139 * **Example:** Explicit name, inbound forwarding, and description
140 * ```typescript
141 * const policy = yield* GCP.DNS.Policy("CorpDns", {
142 * policyName: "corp-dns",
143 * description: "inbound resolver for on-prem",
144 * enableInboundForwarding: true,
145 * enableLogging: true,
146 * networks: ["app-vpc"],
147 * });
148 * ```
149 *
150 * ### Outbound Forwarding
151 * **Example:** Forward all queries to on-prem name servers
152 * ```typescript
153 * const policy = yield* GCP.DNS.Policy("Forward", {
154 * networks: ["app-vpc"],
155 * alternativeNameServers: [{ ipv4Address: "192.0.2.53" }],
156 * });
157 * ```
158 *
159 * @resource
160 * @category DNS
161 */
162export const Policy = Resource<Policy>("GCP.DNS.Policy");
163
164export class PolicyNotResolved extends Data.TaggedError(
165 "GCP.DNS.PolicyNotResolved",
166)<{
167 policyName: string;
168}> {}
169
170const lastSegment = (value: string) => {
171 const trimmed = value.replace(/\/+$/, "");
172 const parts = trimmed.split("/");
173 return parts[parts.length - 1] || trimmed;
174};
175
176const toNetworkUrl = (project: string, network: string) => {
177 if (network.startsWith("https://") || network.startsWith("http://")) {
178 return network;
179 }
180 if (network.includes("/")) {
181 const path = network.replace(/^\//, "");
182 return path.startsWith("compute/")
183 ? `https://www.googleapis.com/${path}`
184 : `https://www.googleapis.com/compute/v1/${path}`;
185 }
186 return `https://www.googleapis.com/compute/v1/projects/${project}/global/networks/${network}`;
187};
188
189const desiredNetworks = (project: string, networks: string[] | undefined) =>
190 [
191 ...new Set(
192 (networks ?? []).map((network) => toNetworkUrl(project, network)),
193 ),
194 ].sort((left, right) => lastSegment(left).localeCompare(lastSegment(right)));
195
196const observedNetworks = (policy: dns.Policy) =>
197 (policy.networks ?? [])
198 .map((network) => network.networkUrl ?? "")
199 .filter((url) => url.length > 0)
200 .sort((left, right) => lastSegment(left).localeCompare(lastSegment(right)));
201
202const sameNetworks = (left: string[], right: string[]) =>
203 left.length === right.length &&
204 left.every(
205 (url, index) => lastSegment(url) === lastSegment(right[index] ?? ""),
206 );
207
208const desiredNameServers = (
209 servers: PolicyAlternativeNameServer[] | undefined,
210): PolicyAlternativeNameServer[] =>
211 (servers ?? []).map((server) => ({
212 ipv4Address: server.ipv4Address,
213 ipv6Address: server.ipv6Address,
214 forwardingPath: server.forwardingPath,
215 }));
216
217const observedNameServers = (
218 policy: dns.Policy,
219): PolicyAlternativeNameServer[] =>
220 (policy.alternativeNameServerConfig?.targetNameServers ?? []).map(
221 (server) => ({
222 ipv4Address: server.ipv4Address,
223 ipv6Address: server.ipv6Address,
224 forwardingPath: server.forwardingPath,
225 }),
226 );
227
228const sameNameServers = (
229 left: PolicyAlternativeNameServer[],
230 right: PolicyAlternativeNameServer[],
231) => JSON.stringify(left) === JSON.stringify(right);
232
233const encodeDescription = (
234 internal: Record<string, string>,
235 user?: string,
236): string => {
237 const marker = `[alchemy ${alchemyLabelKeys.stack}=${internal[alchemyLabelKeys.stack] ?? ""} ${alchemyLabelKeys.stage}=${internal[alchemyLabelKeys.stage] ?? ""} ${alchemyLabelKeys.id}=${internal[alchemyLabelKeys.id] ?? ""}]`;
238 return user && user.length > 0 ? `${marker}\n${user}` : marker;
239};
240
241const parseDescription = (description: string | undefined) => {
242 if (!description?.startsWith("[alchemy ")) {
243 return { labels: {} as Record<string, string>, description };
244 }
245 const end = description.indexOf("]");
246 if (end < 0) return { labels: {} as Record<string, string>, description };
247 const labels: Record<string, string> = {};
248 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
249 const eq = part.indexOf("=");
250 if (eq > 0) {
251 labels[part.slice(0, eq)] = part.slice(eq + 1);
252 }
253 }
254 const rest = description.slice(end + 1).replace(/^\n/, "");
255 return {
256 labels,
257 description: rest.length > 0 ? rest : undefined,
258 };
259};
260
261const hasAlchemyMarker = (description: string | undefined) =>
262 Object.keys(parseDescription(description).labels).some((key) =>
263 key.startsWith("alchemy-"),
264 );
265
266const toPolicyName = (
267 id: string,
268 name: string | undefined,
269 existing?: string,
270) =>
271 Effect.gen(function* () {
272 if (name !== undefined) return name;
273 if (existing !== undefined) return existing;
274 const generated = yield* createPhysicalName({
275 id,
276 maxLength: MAX_NAME_LENGTH,
277 lowercase: true,
278 });
279 const rfc = generated
280 .replace(/[^a-z0-9-]/g, "-")
281 .replace(/^[^a-z]+/, "p")
282 .slice(0, MAX_NAME_LENGTH)
283 .replace(/-+$/g, "");
284 return rfc.length > 0 ? rfc : "p";
285 });
286
287const toAttrs = (policy: dns.Policy, project: string) => ({
288 policyName: policy.name ?? "",
289 project,
290 id: policy.id,
291 description: parseDescription(policy.description).description,
292 enableInboundForwarding: policy.enableInboundForwarding === true,
293 enableLogging: policy.enableLogging === true,
294 networks: observedNetworks(policy),
295 alternativeNameServers: observedNameServers(policy),
296 enableDns64: policy.dns64Config?.scope?.allQueries === true,
297 kind: policy.kind,
298});
299
300const getByName = (project: string, policyName: string) =>
301 dns
302 .getPolicies({ project, policy: policyName })
303 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
304
305const alternativeNameServerConfig = (
306 servers: PolicyAlternativeNameServer[],
307): dns.PolicyAlternativeNameServerConfig | undefined =>
308 servers.length === 0
309 ? undefined
310 : {
311 targetNameServers: servers.map((server) => ({
312 ipv4Address: server.ipv4Address,
313 ipv6Address: server.ipv6Address,
314 forwardingPath: server.forwardingPath,
315 })),
316 };
317
318const dns64Config = (enable: boolean): dns.PolicyDns64Config | undefined =>
319 enable ? { scope: { allQueries: true } } : undefined;
320
321export const PolicyProvider = () =>
322 Provider.succeed(Policy, {
323 stables: ["policyName", "project", "id"],
324
325 diff: Effect.fn(function* ({ news, olds, output }) {
326 if (!isResolved(news)) return undefined;
327 const previousName = olds?.policyName ?? output?.policyName;
328 const nextName = news.policyName ?? previousName;
329 if (
330 previousName === undefined ||
331 nextName === undefined ||
332 nextName === previousName
333 ) {
334 return undefined;
335 }
336 // A VPC may belong to only one server policy, so the old policy
337 // must be deleted (and its networks unbound) before the new one
338 // can attach to the same network.
339 return { action: "replace" as const, deleteFirst: true };
340 }),
341
342 read: Effect.fn(function* ({ id, olds, output }) {
343 const env = yield* GcpEnvironment.current;
344 const policyName = yield* toPolicyName(
345 id,
346 olds?.policyName,
347 output?.policyName,
348 );
349 const existing = yield* getByName(env.project, policyName);
350 if (existing === undefined) return undefined;
351 const attrs = toAttrs(existing, env.project);
352 const parsed = parseDescription(existing.description);
353 return (yield* hasAlchemyLabels(id, parsed.labels))
354 ? attrs
355 : Unowned(attrs);
356 }),
357
358 list: () =>
359 Effect.gen(function* () {
360 const env = yield* GcpEnvironment.current;
361 const found: ReturnType<typeof toAttrs>[] = [];
362 let pageToken: string | undefined;
363 for (let page = 0; page < 10; page++) {
364 const response = yield* dns.listPolicies({
365 project: env.project,
366 maxResults: 1000,
367 pageToken,
368 });
369 for (const policy of response.policies ?? []) {
370 if (hasAlchemyMarker(policy.description)) {
371 found.push(toAttrs(policy, env.project));
372 }
373 }
374 pageToken = response.nextPageToken;
375 if (pageToken === undefined || pageToken === "") break;
376 }
377 return found;
378 }),
379
380 reconcile: Effect.fn(function* ({ id, news, output }) {
381 const env = yield* GcpEnvironment.current;
382 const policyName = yield* toPolicyName(
383 id,
384 news.policyName,
385 output?.policyName,
386 );
387 const enableInboundForwarding = news.enableInboundForwarding === true;
388 const enableLogging = news.enableLogging === true;
389 const enableDns64 = news.enableDns64 === true;
390 const networks = desiredNetworks(env.project, news.networks);
391 const nameServers = desiredNameServers(news.alternativeNameServers);
392 const ownership = yield* createInternalLabels(id);
393 const desiredDescription = encodeDescription(ownership, news.description);
394
395 let current = yield* getByName(env.project, policyName);
396
397 if (current === undefined) {
398 const created = yield* dns
399 .createPolicies({
400 project: env.project,
401 body: {
402 name: policyName,
403 description: desiredDescription,
404 enableInboundForwarding,
405 enableLogging,
406 networks: networks.map((networkUrl) => ({ networkUrl })),
407 alternativeNameServerConfig:
408 alternativeNameServerConfig(nameServers),
409 dns64Config: dns64Config(enableDns64),
410 },
411 })
412 .pipe(
413 Effect.catchTag("Conflict", () =>
414 getByName(env.project, policyName),
415 ),
416 );
417 current = created ?? undefined;
418 }
419
420 if (current === undefined) {
421 return yield* new PolicyNotResolved({ policyName });
422 }
423
424 const descriptionChanged =
425 (current.description ?? "") !== desiredDescription;
426 const inboundChanged =
427 (current.enableInboundForwarding === true) !== enableInboundForwarding;
428 const loggingChanged = (current.enableLogging === true) !== enableLogging;
429 const dns64Changed =
430 (current.dns64Config?.scope?.allQueries === true) !== enableDns64;
431 const networksChanged = !sameNetworks(
432 observedNetworks(current),
433 networks,
434 );
435 const nameServersChanged = !sameNameServers(
436 observedNameServers(current),
437 nameServers,
438 );
439
440 if (
441 descriptionChanged ||
442 inboundChanged ||
443 loggingChanged ||
444 dns64Changed ||
445 networksChanged ||
446 nameServersChanged
447 ) {
448 const body: dns.Policy = {};
449 if (descriptionChanged) body.description = desiredDescription;
450 if (inboundChanged) {
451 body.enableInboundForwarding = enableInboundForwarding;
452 }
453 if (loggingChanged) body.enableLogging = enableLogging;
454 if (dns64Changed) {
455 body.dns64Config = enableDns64
456 ? { scope: { allQueries: true } }
457 : { scope: { allQueries: false } };
458 }
459 if (networksChanged) {
460 body.networks = networks.map((networkUrl) => ({ networkUrl }));
461 }
462 if (nameServersChanged) {
463 body.alternativeNameServerConfig = {
464 targetNameServers: nameServers.map((server) => ({
465 ipv4Address: server.ipv4Address,
466 ipv6Address: server.ipv6Address,
467 forwardingPath: server.forwardingPath,
468 })),
469 };
470 }
471 const patched = yield* dns.patchPolicies({
472 project: env.project,
473 policy: policyName,
474 body,
475 });
476 current =
477 patched.policy ??
478 (yield* getByName(env.project, policyName)) ??
479 current;
480 }
481
482 return toAttrs(current, env.project);
483 }),
484
485 delete: Effect.fn(function* ({ output }) {
486 const project = output.project;
487 const policyName = output.policyName;
488 const detach = dns
489 .patchPolicies({
490 project,
491 policy: policyName,
492 body: {
493 enableInboundForwarding: false,
494 networks: [],
495 },
496 })
497 .pipe(Effect.catchTag("NotFound", () => Effect.void));
498 const attempt = dns
499 .deletePolicies({ project, policy: policyName })
500 .pipe(Effect.catchTag("NotFound", () => Effect.void));
501 yield* detach.pipe(Effect.andThen(attempt)).pipe(
502 Effect.catchIf(
503 (error) => error._tag === "Conflict" || error._tag === "BadRequest",
504 () => detach.pipe(Effect.andThen(attempt)),
505 ),
506 Effect.retry({
507 while: (error) =>
508 error._tag === "Conflict" || error._tag === "BadRequest",
509 times: 8,
510 schedule: Schedule.spaced("2 seconds"),
511 }),
512 );
513 }),
514 });