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 { tagRecord } from "../../Tags.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 ALCHEMY_LABEL_PREFIX,
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21
22const MAX_NAME_LENGTH = 63;
23
24export type ResponsePolicyProps = {
25 /**
26 * User-assigned response policy name, unique within the project. If
27 * omitted, a unique RFC1035 name is generated from the stack, stage,
28 * and logical id. Must be 1-63 characters, begin with a letter, end
29 * with a letter or digit, and contain only lowercase letters, digits,
30 * or dashes. Immutable — changing it replaces the response policy.
31 */
32 responsePolicyName?: string;
33 /**
34 * Human-readable description.
35 */
36 description?: string;
37 /**
38 * User labels. Alchemy ownership labels are merged in automatically.
39 */
40 labels?: Record<string, string>;
41 /**
42 * VPC networks this response policy applies to. Each value may be a
43 * network name, a `projects/.../global/networks/...` path, or a full
44 * compute URL. A network may belong to at most one response policy.
45 * Updates in place.
46 */
47 networks?: string[];
48 /**
49 * GKE clusters this response policy applies to, as
50 * `projects/{project}/locations/{location}/clusters/{cluster}`.
51 * Updates in place.
52 */
53 gkeClusters?: string[];
54};
55
56export type ResponsePolicy = Resource<
57 "GCP.DNS.ResponsePolicy",
58 ResponsePolicyProps,
59 {
60 /** User-assigned response policy name. */
61 responsePolicyName: string;
62 /** Project id. */
63 project: string;
64 /** Server-assigned numeric id. */
65 id: string | undefined;
66 /** User description. */
67 description: string | undefined;
68 /** User labels (Alchemy ownership labels stripped). */
69 labels: Record<string, string>;
70 /** VPC network URLs bound to this response policy. */
71 networks: ReadonlyArray<string>;
72 /** GKE cluster resource names bound to this response policy. */
73 gkeClusters: ReadonlyArray<string>;
74 /** Server-reported kind (`dns#responsePolicy`). */
75 kind: string | undefined;
76 },
77 never,
78 Providers
79>;
80
81/**
82 * A Cloud DNS response policy applied to one or more VPC networks.
83 *
84 * Response policies override DNS answers for selected names (via
85 * response policy rules) for VMs in bound networks. Name is identity —
86 * changing it replaces the policy. Description, labels, and the
87 * network / GKE cluster lists update in place.
88 *
89 * ### Creating a Response Policy
90 * **Example:** Generated name bound to a VPC
91 * ```typescript
92 * const vpc = yield* GCP.Compute.Network("Vpc", {
93 * autoCreateSubnetworks: false,
94 * });
95 * const policy = yield* GCP.DNS.ResponsePolicy("Overrides", {
96 * networks: [vpc.networkName],
97 * });
98 * ```
99 *
100 * **Example:** Explicit name, description, and labels
101 * ```typescript
102 * const policy = yield* GCP.DNS.ResponsePolicy("Overrides", {
103 * responsePolicyName: "app-overrides",
104 * description: "split-horizon answers",
105 * labels: { env: "prod" },
106 * networks: ["app-vpc"],
107 * });
108 * ```
109 *
110 * @resource
111 * @category DNS
112 */
113export const ResponsePolicy = Resource<ResponsePolicy>(
114 "GCP.DNS.ResponsePolicy",
115);
116
117export class ResponsePolicyNotResolved extends Data.TaggedError(
118 "GCP.DNS.ResponsePolicyNotResolved",
119)<{
120 responsePolicyName: string;
121}> {}
122
123const userLabels = (
124 labels: Record<string, string | undefined> | null | undefined,
125): Record<string, string> => stripInternalLabels(tagRecord(labels));
126
127const lastSegment = (value: string) => {
128 const trimmed = value.replace(/\/+$/, "");
129 const parts = trimmed.split("/");
130 return parts[parts.length - 1] || trimmed;
131};
132
133const toNetworkUrl = (project: string, network: string) => {
134 if (network.startsWith("https://") || network.startsWith("http://")) {
135 return network;
136 }
137 if (network.includes("/")) {
138 const path = network.replace(/^\//, "");
139 return path.startsWith("compute/")
140 ? `https://www.googleapis.com/${path}`
141 : `https://www.googleapis.com/compute/v1/${path}`;
142 }
143 return `https://www.googleapis.com/compute/v1/projects/${project}/global/networks/${network}`;
144};
145
146const desiredNetworks = (project: string, networks: string[] | undefined) =>
147 [
148 ...new Set(
149 (networks ?? []).map((network) => toNetworkUrl(project, network)),
150 ),
151 ].sort((left, right) => lastSegment(left).localeCompare(lastSegment(right)));
152
153const observedNetworks = (policy: dns.ResponsePolicy) =>
154 (policy.networks ?? [])
155 .map((network) => network.networkUrl ?? "")
156 .filter((url) => url.length > 0)
157 .sort((left, right) => lastSegment(left).localeCompare(lastSegment(right)));
158
159const sameNetworks = (left: string[], right: string[]) =>
160 left.length === right.length &&
161 left.every(
162 (url, index) => lastSegment(url) === lastSegment(right[index] ?? ""),
163 );
164
165const desiredClusters = (clusters: string[] | undefined) =>
166 [...new Set(clusters ?? [])].sort();
167
168const observedClusters = (policy: dns.ResponsePolicy) =>
169 (policy.gkeClusters ?? [])
170 .map((cluster) => cluster.gkeClusterName ?? "")
171 .filter((name) => name.length > 0)
172 .sort();
173
174const sameList = (left: string[], right: string[]) =>
175 left.length === right.length &&
176 left.every((value, index) => value === right[index]);
177
178const toResponsePolicyName = (
179 id: string,
180 name: string | undefined,
181 existing?: string,
182) =>
183 Effect.gen(function* () {
184 if (name !== undefined) return name;
185 if (existing !== undefined) return existing;
186 const generated = yield* createPhysicalName({
187 id,
188 maxLength: MAX_NAME_LENGTH,
189 lowercase: true,
190 });
191 const rfc = generated
192 .replace(/[^a-z0-9-]/g, "-")
193 .replace(/^[^a-z]+/, "r")
194 .slice(0, MAX_NAME_LENGTH)
195 .replace(/-+$/g, "");
196 return rfc.length > 0 ? rfc : "r";
197 });
198
199const toAttrs = (policy: dns.ResponsePolicy, project: string) => ({
200 responsePolicyName: policy.responsePolicyName ?? "",
201 project,
202 id: policy.id,
203 description:
204 policy.description && policy.description.length > 0
205 ? policy.description
206 : undefined,
207 labels: userLabels(policy.labels),
208 networks: observedNetworks(policy),
209 gkeClusters: observedClusters(policy),
210 kind: policy.kind,
211});
212
213const getByName = (project: string, responsePolicyName: string) =>
214 dns
215 .getResponsePolicies({ project, responsePolicy: responsePolicyName })
216 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
217
218const listRules = (project: string, responsePolicyName: string) =>
219 Effect.gen(function* () {
220 const found: dns.ResponsePolicyRule[] = [];
221 let pageToken: string | undefined;
222 for (let page = 0; page < 10; page++) {
223 const response = yield* dns.listResponsePolicyRules({
224 project,
225 responsePolicy: responsePolicyName,
226 maxResults: 1000,
227 pageToken,
228 });
229 found.push(...(response.responsePolicyRules ?? []));
230 pageToken = response.nextPageToken;
231 if (pageToken === undefined || pageToken === "") break;
232 }
233 return found;
234 }).pipe(
235 Effect.catchTag("NotFound", () =>
236 Effect.succeed([] as dns.ResponsePolicyRule[]),
237 ),
238 );
239
240const emptyRules = (project: string, responsePolicyName: string) =>
241 Effect.gen(function* () {
242 const rules = yield* listRules(project, responsePolicyName);
243 yield* Effect.forEach(
244 rules,
245 (rule) =>
246 rule.ruleName
247 ? dns
248 .deleteResponsePolicyRules({
249 project,
250 responsePolicy: responsePolicyName,
251 responsePolicyRule: rule.ruleName,
252 })
253 .pipe(Effect.catchTag("NotFound", () => Effect.void))
254 : Effect.void,
255 { concurrency: 4 },
256 );
257 });
258
259export const ResponsePolicyProvider = () =>
260 Provider.succeed(ResponsePolicy, {
261 stables: ["responsePolicyName", "project", "id"],
262
263 diff: Effect.fn(function* ({ news, olds, output }) {
264 if (!isResolved(news)) return undefined;
265 const previousName =
266 olds?.responsePolicyName ?? output?.responsePolicyName;
267 const nextName = news.responsePolicyName ?? previousName;
268 if (
269 previousName === undefined ||
270 nextName === undefined ||
271 nextName === previousName
272 ) {
273 return undefined;
274 }
275 // A VPC may belong to only one response policy, so the old policy
276 // must be deleted before the new one can attach to the same
277 // network.
278 return { action: "replace" as const, deleteFirst: true };
279 }),
280
281 read: Effect.fn(function* ({ id, olds, output }) {
282 const env = yield* GcpEnvironment.current;
283 const responsePolicyName = yield* toResponsePolicyName(
284 id,
285 olds?.responsePolicyName,
286 output?.responsePolicyName,
287 );
288 const existing = yield* getByName(env.project, responsePolicyName);
289 if (existing === undefined) return undefined;
290 const attrs = toAttrs(existing, env.project);
291 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
292 ? attrs
293 : Unowned(attrs);
294 }),
295
296 list: () =>
297 Effect.gen(function* () {
298 const env = yield* GcpEnvironment.current;
299 const found: ReturnType<typeof toAttrs>[] = [];
300 let pageToken: string | undefined;
301 for (let page = 0; page < 10; page++) {
302 const response = yield* dns.listResponsePolicies({
303 project: env.project,
304 maxResults: 1000,
305 pageToken,
306 });
307 for (const policy of response.responsePolicies ?? []) {
308 if (
309 Object.keys(policy.labels ?? {}).some((key) =>
310 key.startsWith(ALCHEMY_LABEL_PREFIX),
311 )
312 ) {
313 found.push(toAttrs(policy, env.project));
314 }
315 }
316 pageToken = response.nextPageToken;
317 if (pageToken === undefined || pageToken === "") break;
318 }
319 return found;
320 }),
321
322 reconcile: Effect.fn(function* ({ id, news, output }) {
323 const env = yield* GcpEnvironment.current;
324 const responsePolicyName = yield* toResponsePolicyName(
325 id,
326 news.responsePolicyName,
327 output?.responsePolicyName,
328 );
329 const description = news.description ?? "";
330 const networks = desiredNetworks(env.project, news.networks);
331 const gkeClusters = desiredClusters(news.gkeClusters);
332 const desiredLabels = {
333 ...toLabels(news.labels),
334 ...(yield* createInternalLabels(id)),
335 };
336
337 let current = yield* getByName(env.project, responsePolicyName);
338
339 if (current === undefined) {
340 const created = yield* dns
341 .createResponsePolicies({
342 project: env.project,
343 body: {
344 responsePolicyName,
345 description,
346 labels: desiredLabels,
347 networks: networks.map((networkUrl) => ({ networkUrl })),
348 gkeClusters: gkeClusters.map((gkeClusterName) => ({
349 gkeClusterName,
350 })),
351 },
352 })
353 .pipe(
354 Effect.catchTag("Conflict", () =>
355 getByName(env.project, responsePolicyName),
356 ),
357 );
358 current = created ?? undefined;
359 }
360
361 if (current === undefined) {
362 return yield* new ResponsePolicyNotResolved({ responsePolicyName });
363 }
364
365 const observedLabels = tagRecord(current.labels);
366 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
367 const labelsChanged = upsert.length > 0 || removed.length > 0;
368 const descriptionChanged = (current.description ?? "") !== description;
369 const networksChanged = !sameNetworks(
370 observedNetworks(current),
371 networks,
372 );
373 const clustersChanged = !sameList(observedClusters(current), gkeClusters);
374
375 if (
376 labelsChanged ||
377 descriptionChanged ||
378 networksChanged ||
379 clustersChanged
380 ) {
381 const body: dns.ResponsePolicy = {};
382 if (labelsChanged) body.labels = desiredLabels;
383 if (descriptionChanged) body.description = description;
384 if (networksChanged) {
385 body.networks = networks.map((networkUrl) => ({ networkUrl }));
386 }
387 if (clustersChanged) {
388 body.gkeClusters = gkeClusters.map((gkeClusterName) => ({
389 gkeClusterName,
390 }));
391 }
392 const patched = yield* dns.patchResponsePolicies({
393 project: env.project,
394 responsePolicy: responsePolicyName,
395 body,
396 });
397 current =
398 patched.responsePolicy ??
399 (yield* getByName(env.project, responsePolicyName)) ??
400 current;
401 }
402
403 return toAttrs(current, env.project);
404 }),
405
406 delete: Effect.fn(function* ({ output }) {
407 const project = output.project;
408 const responsePolicyName = output.responsePolicyName;
409 const detach = Effect.gen(function* () {
410 yield* emptyRules(project, responsePolicyName);
411 yield* dns
412 .patchResponsePolicies({
413 project,
414 responsePolicy: responsePolicyName,
415 body: { networks: [], gkeClusters: [] },
416 })
417 .pipe(Effect.catchTag("NotFound", () => Effect.void));
418 });
419 const attempt = dns
420 .deleteResponsePolicies({
421 project,
422 responsePolicy: responsePolicyName,
423 })
424 .pipe(Effect.catchTag("NotFound", () => Effect.void));
425 yield* detach.pipe(Effect.andThen(attempt)).pipe(
426 Effect.catchIf(
427 (error) => error._tag === "Conflict" || error._tag === "BadRequest",
428 () => detach.pipe(Effect.andThen(attempt)),
429 ),
430 Effect.retry({
431 while: (error) =>
432 error._tag === "Conflict" || error._tag === "BadRequest",
433 times: 8,
434 schedule: Schedule.spaced("2 seconds"),
435 }),
436 );
437 }),
438 });