1import * as alloydb from "@distilled.cloud/gcp/alloydb_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";
13
14const DEFAULT_USER_TYPE = "ALLOYDB_BUILT_IN";
15const MAX_NAME_LENGTH = 63;
16
17const SYSTEM_USERS = new Set(["postgres"]);
18
19export type ClustersUserProps = {
20 /**
21 * Parent cluster id or full resource name
22 * (`projects/{project}/locations/{location}/clusters/{cluster}`).
23 * Immutable — changing it replaces the user.
24 */
25 cluster: string;
26 /**
27 * Region of the parent cluster (`us-central1`, …). Ignored when
28 * `cluster` is a full resource name. Immutable — changing it replaces
29 * the user. `US-CENTRAL1` is accepted and normalized to
30 * `us-central1`.
31 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
32 */
33 location?: string;
34 /**
35 * Database user id (the `{user}` segment of
36 * `.../clusters/{cluster}/users/{user}`). If omitted, a unique
37 * PostgreSQL identifier is generated. For IAM users this is the
38 * principal email. Immutable — changing it replaces the user.
39 */
40 userId?: string;
41 /**
42 * User type. `ALLOYDB_BUILT_IN` requires `password`.
43 * `ALLOYDB_IAM_USER` uses IAM authentication. Immutable — changing
44 * it replaces the user.
45 * @default "ALLOYDB_BUILT_IN"
46 */
47 userType?: alloydb.UserUserTypeEnum | (string & {});
48 /**
49 * Password for `ALLOYDB_BUILT_IN` users. Write-only — never returned
50 * by the API. Updating this value rotates the password.
51 */
52 password?: string;
53 /**
54 * PostgreSQL roles to grant (`alloydbsuperuser`, `pg_monitor`, …).
55 */
56 databaseRoles?: string[];
57 /**
58 * Keep roles the user already has that are not listed in
59 * `databaseRoles`. Create-only.
60 */
61 keepExtraRoles?: boolean;
62};
63
64export type ClustersUser = Resource<
65 "GCP.AlloyDB.ClustersUser",
66 ClustersUserProps,
67 {
68 /** Full resource name `projects/{project}/locations/{location}/clusters/{cluster}/users/{user}`. */
69 name: string;
70 /** Database user id (last path segment). */
71 userId: string;
72 /** Parent cluster id. */
73 clusterId: string;
74 /** Parent cluster resource name. */
75 clusterName: string;
76 /** Project id. */
77 project: string;
78 /** Region id (`us-central1`, …). */
79 location: string;
80 /** User type (`ALLOYDB_BUILT_IN`, `ALLOYDB_IAM_USER`). */
81 userType: string;
82 /** Granted database roles. */
83 databaseRoles: string[];
84 },
85 never,
86 Providers
87>;
88
89/**
90 * A database user on an AlloyDB cluster.
91 *
92 * AlloyDB users have no labels field. `list` enumerates non-`postgres`
93 * users on alchemy-labeled clusters so `pnpm nuke:gcp` can find leaked
94 * rows without dropping the built-in superuser.
95 *
96 * Changing `cluster`, `location`, `userId`, or `userType` replaces the
97 * user. `password` and `databaseRoles` update in place. Creating a
98 * built-in user requires a READY primary instance.
99 *
100 * ### Creating a User
101 * **Example:** Built-in user on a cluster
102 * ```typescript
103 * const cluster = yield* GCP.AlloyDB.Cluster("AppDb", {
104 * pscConfig: { pscEnabled: true },
105 * initialUser: { user: "postgres", password: "change-me" },
106 * });
107 * const primary = yield* GCP.AlloyDB.Instance("Primary", {
108 * cluster: cluster.name,
109 * });
110 * const appUser = yield* GCP.AlloyDB.ClustersUser("AppUser", {
111 * cluster: primary.clusterName,
112 * password: "change-me-too",
113 * databaseRoles: ["alloydbsuperuser"],
114 * });
115 * ```
116 *
117 * **Example:** Explicit id and IAM user
118 * ```typescript
119 * const iamUser = yield* GCP.AlloyDB.ClustersUser("Analyst", {
120 * cluster: cluster.name,
121 * userId: "analyst@example.com",
122 * userType: "ALLOYDB_IAM_USER",
123 * databaseRoles: ["pg_read_all_data"],
124 * });
125 * ```
126 *
127 * @resource
128 * @category AlloyDB
129 */
130export const ClustersUser = Resource<ClustersUser>("GCP.AlloyDB.ClustersUser");
131
132export class ClustersUserNotResolved extends Data.TaggedError(
133 "GCP.AlloyDB.ClustersUserNotResolved",
134)<{
135 name: string;
136}> {}
137
138export class ClustersUserClusterMissing extends Data.TaggedError(
139 "GCP.AlloyDB.ClustersUserClusterMissing",
140)<{
141 message: string;
142}> {}
143
144export class ClustersUserStillExists extends Data.TaggedError(
145 "GCP.AlloyDB.ClustersUserStillExists",
146)<{
147 name: string;
148}> {}
149
150const lastSegment = (value: string | undefined) => {
151 if (value === undefined || value.length === 0) return "";
152 const trimmed = value.replace(/\/+$/, "");
153 const parts = trimmed.split("/");
154 return parts[parts.length - 1] || trimmed;
155};
156
157const normalizeLocation = (location: string) =>
158 lastSegment(location).toLowerCase();
159
160const normalizeUserType = (type: string | undefined) => {
161 const value = (type ?? DEFAULT_USER_TYPE).toUpperCase();
162 return value === "USER_TYPE_UNSPECIFIED" ? DEFAULT_USER_TYPE : value;
163};
164
165const isIamType = (type: string | undefined) =>
166 normalizeUserType(type) === "ALLOYDB_IAM_USER";
167
168const pgIdentifier = (name: string): string => {
169 let next = name
170 .toLowerCase()
171 .replace(/[^a-z0-9_]/g, "_")
172 .replace(/_+/g, "_")
173 .replace(/^_+|_+$/g, "");
174 if (!/^[a-z]/.test(next)) next = `u${next}`;
175 next = next.slice(0, MAX_NAME_LENGTH).replace(/_+$/g, "");
176 return next.length > 0 ? next : "dbuser";
177};
178
179const resourceName = (
180 project: string,
181 location: string,
182 clusterId: string,
183 userId: string,
184) =>
185 `projects/${project}/locations/${location}/clusters/${clusterId}/users/${userId}`;
186
187const clusterNameOf = (project: string, location: string, clusterId: string) =>
188 `projects/${project}/locations/${location}/clusters/${clusterId}`;
189
190const parseName = (name: string) => {
191 const parts = name.split("/").filter((part) => part.length > 0);
192 const usersAt = parts.lastIndexOf("users");
193 const clustersAt = parts.lastIndexOf("clusters");
194 const locationsAt = parts.lastIndexOf("locations");
195 const projectsAt = parts.lastIndexOf("projects");
196 return {
197 project:
198 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
199 location:
200 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
201 clusterId:
202 clustersAt >= 0 && parts[clustersAt + 1] ? parts[clustersAt + 1]! : "",
203 userId:
204 usersAt >= 0 && parts[usersAt + 1]
205 ? parts[usersAt + 1]!
206 : lastSegment(name),
207 };
208};
209
210const parseClusterRef = (
211 cluster: string,
212 fallbackProject: string,
213 fallbackLocation: string,
214) => {
215 const trimmed = cluster.trim();
216 if (trimmed.length === 0) {
217 return {
218 project: fallbackProject,
219 location: normalizeLocation(fallbackLocation),
220 clusterId: "",
221 };
222 }
223 if (trimmed.includes("/clusters/") || trimmed.includes("projects/")) {
224 const parsed = parseName(
225 trimmed.includes("/users/") ? trimmed : `${trimmed}/users/_`,
226 );
227 return {
228 project: parsed.project || fallbackProject,
229 location: normalizeLocation(parsed.location || fallbackLocation),
230 clusterId: parsed.clusterId === "_" ? "" : parsed.clusterId,
231 };
232 }
233 return {
234 project: fallbackProject,
235 location: normalizeLocation(fallbackLocation),
236 clusterId: lastSegment(trimmed),
237 };
238};
239
240const toId = (
241 id: string,
242 userId: string | undefined,
243 existing: string | undefined,
244 userType: string,
245) =>
246 Effect.gen(function* () {
247 if (userId !== undefined) return userId;
248 if (existing !== undefined) return existing;
249 const generated = yield* createPhysicalName({
250 id,
251 maxLength: MAX_NAME_LENGTH,
252 lowercase: true,
253 delimiter: isIamType(userType) ? "-" : "_",
254 });
255 return isIamType(userType) ? generated : pgIdentifier(generated);
256 });
257
258const rolesKey = (roles: string[] | undefined) =>
259 JSON.stringify([...(roles ?? [])].map((role) => role.toLowerCase()).sort());
260
261const toRoles = (roles: string[] | undefined): string[] =>
262 (roles ?? []).filter((role) => role.length > 0);
263
264const hasAlchemyClusterLabels = (
265 labels: Record<string, string | undefined> | null | undefined,
266) =>
267 Object.keys(labels ?? {}).some((key) => key.startsWith(ALCHEMY_LABEL_PREFIX));
268
269const isManagedUser = (user: alloydb.User) => {
270 const name = lastSegment(user.name).toLowerCase();
271 if (name.length === 0) return false;
272 if (SYSTEM_USERS.has(name)) return false;
273 return true;
274};
275
276const isPlaceholder = (user: alloydb.User) => {
277 const name = user.name ?? "";
278 return name.endsWith("/users/-") || name.endsWith("/users/");
279};
280
281const toAttrs = (user: alloydb.User, project: string) => {
282 const name = user.name ?? "";
283 const parsed = parseName(name);
284 const resolvedProject = parsed.project || project;
285 return {
286 name,
287 userId: parsed.userId,
288 clusterId: parsed.clusterId,
289 clusterName: clusterNameOf(
290 resolvedProject,
291 parsed.location,
292 parsed.clusterId,
293 ),
294 project: resolvedProject,
295 location: parsed.location,
296 userType: normalizeUserType(user.userType),
297 databaseRoles: toRoles(user.databaseRoles),
298 };
299};
300
301const getByName = (name: string) =>
302 alloydb
303 .getProjectsLocationsClustersUsers({ name })
304 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
305
306const waitUntilExists = (name: string) =>
307 getByName(name).pipe(
308 Effect.flatMap((user) =>
309 user
310 ? Effect.succeed(user)
311 : Effect.fail(new ClustersUserNotResolved({ name })),
312 ),
313 Effect.retry({
314 while: (error) => error._tag === "GCP.AlloyDB.ClustersUserNotResolved",
315 times: 8,
316 schedule: Schedule.spaced("1 second"),
317 }),
318 );
319
320const waitUntilGone = (name: string) =>
321 getByName(name).pipe(
322 Effect.flatMap((user) =>
323 user === undefined
324 ? Effect.void
325 : Effect.fail(new ClustersUserStillExists({ name })),
326 ),
327 Effect.retry({
328 while: (error) => error._tag === "GCP.AlloyDB.ClustersUserStillExists",
329 times: 10,
330 schedule: Schedule.spaced("1 second"),
331 }),
332 );
333
334const toBody = (
335 news: ClustersUserProps,
336 userType: string,
337 options?: { includePassword?: boolean; includeKeepExtraRoles?: boolean },
338): alloydb.User => {
339 const body: alloydb.User = {
340 userType,
341 };
342 if (news.databaseRoles !== undefined) {
343 body.databaseRoles = news.databaseRoles;
344 }
345 if (
346 options?.includePassword !== false &&
347 news.password !== undefined &&
348 !isIamType(userType)
349 ) {
350 body.password = news.password;
351 }
352 if (
353 options?.includeKeepExtraRoles === true &&
354 news.keepExtraRoles !== undefined
355 ) {
356 body.keepExtraRoles = news.keepExtraRoles;
357 }
358 return body;
359};
360
361export const ClustersUserProvider = () =>
362 Provider.succeed(ClustersUser, {
363 stables: [
364 "name",
365 "userId",
366 "clusterId",
367 "clusterName",
368 "project",
369 "location",
370 ],
371
372 diff: Effect.fn(function* ({ news, olds, output }) {
373 if (!isResolved(news)) return undefined;
374 const env = yield* GcpEnvironment.current;
375
376 const previousId = olds?.userId ?? output?.userId;
377 const nextId = news.userId ?? previousId;
378 const previousCluster = lastSegment(olds?.cluster ?? output?.clusterId);
379 const nextCluster = lastSegment(news.cluster ?? previousCluster);
380 const previousLocation = normalizeLocation(
381 olds?.location ?? output?.location ?? env.region,
382 );
383 const nextLocation = normalizeLocation(
384 news.location ?? output?.location ?? env.region,
385 );
386 const previousType = normalizeUserType(
387 olds?.userType ?? output?.userType,
388 );
389 const nextType = normalizeUserType(news.userType ?? output?.userType);
390
391 const replace =
392 (previousId !== undefined &&
393 nextId !== undefined &&
394 nextId !== previousId) ||
395 (previousCluster.length > 0 &&
396 nextCluster.length > 0 &&
397 previousCluster !== nextCluster) ||
398 previousLocation !== nextLocation ||
399 previousType !== nextType;
400
401 if (!replace) return undefined;
402 return {
403 action: "replace" as const,
404 deleteFirst:
405 previousLocation === nextLocation &&
406 previousCluster === nextCluster &&
407 previousId !== undefined &&
408 nextId === previousId,
409 };
410 }),
411
412 read: Effect.fn(function* ({ id, olds, output }) {
413 const env = yield* GcpEnvironment.current;
414 if (output?.name) {
415 const existing = yield* getByName(output.name);
416 if (existing === undefined) return undefined;
417 return toAttrs(existing, env.project);
418 }
419 const userType = normalizeUserType(olds?.userType ?? output?.userType);
420 const userId = yield* toId(id, olds?.userId, output?.userId, userType);
421 const ref = parseClusterRef(
422 olds?.cluster ?? output?.clusterName ?? output?.clusterId ?? "",
423 env.project,
424 olds?.location ?? output?.location ?? env.region,
425 );
426 if (ref.clusterId.length === 0) return undefined;
427 const name = resourceName(
428 ref.project,
429 ref.location,
430 ref.clusterId,
431 userId,
432 );
433 const existing = yield* getByName(name);
434 if (existing === undefined) return undefined;
435 return toAttrs(existing, env.project);
436 }),
437
438 list: () =>
439 Effect.gen(function* () {
440 const env = yield* GcpEnvironment.current;
441 const clusters = yield* alloydb.listProjectsLocationsClusters
442 .pages({
443 parent: `projects/${env.project}/locations/-`,
444 pageSize: 1000,
445 })
446 .pipe(
447 Stream.flatMap((page) => Stream.fromIterable(page.clusters ?? [])),
448 Stream.filter(
449 (cluster) =>
450 (cluster.name ?? "").length > 0 &&
451 hasAlchemyClusterLabels(cluster.labels),
452 ),
453 Stream.runCollect,
454 Effect.map((chunk) => Array.from(chunk)),
455 Effect.catchTag("NotFound", () => Effect.succeed([])),
456 );
457
458 const pages = yield* Effect.forEach(
459 clusters,
460 (cluster) => {
461 const parent = cluster.name;
462 if (parent === undefined || parent.length === 0) {
463 return Effect.succeed([] as ClustersUser["Attributes"][]);
464 }
465 return alloydb.listProjectsLocationsClustersUsers
466 .pages({
467 parent,
468 pageSize: 1000,
469 })
470 .pipe(
471 Stream.flatMap((page) => Stream.fromIterable(page.users ?? [])),
472 Stream.filter(
473 (user) => !isPlaceholder(user) && isManagedUser(user),
474 ),
475 Stream.map((user) => toAttrs(user, env.project)),
476 Stream.runCollect,
477 Effect.map((chunk) => Array.from(chunk)),
478 Effect.catchTag("NotFound", () => Effect.succeed([])),
479 );
480 },
481 { concurrency: 4 },
482 );
483 return pages.flat();
484 }),
485
486 reconcile: Effect.fn(function* ({ id, news, output, olds }) {
487 const env = yield* GcpEnvironment.current;
488 const userType = normalizeUserType(news.userType);
489 const userId = yield* toId(id, news.userId, output?.userId, userType);
490 const ref = parseClusterRef(
491 news.cluster,
492 env.project,
493 news.location ?? output?.location ?? env.region,
494 );
495 if (ref.clusterId.length === 0) {
496 return yield* new ClustersUserClusterMissing({
497 message:
498 "GCP.AlloyDB.ClustersUser requires `cluster` (cluster id or full resource name)",
499 });
500 }
501 const name = resourceName(
502 ref.project,
503 ref.location,
504 ref.clusterId,
505 userId,
506 );
507 const parent = clusterNameOf(ref.project, ref.location, ref.clusterId);
508
509 let current = yield* getByName(output?.name ?? name);
510
511 if (current === undefined) {
512 const created = yield* alloydb
513 .createProjectsLocationsClustersUsers({
514 parent,
515 userId,
516 body: toBody(news, userType, {
517 includePassword: true,
518 includeKeepExtraRoles: true,
519 }),
520 })
521 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
522 current =
523 created !== undefined ? created : yield* waitUntilExists(name);
524 }
525
526 if (current === undefined) {
527 return yield* new ClustersUserNotResolved({ name });
528 }
529
530 const passwordChanged =
531 news.password !== undefined &&
532 !isIamType(userType) &&
533 (olds !== undefined
534 ? news.password !== olds.password
535 : output !== undefined);
536 const rolesChanged =
537 news.databaseRoles !== undefined &&
538 rolesKey(news.databaseRoles) !== rolesKey(current.databaseRoles);
539
540 if (passwordChanged || rolesChanged) {
541 const updateMask = [
542 passwordChanged ? "password" : undefined,
543 rolesChanged ? "databaseRoles" : undefined,
544 ].filter((field): field is string => field !== undefined);
545
546 current = yield* alloydb
547 .patchProjectsLocationsClustersUsers({
548 name,
549 updateMask: updateMask.join(","),
550 body: toBody(news, userType, {
551 includePassword: passwordChanged,
552 }),
553 })
554 .pipe(
555 Effect.retry({
556 while: (error) => error._tag === "Conflict",
557 times: 8,
558 schedule: Schedule.spaced("2 seconds"),
559 }),
560 );
561 }
562
563 return toAttrs(current, env.project);
564 }),
565
566 delete: Effect.fn(function* ({ output }) {
567 yield* alloydb
568 .deleteProjectsLocationsClustersUsers({ name: output.name })
569 .pipe(
570 Effect.catchTag("NotFound", () => Effect.void),
571 Effect.retry({
572 while: (error) => error._tag === "Conflict",
573 times: 8,
574 schedule: Schedule.spaced("2 seconds"),
575 }),
576 );
577 yield* waitUntilGone(output.name);
578 }),
579 });