1import * as certificatemanager from "@distilled.cloud/gcp/certificatemanager_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21import { waitForOperation } from "./operations.ts";
22
23const DEFAULT_LOCATION = "global";
24const MAX_NAME_LENGTH = 63;
25
26export type DnsAuthorizationType = "FIXED_RECORD" | "PER_PROJECT_RECORD";
27
28export type DnsAuthorizationProps = {
29 /**
30 * DnsAuthorization id (the `{dnsAuthorization}` segment of
31 * `projects/{project}/locations/{location}/dnsAuthorizations/{dnsAuthorization}`).
32 * If omitted, a unique name is generated from the stack, stage, and
33 * logical id. Must be 1-63 characters, start with a letter, and match
34 * `[a-zA-Z][a-zA-Z0-9_-]*`. Immutable — changing it replaces the
35 * authorization.
36 */
37 dnsAuthorizationId?: string;
38 /**
39 * Certificate Manager location (`global`, `us-central1`, …). Immutable —
40 * changing it replaces the authorization. `US-CENTRAL1` is accepted and
41 * normalized to `us-central1`. `FIXED_RECORD` authorizations live in
42 * `global`; regional locations default to `PER_PROJECT_RECORD`.
43 * @default "global"
44 */
45 location?: string;
46 /**
47 * Domain being authorized. Covers this domain and its wildcard
48 * (`example.com` also covers `*.example.com`). Immutable — changing it
49 * replaces the authorization.
50 */
51 domain: string;
52 /**
53 * Authorization type. Immutable — changing it replaces the
54 * authorization. Defaults to `FIXED_RECORD` in `global` and
55 * `PER_PROJECT_RECORD` in regional locations.
56 */
57 type?: DnsAuthorizationType | (string & {});
58 /**
59 * Human-readable description.
60 */
61 description?: string;
62 /**
63 * User labels. Alchemy ownership labels are merged in automatically.
64 */
65 labels?: Record<string, string>;
66};
67
68export type DnsAuthorization = Resource<
69 "GCP.CertificateManager.DnsAuthorization",
70 DnsAuthorizationProps,
71 {
72 /** Full resource name `projects/{project}/locations/{location}/dnsAuthorizations/{dnsAuthorization}`. */
73 name: string;
74 /** DnsAuthorization id (last path segment). */
75 dnsAuthorizationId: string;
76 /** Project id. */
77 project: string;
78 /** Location id (`global`, `us-central1`, …). */
79 location: string;
80 /** Authorized domain. */
81 domain: string;
82 /** `FIXED_RECORD` or `PER_PROJECT_RECORD`. */
83 type: string;
84 /** User-provided description. */
85 description: string | undefined;
86 /** User labels (Alchemy ownership labels stripped). */
87 labels: Record<string, string>;
88 /** FQDN of the CNAME record to add (`_acme-challenge.example.com`). */
89 dnsResourceRecordName: string | undefined;
90 /** DNS record type. Currently always `CNAME`. */
91 dnsResourceRecordType: string | undefined;
92 /** CNAME target to publish for domain validation. */
93 dnsResourceRecordData: string | undefined;
94 /** RFC3339 creation timestamp. */
95 createTime: string | undefined;
96 /** RFC3339 last-update timestamp. */
97 updateTime: string | undefined;
98 },
99 never,
100 Providers
101>;
102
103/**
104 * A Certificate Manager DNS authorization proving ownership of a domain
105 * for Google-managed certificates.
106 *
107 * Changing `dnsAuthorizationId`, `location`, `domain`, or `type` replaces
108 * the resource. Description and labels update in place.
109 *
110 * ### Creating a DNS Authorization
111 * **Example:** Generated name
112 * ```typescript
113 * const authorization = yield* GCP.CertificateManager.DnsAuthorization(
114 * "WwwAuth",
115 * { domain: "www.example.com" },
116 * );
117 * ```
118 *
119 * **Example:** Named authorization with labels
120 * ```typescript
121 * const authorization = yield* GCP.CertificateManager.DnsAuthorization(
122 * "WwwAuth",
123 * {
124 * dnsAuthorizationId: "www-example-auth",
125 * description: "prod www",
126 * domain: "www.example.com",
127 * labels: { env: "prod" },
128 * },
129 * );
130 * ```
131 *
132 * ### Regional Per-Project Records
133 * **Example:** PER_PROJECT_RECORD in us-central1
134 * ```typescript
135 * const authorization = yield* GCP.CertificateManager.DnsAuthorization(
136 * "WwwAuth",
137 * {
138 * location: "us-central1",
139 * type: "PER_PROJECT_RECORD",
140 * domain: "www.example.com",
141 * },
142 * );
143 * ```
144 *
145 * ### Google-Managed Certificates
146 * **Example:** Authorize a domain then issue a managed certificate
147 * ```typescript
148 * const authorization = yield* GCP.CertificateManager.DnsAuthorization(
149 * "WwwAuth",
150 * { domain: "www.example.com" },
151 * );
152 * const cert = yield* GCP.CertificateManager.Certificate("FrontendTls", {
153 * type: "MANAGED",
154 * managed: {
155 * domains: ["www.example.com"],
156 * dnsAuthorizations: [authorization.name],
157 * },
158 * });
159 * ```
160 *
161 * @resource
162 * @category CertificateManager
163 */
164export const DnsAuthorization = Resource<DnsAuthorization>(
165 "GCP.CertificateManager.DnsAuthorization",
166);
167
168export class DnsAuthorizationNotResolved extends Data.TaggedError(
169 "GCP.CertificateManager.DnsAuthorizationNotResolved",
170)<{
171 name: string;
172}> {}
173
174export class DnsAuthorizationStillExists extends Data.TaggedError(
175 "GCP.CertificateManager.DnsAuthorizationStillExists",
176)<{
177 name: string;
178}> {}
179
180const lastSegment = (value: string) => {
181 const trimmed = value.replace(/\/+$/, "");
182 const parts = trimmed.split("/");
183 return parts[parts.length - 1] || trimmed;
184};
185
186const rfc1035 = (name: string): string => {
187 let next = name
188 .toLowerCase()
189 .replace(/[^a-z0-9-]/g, "-")
190 .replace(/-+/g, "-")
191 .replace(/^-+/, "")
192 .replace(/-+$/, "");
193 if (!/^[a-z]/.test(next)) {
194 next = `d${next}`;
195 }
196 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
197 return next.length > 0 ? next : "dns-authorization";
198};
199
200const normalizeLocation = (location: string | undefined) =>
201 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
202
203const defaultType = (location: string): DnsAuthorizationType =>
204 location === DEFAULT_LOCATION ? "FIXED_RECORD" : "PER_PROJECT_RECORD";
205
206const normalizeType = (type: string | undefined, location: string) => {
207 const value = (type ?? "").toUpperCase();
208 return value === "" || value === "TYPE_UNSPECIFIED"
209 ? defaultType(location)
210 : value;
211};
212
213const resourceName = (
214 project: string,
215 location: string,
216 dnsAuthorizationId: string,
217) =>
218 `projects/${project}/locations/${location}/dnsAuthorizations/${dnsAuthorizationId}`;
219
220const parseName = (name: string) => {
221 const parts = name.split("/").filter((part) => part.length > 0);
222 const authsAt = parts.lastIndexOf("dnsAuthorizations");
223 const locationsAt = parts.lastIndexOf("locations");
224 const projectsAt = parts.lastIndexOf("projects");
225 return {
226 project:
227 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
228 location:
229 locationsAt >= 0 && parts[locationsAt + 1]
230 ? parts[locationsAt + 1]!
231 : DEFAULT_LOCATION,
232 dnsAuthorizationId:
233 authsAt >= 0 && parts[authsAt + 1]
234 ? parts[authsAt + 1]!
235 : lastSegment(name),
236 };
237};
238
239const userLabels = (
240 labels: Record<string, string | undefined> | null | undefined,
241): Record<string, string> => stripInternalLabels(tagRecord(labels));
242
243const toId = (
244 id: string,
245 dnsAuthorizationId: string | undefined,
246 existing?: string,
247) =>
248 Effect.gen(function* () {
249 if (dnsAuthorizationId !== undefined) return dnsAuthorizationId;
250 if (existing !== undefined) return existing;
251 return rfc1035(
252 yield* createPhysicalName({
253 id,
254 maxLength: MAX_NAME_LENGTH,
255 lowercase: true,
256 }),
257 );
258 });
259
260const toAttrs = (
261 auth: certificatemanager.DnsAuthorization,
262 project: string,
263) => {
264 const name = auth.name ?? "";
265 const parsed = parseName(name);
266 const location = parsed.location;
267 return {
268 name,
269 dnsAuthorizationId: parsed.dnsAuthorizationId,
270 project: parsed.project || project,
271 location,
272 domain: auth.domain ?? "",
273 type: normalizeType(auth.type, location),
274 description: auth.description,
275 labels: userLabels(auth.labels),
276 dnsResourceRecordName: auth.dnsResourceRecord?.name,
277 dnsResourceRecordType: auth.dnsResourceRecord?.type,
278 dnsResourceRecordData: auth.dnsResourceRecord?.data,
279 createTime: auth.createTime,
280 updateTime: auth.updateTime,
281 };
282};
283
284const getByName = (name: string) =>
285 certificatemanager
286 .getProjectsLocationsDnsAuthorizations({ name })
287 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
288
289const waitUntilExists = (name: string) =>
290 getByName(name).pipe(
291 Effect.flatMap((auth) =>
292 auth
293 ? Effect.succeed(auth)
294 : Effect.fail(new DnsAuthorizationNotResolved({ name })),
295 ),
296 Effect.retry({
297 while: (error) =>
298 error._tag === "GCP.CertificateManager.DnsAuthorizationNotResolved",
299 times: 8,
300 schedule: Schedule.spaced("1 second"),
301 }),
302 );
303
304const waitUntilGone = (name: string) =>
305 getByName(name).pipe(
306 Effect.flatMap((auth) =>
307 auth === undefined
308 ? Effect.void
309 : Effect.fail(new DnsAuthorizationStillExists({ name })),
310 ),
311 Effect.retry({
312 while: (error) =>
313 error._tag === "GCP.CertificateManager.DnsAuthorizationStillExists",
314 times: 10,
315 schedule: Schedule.spaced("1 second"),
316 }),
317 );
318
319const listOwnedDnsAuthorizations = (project: string) =>
320 certificatemanager.listProjectsLocationsDnsAuthorizations
321 .pages({
322 parent: `projects/${project}/locations/-`,
323 pageSize: 1000,
324 })
325 .pipe(
326 Stream.flatMap((page) =>
327 Stream.fromIterable(page.dnsAuthorizations ?? []),
328 ),
329 Stream.filter((auth) =>
330 Object.keys(auth.labels ?? {}).some((key) =>
331 key.startsWith("alchemy-"),
332 ),
333 ),
334 Stream.map((auth) => toAttrs(auth, project)),
335 Stream.runCollect,
336 Effect.map((chunk) => Array.from(chunk)),
337 Effect.catchTag("NotFound", () => Effect.succeed([])),
338 );
339
340export const DnsAuthorizationProvider = () =>
341 Provider.succeed(DnsAuthorization, {
342 stables: [
343 "name",
344 "dnsAuthorizationId",
345 "project",
346 "location",
347 "domain",
348 "type",
349 "dnsResourceRecordName",
350 "dnsResourceRecordType",
351 "dnsResourceRecordData",
352 "createTime",
353 ],
354
355 diff: Effect.fn(function* ({ news, olds, output }) {
356 if (!isResolved(news)) return undefined;
357
358 const previousId = olds?.dnsAuthorizationId ?? output?.dnsAuthorizationId;
359 const nextId = news.dnsAuthorizationId ?? previousId;
360 const previousLocation = normalizeLocation(
361 olds?.location ?? output?.location,
362 );
363 const nextLocation = normalizeLocation(
364 news.location ?? olds?.location ?? output?.location,
365 );
366 const previousDomain = olds?.domain ?? output?.domain;
367 const previousType = normalizeType(
368 olds?.type ?? output?.type,
369 previousLocation,
370 );
371 const nextType = normalizeType(
372 news.type ?? olds?.type ?? output?.type,
373 nextLocation,
374 );
375
376 const replace =
377 (previousId !== undefined &&
378 nextId !== undefined &&
379 nextId !== previousId) ||
380 previousLocation !== nextLocation ||
381 (previousDomain !== undefined && news.domain !== previousDomain) ||
382 previousType !== nextType;
383
384 if (!replace) return undefined;
385 return {
386 action: "replace" as const,
387 deleteFirst:
388 previousLocation === nextLocation &&
389 previousId !== undefined &&
390 nextId === previousId,
391 };
392 }),
393
394 read: Effect.fn(function* ({ id, olds, output }) {
395 const env = yield* GcpEnvironment.current;
396 const dnsAuthorizationId = yield* toId(
397 id,
398 olds?.dnsAuthorizationId,
399 output?.dnsAuthorizationId,
400 );
401 const location = normalizeLocation(olds?.location ?? output?.location);
402 const name =
403 output?.name ?? resourceName(env.project, location, dnsAuthorizationId);
404 const existing = yield* getByName(name);
405 if (existing === undefined) return undefined;
406 const attrs = toAttrs(existing, env.project);
407 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
408 ? attrs
409 : Unowned(attrs);
410 }),
411
412 list: () =>
413 Effect.gen(function* () {
414 const env = yield* GcpEnvironment.current;
415 return yield* listOwnedDnsAuthorizations(env.project);
416 }),
417
418 reconcile: Effect.fn(function* ({ id, news, output }) {
419 const env = yield* GcpEnvironment.current;
420 const dnsAuthorizationId = yield* toId(
421 id,
422 news.dnsAuthorizationId,
423 output?.dnsAuthorizationId,
424 );
425 const location = normalizeLocation(news.location ?? output?.location);
426 const name = resourceName(env.project, location, dnsAuthorizationId);
427 const desiredLabels = {
428 ...toLabels(news.labels),
429 ...(yield* createInternalLabels(id)),
430 };
431
432 let current = yield* getByName(name);
433
434 if (current === undefined) {
435 const body: certificatemanager.DnsAuthorization = {
436 domain: news.domain,
437 description: news.description,
438 labels: desiredLabels,
439 };
440 if (news.type !== undefined) {
441 body.type = news.type;
442 }
443 const created = yield* certificatemanager
444 .createProjectsLocationsDnsAuthorizations({
445 parent: `projects/${env.project}/locations/${location}`,
446 dnsAuthorizationId,
447 body,
448 })
449 .pipe(
450 Effect.retry({
451 while: (error) => error._tag === "Conflict",
452 times: 5,
453 schedule: Schedule.spaced("2 seconds"),
454 }),
455 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
456 );
457 if (created !== undefined) {
458 yield* waitForOperation(created);
459 }
460 current = yield* waitUntilExists(name);
461 }
462
463 if (current === undefined) {
464 return yield* new DnsAuthorizationNotResolved({ name });
465 }
466
467 const observedLabels = tagRecord(current.labels);
468 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
469 const labelsChanged = upsert.length > 0 || removed.length > 0;
470 const descriptionChanged =
471 (current.description ?? "") !== (news.description ?? "");
472
473 if (labelsChanged || descriptionChanged) {
474 const updateMask = [
475 labelsChanged ? "labels" : undefined,
476 descriptionChanged ? "description" : undefined,
477 ].filter((field): field is string => field !== undefined);
478
479 const operation =
480 yield* certificatemanager.patchProjectsLocationsDnsAuthorizations({
481 name,
482 updateMask: updateMask.join(","),
483 body: {
484 name,
485 labels: desiredLabels,
486 description: news.description,
487 },
488 });
489 yield* waitForOperation(operation);
490 current = yield* waitUntilExists(name);
491 }
492
493 return toAttrs(current, env.project);
494 }),
495
496 delete: Effect.fn(function* ({ output }) {
497 const operation = yield* certificatemanager
498 .deleteProjectsLocationsDnsAuthorizations({ name: output.name })
499 .pipe(
500 Effect.retry({
501 while: (error) => error._tag === "Conflict",
502 times: 8,
503 schedule: Schedule.spaced("2 seconds"),
504 }),
505 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
506 );
507 if (operation !== undefined) {
508 yield* waitForOperation(operation, { notFoundOk: true });
509 }
510 yield* waitUntilGone(output.name);
511 }),
512 });