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 CertificateMapIpConfig = {
27 /** External IP address where this map is serving. */
28 ipAddress: string | undefined;
29 /** Ports on `ipAddress` where this map is serving. */
30 ports: number[];
31};
32
33export type CertificateMapGclbTarget = {
34 /**
35 * Target HTTPS proxy using this map
36 * (`//compute.googleapis.com/projects/{project}/global/targetHttpsProxies/{proxy}`).
37 */
38 targetHttpsProxy: string | undefined;
39 /**
40 * Target SSL proxy using this map
41 * (`//compute.googleapis.com/projects/{project}/global/targetSslProxies/{proxy}`).
42 */
43 targetSslProxy: string | undefined;
44 /** IP configurations where this map is serving. */
45 ipConfigs: CertificateMapIpConfig[];
46};
47
48export type CertificateMapProps = {
49 /**
50 * Certificate map id (the `{certificateMap}` segment of
51 * `projects/{project}/locations/{location}/certificateMaps/{certificateMap}`).
52 * If omitted, a unique name is generated from the stack, stage, and
53 * logical id. Must be 1-63 characters, start with a letter, and match
54 * `[a-z]([-a-z0-9]*[a-z0-9])?`. Immutable — changing it replaces the
55 * certificate map.
56 */
57 certificateMapId?: string;
58 /**
59 * Certificate Manager location. Certificate maps are global resources;
60 * omit or set `"global"`. Immutable — changing it replaces the map.
61 * `GLOBAL` is accepted and normalized to `global`.
62 * @default "global"
63 */
64 location?: string;
65 /**
66 * Human-readable description.
67 */
68 description?: string;
69 /**
70 * User labels. Alchemy ownership labels are merged in automatically.
71 */
72 labels?: Record<string, string>;
73};
74
75export type CertificateMap = Resource<
76 "GCP.CertificateManager.CertificateMap",
77 CertificateMapProps,
78 {
79 /** Full resource name `projects/{project}/locations/{location}/certificateMaps/{certificateMap}`. */
80 name: string;
81 /** Certificate map id (last path segment). */
82 certificateMapId: string;
83 /** Project id. */
84 project: string;
85 /** Location id (`global`). */
86 location: string;
87 /** User-provided description. */
88 description: string | undefined;
89 /** User labels (Alchemy ownership labels stripped). */
90 labels: Record<string, string>;
91 /** GCLB targets currently using this map (output-only). */
92 gclbTargets: CertificateMapGclbTarget[];
93 /** RFC3339 creation timestamp. */
94 createTime: string | undefined;
95 /** RFC3339 last-update timestamp. */
96 updateTime: string | undefined;
97 },
98 never,
99 Providers
100>;
101
102/**
103 * A Certificate Manager certificate map — a collection of certificate
104 * configurations selected by SNI for global external Application and
105 * proxy Network Load Balancers.
106 *
107 * Changing `certificateMapId` or `location` replaces the resource.
108 * Description and labels update in place. Certificate maps cannot be
109 * deleted while they still contain certificate map entries.
110 *
111 * ### Creating a Certificate Map
112 * **Example:** Generated name
113 * ```typescript
114 * const map = yield* GCP.CertificateManager.CertificateMap("FrontendMap", {});
115 * ```
116 *
117 * **Example:** Named map with labels
118 * ```typescript
119 * const map = yield* GCP.CertificateManager.CertificateMap("FrontendMap", {
120 * certificateMapId: "app-frontend",
121 * description: "prod frontend",
122 * labels: { env: "prod" },
123 * });
124 * ```
125 *
126 * ### Updating a Certificate Map
127 * **Example:** Description and labels
128 * ```typescript
129 * const map = yield* GCP.CertificateManager.CertificateMap("FrontendMap", {
130 * certificateMapId: "app-frontend",
131 * description: "prod frontend v2",
132 * labels: { env: "prod", role: "tls" },
133 * });
134 * ```
135 *
136 * @resource
137 * @category CertificateManager
138 */
139export const CertificateMap = Resource<CertificateMap>(
140 "GCP.CertificateManager.CertificateMap",
141);
142
143export class CertificateMapNotResolved extends Data.TaggedError(
144 "GCP.CertificateManager.CertificateMapNotResolved",
145)<{
146 name: string;
147}> {}
148
149export class CertificateMapStillExists extends Data.TaggedError(
150 "GCP.CertificateManager.CertificateMapStillExists",
151)<{
152 name: string;
153}> {}
154
155const lastSegment = (value: string) => {
156 const trimmed = value.replace(/\/+$/, "");
157 const parts = trimmed.split("/");
158 return parts[parts.length - 1] || trimmed;
159};
160
161const rfc1035 = (name: string): string => {
162 let next = name
163 .toLowerCase()
164 .replace(/[^a-z0-9-]/g, "-")
165 .replace(/-+/g, "-")
166 .replace(/^-+/, "")
167 .replace(/-+$/, "");
168 if (!/^[a-z]/.test(next)) {
169 next = `c${next}`;
170 }
171 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
172 return next.length > 0 ? next : "certificatemap";
173};
174
175const normalizeLocation = (location: string | undefined) =>
176 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
177
178const resourceName = (
179 project: string,
180 location: string,
181 certificateMapId: string,
182) =>
183 `projects/${project}/locations/${location}/certificateMaps/${certificateMapId}`;
184
185const parseName = (name: string) => {
186 const parts = name.split("/").filter((part) => part.length > 0);
187 const mapsAt = parts.lastIndexOf("certificateMaps");
188 const locationsAt = parts.lastIndexOf("locations");
189 const projectsAt = parts.lastIndexOf("projects");
190 return {
191 project:
192 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
193 location:
194 locationsAt >= 0 && parts[locationsAt + 1]
195 ? parts[locationsAt + 1]!
196 : DEFAULT_LOCATION,
197 certificateMapId:
198 mapsAt >= 0 && parts[mapsAt + 1] ? parts[mapsAt + 1]! : lastSegment(name),
199 };
200};
201
202const userLabels = (
203 labels: Record<string, string | undefined> | null | undefined,
204): Record<string, string> => stripInternalLabels(tagRecord(labels));
205
206const toId = (
207 id: string,
208 certificateMapId: string | undefined,
209 existing?: string,
210) =>
211 Effect.gen(function* () {
212 if (certificateMapId !== undefined) return certificateMapId;
213 if (existing !== undefined) return existing;
214 return rfc1035(
215 yield* createPhysicalName({
216 id,
217 maxLength: MAX_NAME_LENGTH,
218 lowercase: true,
219 }),
220 );
221 });
222
223const toGclbTargets = (
224 targets: certificatemanager.GclbTargetList | undefined,
225): CertificateMapGclbTarget[] =>
226 (targets ?? []).map((target) => ({
227 targetHttpsProxy: target.targetHttpsProxy,
228 targetSslProxy: target.targetSslProxy,
229 ipConfigs: (target.ipConfigs ?? []).map((config) => ({
230 ipAddress: config.ipAddress,
231 ports: config.ports ?? [],
232 })),
233 }));
234
235const toAttrs = (map: certificatemanager.CertificateMap, project: string) => {
236 const name = map.name ?? "";
237 const parsed = parseName(name);
238 return {
239 name,
240 certificateMapId: parsed.certificateMapId,
241 project: parsed.project || project,
242 location: parsed.location,
243 description: map.description,
244 labels: userLabels(map.labels),
245 gclbTargets: toGclbTargets(map.gclbTargets),
246 createTime: map.createTime,
247 updateTime: map.updateTime,
248 };
249};
250
251const getByName = (name: string) =>
252 certificatemanager
253 .getProjectsLocationsCertificateMaps({ name })
254 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
255
256const waitUntilExists = (name: string) =>
257 getByName(name).pipe(
258 Effect.flatMap((map) =>
259 map
260 ? Effect.succeed(map)
261 : Effect.fail(new CertificateMapNotResolved({ name })),
262 ),
263 Effect.retry({
264 while: (error) =>
265 error._tag === "GCP.CertificateManager.CertificateMapNotResolved",
266 times: 8,
267 schedule: Schedule.spaced("1 second"),
268 }),
269 );
270
271const waitUntilGone = (name: string) =>
272 getByName(name).pipe(
273 Effect.flatMap((map) =>
274 map === undefined
275 ? Effect.void
276 : Effect.fail(new CertificateMapStillExists({ name })),
277 ),
278 Effect.retry({
279 while: (error) =>
280 error._tag === "GCP.CertificateManager.CertificateMapStillExists",
281 times: 10,
282 schedule: Schedule.spaced("1 second"),
283 }),
284 );
285
286const listOwnedMaps = (project: string) =>
287 certificatemanager.listProjectsLocationsCertificateMaps
288 .pages({
289 parent: `projects/${project}/locations/-`,
290 pageSize: 1000,
291 })
292 .pipe(
293 Stream.flatMap((page) => Stream.fromIterable(page.certificateMaps ?? [])),
294 Stream.filter((map) =>
295 Object.keys(map.labels ?? {}).some((key) => key.startsWith("alchemy-")),
296 ),
297 Stream.map((map) => toAttrs(map, project)),
298 Stream.runCollect,
299 Effect.map((chunk) => Array.from(chunk)),
300 Effect.catchTag("NotFound", () => Effect.succeed([])),
301 );
302
303export const CertificateMapProvider = () =>
304 Provider.succeed(CertificateMap, {
305 stables: ["name", "certificateMapId", "project", "location", "createTime"],
306
307 diff: Effect.fn(function* ({ news, olds, output }) {
308 if (!isResolved(news)) return undefined;
309
310 const previousId = olds?.certificateMapId ?? output?.certificateMapId;
311 const nextId = news.certificateMapId ?? previousId;
312 const previousLocation = normalizeLocation(
313 olds?.location ?? output?.location,
314 );
315 const nextLocation = normalizeLocation(
316 news.location ?? olds?.location ?? output?.location,
317 );
318
319 const replace =
320 (previousId !== undefined &&
321 nextId !== undefined &&
322 nextId !== previousId) ||
323 previousLocation !== nextLocation;
324
325 if (!replace) return undefined;
326 return {
327 action: "replace" as const,
328 };
329 }),
330
331 read: Effect.fn(function* ({ id, olds, output }) {
332 const env = yield* GcpEnvironment.current;
333 const certificateMapId = yield* toId(
334 id,
335 olds?.certificateMapId,
336 output?.certificateMapId,
337 );
338 const location = normalizeLocation(olds?.location ?? output?.location);
339 const name =
340 output?.name ?? resourceName(env.project, location, certificateMapId);
341 const existing = yield* getByName(name);
342 if (existing === undefined) return undefined;
343 const attrs = toAttrs(existing, env.project);
344 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
345 ? attrs
346 : Unowned(attrs);
347 }),
348
349 list: () =>
350 Effect.gen(function* () {
351 const env = yield* GcpEnvironment.current;
352 return yield* listOwnedMaps(env.project);
353 }),
354
355 reconcile: Effect.fn(function* ({ id, news, output }) {
356 const env = yield* GcpEnvironment.current;
357 const certificateMapId = yield* toId(
358 id,
359 news.certificateMapId,
360 output?.certificateMapId,
361 );
362 const location = normalizeLocation(news.location ?? output?.location);
363 const name = resourceName(env.project, location, certificateMapId);
364 const desiredLabels = {
365 ...toLabels(news.labels),
366 ...(yield* createInternalLabels(id)),
367 };
368
369 let current = yield* getByName(name);
370
371 if (current === undefined) {
372 const created = yield* certificatemanager
373 .createProjectsLocationsCertificateMaps({
374 parent: `projects/${env.project}/locations/${location}`,
375 certificateMapId,
376 body: {
377 description: news.description,
378 labels: desiredLabels,
379 },
380 })
381 .pipe(
382 Effect.retry({
383 while: (error) => error._tag === "Conflict",
384 times: 5,
385 schedule: Schedule.spaced("2 seconds"),
386 }),
387 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
388 );
389 if (created !== undefined) {
390 yield* waitForOperation(created);
391 }
392 current = yield* waitUntilExists(name);
393 }
394
395 if (current === undefined) {
396 return yield* new CertificateMapNotResolved({ name });
397 }
398
399 const observedLabels = tagRecord(current.labels);
400 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
401 const labelsChanged = upsert.length > 0 || removed.length > 0;
402 const descriptionChanged =
403 (current.description ?? "") !== (news.description ?? "");
404
405 if (labelsChanged || descriptionChanged) {
406 const updateMask = [
407 labelsChanged ? "labels" : undefined,
408 descriptionChanged ? "description" : undefined,
409 ].filter((field): field is string => field !== undefined);
410
411 const operation =
412 yield* certificatemanager.patchProjectsLocationsCertificateMaps({
413 name,
414 updateMask: updateMask.join(","),
415 body: {
416 name,
417 labels: desiredLabels,
418 description: news.description,
419 },
420 });
421 yield* waitForOperation(operation);
422 current = yield* waitUntilExists(name);
423 }
424
425 return toAttrs(current, env.project);
426 }),
427
428 delete: Effect.fn(function* ({ output }) {
429 const operation = yield* certificatemanager
430 .deleteProjectsLocationsCertificateMaps({ name: output.name })
431 .pipe(
432 Effect.retry({
433 while: (error) => error._tag === "Conflict",
434 times: 8,
435 schedule: Schedule.spaced("2 seconds"),
436 }),
437 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
438 );
439 if (operation !== undefined) {
440 yield* waitForOperation(operation, { notFoundOk: true });
441 }
442 yield* waitUntilGone(output.name);
443 }),
444 });