1import * as sqladmin from "@distilled.cloud/gcp/sqladmin_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 { 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 { ALCHEMY_LABEL_PREFIX } from "../Labels.ts";
12import type { Providers } from "../Providers.ts";
13import { recoverIfInstanceMissing, waitForSqlOperation } from "./operations.ts";
14
15const MAX_COMMON_NAME_LENGTH = 64;
16
17export type SslCertProps = {
18 /**
19 * Cloud SQL instance id (the `{instance}` segment of
20 * `projects/{project}/instances/{instance}`). Full resource names are
21 * accepted and reduced to the last path segment. Immutable — changing
22 * it replaces the certificate.
23 */
24 instance: string;
25 /**
26 * Common name used to identify the client certificate. If omitted, a
27 * unique name is generated from the stack, stage, and logical id.
28 * Must be distinct from other certificates on the instance. Constrained
29 * to letters, digits, dots, hyphens, underscores, and spaces. Immutable
30 * — changing it replaces the certificate.
31 */
32 commonName?: string;
33};
34
35export type SslCert = Resource<
36 "GCP.SQL.SslCert",
37 SslCertProps,
38 {
39 /** Common name used to identify the client certificate. */
40 commonName: string;
41 /** Cloud SQL instance id. */
42 instance: string;
43 /** Project id. */
44 project: string;
45 /** SHA-1 fingerprint; the API id for get/delete. */
46 sha1Fingerprint: string;
47 /** PEM-encoded client certificate. */
48 cert: string | undefined;
49 /**
50 * PEM-encoded private key. Returned only by insert and persisted in
51 * state — subsequent gets omit it.
52 */
53 privateKey: string | undefined;
54 /** Serial number extracted from the certificate. */
55 certSerialNumber: string | undefined;
56 /** RFC3339 creation timestamp. */
57 createTime: string | undefined;
58 /** RFC3339 expiry timestamp. */
59 expirationTime: string | undefined;
60 /** SQL Admin self-link. */
61 selfLink: string | undefined;
62 /**
63 * PEM-encoded server CA certificate from insert. Subsequent gets omit
64 * it, so the value is persisted from the create response.
65 */
66 serverCaCert: string | undefined;
67 },
68 never,
69 Providers
70>;
71
72/**
73 * A Cloud SQL client SSL certificate.
74 *
75 * Insert returns the private key once; it is stored on the resource and
76 * cannot be recovered from the API later. Certificates have no labels
77 * field. `list` enumerates certs on alchemy-labeled instances so
78 * `pnpm nuke:gcp` can find leaked rows. Changing `instance` or
79 * `commonName` replaces the certificate. The new certificate is not
80 * usable until the instance is restarted.
81 *
82 * ### Creating a Certificate
83 * **Example:** Generated common name on a Cloud SQL instance
84 * ```typescript
85 * const instance = yield* GCP.SQL.Instance("AppDb", {
86 * tier: "db-f1-micro",
87 * backupEnabled: false,
88 * });
89 * const cert = yield* GCP.SQL.SslCert("Client", {
90 * instance: instance.instanceName,
91 * });
92 * ```
93 *
94 * **Example:** Explicit common name
95 * ```typescript
96 * const cert = yield* GCP.SQL.SslCert("Client", {
97 * instance: instance.instanceName,
98 * commonName: "app-client",
99 * });
100 * ```
101 *
102 * @resource
103 * @category SQL
104 */
105export const SslCert = Resource<SslCert>("GCP.SQL.SslCert");
106
107export class SslCertNotResolved extends Data.TaggedError(
108 "GCP.SQL.SslCertNotResolved",
109)<{
110 instance: string;
111 sha1Fingerprint: string;
112}> {}
113
114export class SslCertStillExists extends Data.TaggedError(
115 "GCP.SQL.SslCertStillExists",
116)<{
117 instance: string;
118 sha1Fingerprint: string;
119}> {}
120
121const lastSegment = (value: string) => {
122 const trimmed = value.replace(/\/+$/, "");
123 const parts = trimmed.split("/");
124 return parts[parts.length - 1] || trimmed;
125};
126
127const instanceIdOf = (value: string) => lastSegment(value);
128
129const hasAlchemyInstanceLabels = (
130 labels: Record<string, string | undefined> | null | undefined,
131) =>
132 Object.keys(labels ?? {}).some((key) => key.startsWith(ALCHEMY_LABEL_PREFIX));
133
134const sanitizeCommonName = (name: string) => {
135 const mapped = name.replace(
136 /[0-9]/g,
137 (digit) => "abcdefghij"[Number(digit)] ?? "a",
138 );
139 let next = mapped.replace(/[^a-zA-Z.\-_ ]/g, "-").replace(/[-\s]+/g, "-");
140 next = next.replace(/^[-. ]+|[-. ]+$/g, "");
141 if (!/^[a-zA-Z]/.test(next)) next = `c${next}`;
142 next = next.slice(0, MAX_COMMON_NAME_LENGTH).replace(/[-. ]+$/g, "");
143 return next.length > 0 ? next : "cert";
144};
145
146const toCommonName = (
147 id: string,
148 commonName: string | undefined,
149 existing?: string,
150) =>
151 Effect.gen(function* () {
152 if (commonName !== undefined) return commonName;
153 if (existing !== undefined) return existing;
154 return sanitizeCommonName(
155 yield* createPhysicalName({
156 id,
157 maxLength: MAX_COMMON_NAME_LENGTH,
158 lowercase: true,
159 delimiter: "-",
160 }),
161 );
162 });
163
164type SslCertSecrets = {
165 privateKey?: string;
166 serverCaCert?: string;
167};
168
169const toAttrs = (
170 cert: sqladmin.SslCert,
171 project: string,
172 instance: string,
173 secrets?: SslCertSecrets,
174) => ({
175 commonName: cert.commonName ?? "",
176 instance: cert.instance ?? instance,
177 project,
178 sha1Fingerprint: cert.sha1Fingerprint ?? "",
179 cert: cert.cert,
180 privateKey: secrets?.privateKey,
181 certSerialNumber: cert.certSerialNumber,
182 createTime: cert.createTime,
183 expirationTime: cert.expirationTime,
184 selfLink: cert.selfLink,
185 serverCaCert: secrets?.serverCaCert,
186});
187
188const getByFingerprint = (
189 project: string,
190 instance: string,
191 sha1Fingerprint: string,
192) =>
193 sha1Fingerprint.length === 0
194 ? Effect.succeed(undefined)
195 : sqladmin.getSslCerts({ project, instance, sha1Fingerprint }).pipe(
196 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
197 recoverIfInstanceMissing(project, instance, () => undefined),
198 );
199
200const findByCommonName = (
201 project: string,
202 instance: string,
203 commonName: string,
204) =>
205 commonName.length === 0
206 ? Effect.succeed(undefined)
207 : sqladmin.listSslCerts({ project, instance }).pipe(
208 Effect.map((page) =>
209 (page.items ?? []).find((cert) => cert.commonName === commonName),
210 ),
211 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
212 recoverIfInstanceMissing(project, instance, () => undefined),
213 );
214
215const observe = (
216 project: string,
217 instance: string,
218 sha1Fingerprint: string | undefined,
219 commonName: string,
220) =>
221 Effect.gen(function* () {
222 if (sha1Fingerprint !== undefined && sha1Fingerprint.length > 0) {
223 const byFingerprint = yield* getByFingerprint(
224 project,
225 instance,
226 sha1Fingerprint,
227 );
228 if (byFingerprint !== undefined) return byFingerprint;
229 }
230 return yield* findByCommonName(project, instance, commonName);
231 });
232
233const waitForOperation = (
234 project: string,
235 operation: sqladmin.Operation,
236 options?: { notFoundOk?: boolean },
237) =>
238 waitForSqlOperation(project, operation, {
239 budget: "10 minutes",
240 notFoundOk: options?.notFoundOk,
241 });
242
243const waitUntilExists = (
244 project: string,
245 instance: string,
246 sha1Fingerprint: string,
247) =>
248 getByFingerprint(project, instance, sha1Fingerprint).pipe(
249 Effect.flatMap((cert) =>
250 cert
251 ? Effect.succeed(cert)
252 : Effect.fail(new SslCertNotResolved({ instance, sha1Fingerprint })),
253 ),
254 Effect.retry({
255 while: (error) => error._tag === "GCP.SQL.SslCertNotResolved",
256 times: 8,
257 schedule: Schedule.spaced("1 second"),
258 }),
259 );
260
261const waitUntilGone = (
262 project: string,
263 instance: string,
264 sha1Fingerprint: string,
265) =>
266 getByFingerprint(project, instance, sha1Fingerprint).pipe(
267 Effect.flatMap((cert) =>
268 cert === undefined
269 ? Effect.void
270 : Effect.fail(new SslCertStillExists({ instance, sha1Fingerprint })),
271 ),
272 Effect.retry({
273 while: (error) => error._tag === "GCP.SQL.SslCertStillExists",
274 times: 10,
275 schedule: Schedule.spaced("1 second"),
276 }),
277 );
278
279export const SslCertProvider = () =>
280 Provider.succeed(SslCert, {
281 stables: [
282 "commonName",
283 "instance",
284 "project",
285 "sha1Fingerprint",
286 "selfLink",
287 "privateKey",
288 "serverCaCert",
289 "certSerialNumber",
290 "createTime",
291 ],
292
293 diff: Effect.fn(function* ({ news, olds, output }) {
294 if (!isResolved(news)) return undefined;
295 const previousInstance = olds?.instance ?? output?.instance;
296 const nextInstance = news.instance;
297 const previousName = olds?.commonName ?? output?.commonName;
298 const nextName = news.commonName ?? previousName;
299 const instanceChanged =
300 previousInstance !== undefined &&
301 instanceIdOf(previousInstance) !== instanceIdOf(nextInstance);
302 const nameChanged =
303 previousName !== undefined &&
304 nextName !== undefined &&
305 previousName !== nextName;
306 if (!instanceChanged && !nameChanged) return undefined;
307 return {
308 action: "replace" as const,
309 deleteFirst: false,
310 };
311 }),
312
313 read: Effect.fn(function* ({ id, olds, output }) {
314 const env = yield* GcpEnvironment.current;
315 const instance = instanceIdOf(olds?.instance ?? output?.instance ?? "");
316 if (instance.length === 0) return undefined;
317 const commonName = yield* toCommonName(
318 id,
319 olds?.commonName,
320 output?.commonName,
321 );
322 const existing = yield* observe(
323 env.project,
324 instance,
325 output?.sha1Fingerprint,
326 commonName,
327 );
328 if (existing === undefined) return undefined;
329 return toAttrs(existing, env.project, instance, {
330 privateKey: output?.privateKey,
331 serverCaCert: output?.serverCaCert,
332 });
333 }),
334
335 list: () =>
336 Effect.gen(function* () {
337 const env = yield* GcpEnvironment.current;
338 const instances = yield* sqladmin.listInstances
339 .items({
340 project: env.project,
341 maxResults: 1000,
342 filter: "instanceType:CLOUD_SQL_INSTANCE",
343 })
344 .pipe(
345 Stream.filter((instance) =>
346 hasAlchemyInstanceLabels(instance.settings?.userLabels),
347 ),
348 Stream.runCollect,
349 Effect.map((chunk) => Array.from(chunk)),
350 Effect.catchTag("NotFound", () =>
351 Effect.succeed([] as sqladmin.DatabaseInstance[]),
352 ),
353 );
354 const pages = yield* Effect.forEach(
355 instances,
356 (instance) => {
357 const instanceName = instance.name;
358 if (instanceName === undefined || instanceName.length === 0) {
359 return Effect.succeed([] as SslCert["Attributes"][]);
360 }
361 return sqladmin
362 .listSslCerts({
363 project: env.project,
364 instance: instanceName,
365 })
366 .pipe(
367 Effect.map((page) =>
368 (page.items ?? []).map((cert) =>
369 toAttrs(cert, env.project, instanceName),
370 ),
371 ),
372 // The instance may be deleted mid-listing.
373 Effect.catchTag("NotFound", () =>
374 Effect.succeed([] as SslCert["Attributes"][]),
375 ),
376 recoverIfInstanceMissing(
377 env.project,
378 instanceName,
379 () => [] as SslCert["Attributes"][],
380 ),
381 );
382 },
383 { concurrency: 4 },
384 );
385 return pages.flat();
386 }),
387
388 reconcile: Effect.fn(function* ({ id, news, output }) {
389 const env = yield* GcpEnvironment.current;
390 const instance = instanceIdOf(news.instance);
391 const commonName = yield* toCommonName(
392 id,
393 news.commonName,
394 output?.commonName,
395 );
396
397 let current = yield* observe(
398 env.project,
399 instance,
400 output?.sha1Fingerprint,
401 commonName,
402 );
403 let secrets: SslCertSecrets = {
404 privateKey: output?.privateKey,
405 serverCaCert: output?.serverCaCert,
406 };
407
408 if (current === undefined) {
409 const inserted = yield* sqladmin
410 .insertSslCerts({
411 project: env.project,
412 instance,
413 body: { commonName },
414 })
415 .pipe(
416 Effect.tap((response) =>
417 response.operation
418 ? waitForOperation(env.project, response.operation)
419 : Effect.void,
420 ),
421 Effect.catchTag("Conflict", (error) =>
422 findByCommonName(env.project, instance, commonName).pipe(
423 Effect.flatMap((existing) =>
424 existing ? Effect.succeed(undefined) : Effect.fail(error),
425 ),
426 ),
427 ),
428 Effect.retry({
429 while: (error) => error._tag === "Conflict",
430 times: 8,
431 schedule: Schedule.spaced("5 seconds"),
432 }),
433 );
434
435 if (inserted !== undefined) {
436 secrets = {
437 privateKey:
438 inserted.clientCert?.certPrivateKey ?? secrets.privateKey,
439 serverCaCert: inserted.serverCaCert?.cert ?? secrets.serverCaCert,
440 };
441 const fingerprint =
442 inserted.clientCert?.certInfo?.sha1Fingerprint ?? "";
443 current =
444 inserted.clientCert?.certInfo ??
445 (fingerprint.length > 0
446 ? yield* waitUntilExists(env.project, instance, fingerprint)
447 : yield* findByCommonName(env.project, instance, commonName));
448 } else {
449 current = yield* findByCommonName(env.project, instance, commonName);
450 }
451 }
452
453 if (
454 current === undefined ||
455 (current.sha1Fingerprint ?? "").length === 0
456 ) {
457 return yield* new SslCertNotResolved({
458 instance,
459 sha1Fingerprint: current?.sha1Fingerprint ?? "",
460 });
461 }
462
463 if ((current.cert ?? "").length === 0) {
464 current = yield* waitUntilExists(
465 env.project,
466 instance,
467 current.sha1Fingerprint ?? "",
468 );
469 }
470
471 return toAttrs(current, env.project, instance, secrets);
472 }),
473
474 delete: Effect.fn(function* ({ olds, output }) {
475 const project = output.project;
476 const instance = instanceIdOf(output.instance);
477 let sha1Fingerprint = output.sha1Fingerprint;
478 if (sha1Fingerprint.length === 0) {
479 const existing = yield* findByCommonName(
480 project,
481 instance,
482 output.commonName || olds.commonName || "",
483 );
484 sha1Fingerprint = existing?.sha1Fingerprint ?? "";
485 }
486 if (sha1Fingerprint.length === 0) return;
487 yield* sqladmin
488 .deleteSslCerts({
489 project,
490 instance,
491 sha1Fingerprint,
492 })
493 .pipe(
494 Effect.flatMap((operation) =>
495 waitForOperation(project, operation, { notFoundOk: true }),
496 ),
497 Effect.catchTag("NotFound", () => Effect.void),
498 recoverIfInstanceMissing(project, instance, () => undefined),
499 Effect.retry({
500 while: (error) => error._tag === "Conflict",
501 times: 8,
502 schedule: Schedule.spaced("5 seconds"),
503 }),
504 );
505 yield* waitUntilGone(project, instance, sha1Fingerprint);
506 }),
507 });