1import * as looker from "@distilled.cloud/gcp/looker_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 { GcpEnvironment } from "../Environment.ts";
12import type { Providers } from "../Providers.ts";
13import { waitForOperation } from "./operations.ts";
14
15const MAX_NAME_LENGTH = 63;
16const OWNERSHIP_PREFIX = "alch-";
17const UUID_RE =
18 /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
19
20export type EncryptionConfig = {
21 /** CMEK key resource name. */
22 kmsKeyName?: string;
23 /** Full CMEK key version currently in use. */
24 kmsKeyNameVersion?: string;
25 /** CMEK key status (`VALID`, `REVOKED`, …). */
26 kmsKeyState?: string;
27};
28
29export type InstancesBackupProps = {
30 /**
31 * Parent Looker instance. Full name
32 * `projects/{project}/locations/{location}/instances/{instance}` or
33 * the instance id (combined with `location`). Immutable — changing it
34 * replaces the backup.
35 */
36 instance: string;
37 /**
38 * Region of the parent instance when `instance` is a bare id.
39 * Ignored when `instance` is a full resource name. Immutable —
40 * changing it replaces the backup. `US-CENTRAL1` is accepted and
41 * normalized to `us-central1`.
42 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
43 */
44 location?: string;
45 /**
46 * Backup id (the `{backup}` segment of
47 * `.../instances/{instance}/backups/{backup}`). If omitted, a unique
48 * RFC1035 name is generated with an `alch-` prefix so `list` / nuke
49 * can find it. The backups API has no labels or description field;
50 * ownership is stamped in the backup id. Immutable — changing it
51 * replaces the backup. The API may ignore a requested id and assign
52 * a UUID; Alchemy then tracks the server-assigned name.
53 */
54 backupId?: string;
55};
56
57export type InstancesBackup = Resource<
58 "GCP.Looker.InstancesBackup",
59 InstancesBackupProps,
60 {
61 /** Full resource name `projects/{project}/locations/{location}/instances/{instance}/backups/{backup}`. */
62 name: string;
63 /** Backup id (last path segment). */
64 backupId: string;
65 /** Parent instance resource name. */
66 instance: string;
67 /** Parent instance id. */
68 instanceId: string;
69 /** Project id. */
70 project: string;
71 /** Region id (`us-central1`, …). */
72 location: string;
73 /** Server-reported state (`ACTIVE`, `CREATING`, `FAILED`, …). */
74 state: string | undefined;
75 /** Customer-managed encryption status, if any. */
76 encryptionConfig: EncryptionConfig | undefined;
77 /** RFC3339 time when the backup was started. */
78 createTime: string | undefined;
79 /** RFC3339 time when the backup will be deleted. */
80 expireTime: string | undefined;
81 },
82 never,
83 Providers
84>;
85
86/**
87 * A Looker (Google Cloud core) instance backup.
88 *
89 * Backups are existence-only: the API exposes no mutable fields. Changing
90 * `instance`, `location`, or `backupId` replaces the backup. Looker
91 * instance backups have no labels or description, so Alchemy stamps
92 * ownership into a generated `alch-` backup id for `list` / nuke.
93 *
94 * Create and delete are long-running operations. Provisioning a parent
95 * Looker instance is slow and billed; live lifecycle tests skipIf-gate
96 * behind `FAST` and the typed Looker API entitlement error.
97 *
98 * ### Creating a Backup
99 * **Example:** Generated id
100 * ```typescript
101 * const backup = yield* GCP.Looker.InstancesBackup("Nightly", {
102 * instance: instance.name,
103 * });
104 * ```
105 *
106 * **Example:** Explicit id and parent
107 * ```typescript
108 * const backup = yield* GCP.Looker.InstancesBackup("Nightly", {
109 * instance: "projects/my-project/locations/us-central1/instances/analytics",
110 * backupId: "alch-nightly",
111 * });
112 * ```
113 *
114 * @resource
115 * @category Looker
116 */
117export const InstancesBackup = Resource<InstancesBackup>(
118 "GCP.Looker.InstancesBackup",
119);
120
121export class InstancesBackupNotResolved extends Data.TaggedError(
122 "GCP.Looker.InstancesBackupNotResolved",
123)<{
124 name: string;
125}> {}
126
127export class InstancesBackupInstanceMissing extends Data.TaggedError(
128 "GCP.Looker.InstancesBackupInstanceMissing",
129)<{
130 message: string;
131}> {}
132
133export class InstancesBackupNotReady extends Data.TaggedError(
134 "GCP.Looker.InstancesBackupNotReady",
135)<{
136 name: string;
137 state: string;
138}> {}
139
140export class InstancesBackupFailed extends Data.TaggedError(
141 "GCP.Looker.InstancesBackupFailed",
142)<{
143 name: string;
144 state: string;
145}> {}
146
147export class InstancesBackupStillExists extends Data.TaggedError(
148 "GCP.Looker.InstancesBackupStillExists",
149)<{
150 name: string;
151}> {}
152
153const lastSegment = (value: string | undefined) => {
154 if (value === undefined || value.length === 0) return "";
155 const trimmed = value.replace(/\/+$/, "");
156 const parts = trimmed.split("/");
157 return parts[parts.length - 1] || trimmed;
158};
159
160const rfc1035 = (name: string): string => {
161 let next = name
162 .toLowerCase()
163 .replace(/[^a-z0-9-]/g, "-")
164 .replace(/-+/g, "-")
165 .replace(/^-+|-+$/g, "");
166 if (!/^[a-z]/.test(next)) next = `b${next}`;
167 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
168 if (next.length === 0) return "backup";
169 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
170 return next.slice(0, MAX_NAME_LENGTH);
171};
172
173const normalizeLocation = (location: string | undefined, fallback: string) =>
174 lastSegment(location ?? fallback).toLowerCase();
175
176const instanceNameOf = (
177 project: string,
178 location: string,
179 instanceId: string,
180) => `projects/${project}/locations/${location}/instances/${instanceId}`;
181
182const resourceName = (instance: string, backupId: string) =>
183 `${instance}/backups/${backupId}`;
184
185const parseBackupName = (name: string, fallbackLocation: string) => {
186 const parts = name.split("/").filter((part) => part.length > 0);
187 const backupsAt = parts.lastIndexOf("backups");
188 const instancesAt = parts.lastIndexOf("instances");
189 const locationsAt = parts.lastIndexOf("locations");
190 const projectsAt = parts.lastIndexOf("projects");
191 const instanceId =
192 instancesAt >= 0 && parts[instancesAt + 1] ? parts[instancesAt + 1]! : "";
193 const project =
194 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "";
195 const location =
196 locationsAt >= 0 && parts[locationsAt + 1]
197 ? parts[locationsAt + 1]!
198 : fallbackLocation;
199 return {
200 project,
201 location,
202 instanceId,
203 instance:
204 instanceId.length > 0
205 ? instanceNameOf(project, location, instanceId)
206 : "",
207 backupId:
208 backupsAt >= 0 && parts[backupsAt + 1]
209 ? parts[backupsAt + 1]!
210 : lastSegment(name),
211 };
212};
213
214const parseInstanceRef = (
215 instance: string,
216 fallbackProject: string,
217 fallbackLocation: string | undefined,
218 region: string,
219) => {
220 const trimmed = instance.trim();
221 if (trimmed.length === 0) {
222 return {
223 project: fallbackProject,
224 location: normalizeLocation(fallbackLocation, region),
225 instanceId: "",
226 instanceName: "",
227 };
228 }
229 if (trimmed.includes("/instances/") || trimmed.includes("projects/")) {
230 const parsed = parseBackupName(
231 trimmed.includes("/backups/") ? trimmed : `${trimmed}/backups/-`,
232 "",
233 );
234 const location = normalizeLocation(
235 parsed.location || fallbackLocation,
236 region,
237 );
238 const project = parsed.project || fallbackProject;
239 return {
240 project,
241 location,
242 instanceId: parsed.instanceId,
243 instanceName: instanceNameOf(project, location, parsed.instanceId),
244 };
245 }
246 const location = normalizeLocation(fallbackLocation, region);
247 const instanceId = lastSegment(trimmed);
248 return {
249 project: fallbackProject,
250 location,
251 instanceId,
252 instanceName: instanceNameOf(fallbackProject, location, instanceId),
253 };
254};
255
256const toEncryptionConfig = (
257 config: looker.EncryptionConfig | undefined,
258): EncryptionConfig | undefined => {
259 if (config === undefined) return undefined;
260 if (
261 (config.kmsKeyName === undefined || config.kmsKeyName.length === 0) &&
262 (config.kmsKeyNameVersion === undefined ||
263 config.kmsKeyNameVersion.length === 0) &&
264 (config.kmsKeyState === undefined || config.kmsKeyState.length === 0)
265 ) {
266 return undefined;
267 }
268 return {
269 kmsKeyName: config.kmsKeyName,
270 kmsKeyNameVersion: config.kmsKeyNameVersion,
271 kmsKeyState: config.kmsKeyState,
272 };
273};
274
275const isUuid = (value: string) => UUID_RE.test(value);
276
277const isOwnedBackupId = (backupId: string) =>
278 backupId.startsWith(OWNERSHIP_PREFIX) && !isUuid(backupId);
279
280const isPlaceholder = (backup: looker.InstanceBackup) => {
281 const name = backup.name ?? "";
282 return (
283 name.length === 0 ||
284 name.endsWith("/backups/-") ||
285 name.endsWith("/backups/")
286 );
287};
288
289const isAvailable = (state: string | undefined) =>
290 (state ?? "").toUpperCase() === "ACTIVE";
291
292const isFailed = (state: string | undefined) =>
293 (state ?? "").toUpperCase() === "FAILED";
294
295const toAttrs = (
296 backup: looker.InstanceBackup,
297 project: string,
298 region: string,
299) => {
300 const name = backup.name ?? "";
301 const parsed = parseBackupName(name, region);
302 return {
303 name,
304 backupId: parsed.backupId,
305 instance: parsed.instance,
306 instanceId: parsed.instanceId,
307 project: parsed.project || project,
308 location: parsed.location,
309 state: backup.state,
310 encryptionConfig: toEncryptionConfig(backup.encryptionConfig),
311 createTime: backup.createTime,
312 expireTime: backup.expireTime,
313 };
314};
315
316const toId = (id: string, backupId: string | undefined, existing?: string) =>
317 Effect.gen(function* () {
318 if (backupId !== undefined && backupId.length > 0) return backupId;
319 if (existing !== undefined && existing.length > 0) return existing;
320 const generated = yield* createPhysicalName({
321 id,
322 maxLength: MAX_NAME_LENGTH - OWNERSHIP_PREFIX.length,
323 lowercase: true,
324 });
325 return rfc1035(`${OWNERSHIP_PREFIX}${generated}`);
326 });
327
328const getByName = (name: string) =>
329 name.length === 0 || name.includes("//") || name.endsWith("/backups/-")
330 ? Effect.succeed(undefined)
331 : looker
332 .getProjectsLocationsInstancesBackups({ name })
333 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
334
335const waitUntilExists = (name: string) =>
336 getByName(name).pipe(
337 Effect.flatMap((backup) =>
338 backup
339 ? Effect.succeed(backup)
340 : Effect.fail(new InstancesBackupNotResolved({ name })),
341 ),
342 Effect.retry({
343 while: (error) => error._tag === "GCP.Looker.InstancesBackupNotResolved",
344 times: 8,
345 schedule: Schedule.spaced("1 second"),
346 }),
347 );
348
349const waitUntilReady = (name: string) =>
350 Effect.gen(function* () {
351 const backup = yield* getByName(name);
352 if (backup === undefined) {
353 return yield* new InstancesBackupNotReady({ name, state: "MISSING" });
354 }
355 if (isFailed(backup.state)) {
356 return yield* new InstancesBackupFailed({
357 name,
358 state: backup.state ?? "FAILED",
359 });
360 }
361 if (!isAvailable(backup.state)) {
362 return yield* new InstancesBackupNotReady({
363 name,
364 state: backup.state ?? "STATE_UNSPECIFIED",
365 });
366 }
367 return backup;
368 }).pipe(
369 Effect.retry({
370 while: (error) => error._tag === "GCP.Looker.InstancesBackupNotReady",
371 times: 10,
372 schedule: Schedule.spaced("8 seconds"),
373 }),
374 );
375
376const waitUntilGone = (name: string) =>
377 getByName(name).pipe(
378 Effect.flatMap((backup) =>
379 backup === undefined
380 ? Effect.void
381 : Effect.fail(new InstancesBackupStillExists({ name })),
382 ),
383 Effect.retry({
384 while: (error) => error._tag === "GCP.Looker.InstancesBackupStillExists",
385 times: 10,
386 schedule: Schedule.spaced("8 seconds"),
387 }),
388 );
389
390const listInstanceBackups = (parent: string) =>
391 looker.listProjectsLocationsInstancesBackups
392 .pages({
393 parent,
394 pageSize: 1000,
395 })
396 .pipe(
397 Stream.flatMap((page) => Stream.fromIterable(page.instanceBackups ?? [])),
398 Stream.runCollect,
399 Effect.map((chunk) => Array.from(chunk)),
400 Effect.catchTag("NotFound", () => Effect.succeed([])),
401 );
402
403const listOwned = (project: string, region: string) =>
404 looker.listProjectsLocationsInstances
405 .pages({
406 parent: `projects/${project}/locations/-`,
407 pageSize: 1000,
408 })
409 .pipe(
410 Stream.flatMap((page) => Stream.fromIterable(page.instances ?? [])),
411 Stream.filter((instance) => (instance.name ?? "").length > 0),
412 Stream.runCollect,
413 Effect.map((chunk) => Array.from(chunk)),
414 Effect.catchTag("NotFound", () =>
415 Effect.succeed([] as looker.Instance[]),
416 ),
417 Effect.flatMap((instances) =>
418 instances.length > 0
419 ? Effect.succeed(instances)
420 : looker.listProjectsLocationsInstances
421 .pages({
422 parent: `projects/${project}/locations/${region}`,
423 pageSize: 1000,
424 })
425 .pipe(
426 Stream.flatMap((page) =>
427 Stream.fromIterable(page.instances ?? []),
428 ),
429 Stream.runCollect,
430 Effect.map((chunk) => Array.from(chunk)),
431 Effect.catchTag("NotFound", () =>
432 Effect.succeed([] as looker.Instance[]),
433 ),
434 ),
435 ),
436 Effect.flatMap((instances) =>
437 Effect.forEach(
438 instances.filter((instance) => (instance.name ?? "").length > 0),
439 (instance) => listInstanceBackups(instance.name!),
440 { concurrency: 4 },
441 ),
442 ),
443 Effect.map((pages) =>
444 pages
445 .flat()
446 .filter(
447 (backup) =>
448 !isPlaceholder(backup) &&
449 isOwnedBackupId(
450 parseBackupName(backup.name ?? "", region).backupId,
451 ),
452 ),
453 ),
454 );
455
456export const InstancesBackupProvider = () =>
457 Provider.succeed(InstancesBackup, {
458 stables: [
459 "name",
460 "backupId",
461 "instance",
462 "instanceId",
463 "project",
464 "location",
465 "createTime",
466 ],
467
468 diff: Effect.fn(function* ({ news, olds, output }) {
469 if (!isResolved(news)) return undefined;
470 const env = yield* GcpEnvironment.current;
471
472 const previousId = olds?.backupId ?? output?.backupId;
473 const nextId = news.backupId ?? previousId;
474 const previousInstance = lastSegment(
475 olds?.instance ?? output?.instance ?? output?.instanceId,
476 );
477 const nextInstance = lastSegment(news.instance ?? previousInstance);
478 const previousLocation = normalizeLocation(
479 olds?.location ?? output?.location,
480 env.region,
481 );
482 const nextLocation = normalizeLocation(
483 news.location ?? output?.location,
484 env.region,
485 );
486
487 const replace =
488 (previousId !== undefined &&
489 nextId !== undefined &&
490 nextId !== previousId) ||
491 (previousInstance.length > 0 &&
492 nextInstance.length > 0 &&
493 previousInstance !== nextInstance) ||
494 previousLocation !== nextLocation;
495
496 if (!replace) return undefined;
497 return {
498 action: "replace" as const,
499 deleteFirst:
500 previousLocation === nextLocation &&
501 previousInstance === nextInstance &&
502 previousId !== undefined &&
503 nextId === previousId,
504 };
505 }),
506
507 read: Effect.fn(function* ({ id, olds, output }) {
508 const env = yield* GcpEnvironment.current;
509 if (output?.name) {
510 const existing = yield* getByName(output.name);
511 if (existing === undefined) return undefined;
512 const attrs = toAttrs(existing, env.project, env.region);
513 const owned =
514 isOwnedBackupId(attrs.backupId) ||
515 attrs.name === output.name ||
516 attrs.backupId === (olds?.backupId ?? output.backupId);
517 return owned ? attrs : Unowned(attrs);
518 }
519 const backupId = yield* toId(id, olds?.backupId, output?.backupId);
520 const ref = parseInstanceRef(
521 olds?.instance ?? output?.instance ?? output?.instanceId ?? "",
522 env.project,
523 olds?.location ?? output?.location,
524 env.region,
525 );
526 if (ref.instanceId.length === 0) return undefined;
527 const name = resourceName(ref.instanceName, backupId);
528 const existing = yield* getByName(name);
529 if (existing === undefined) return undefined;
530 const attrs = toAttrs(existing, env.project, env.region);
531 return isOwnedBackupId(attrs.backupId) || attrs.backupId === backupId
532 ? attrs
533 : Unowned(attrs);
534 }),
535
536 list: () =>
537 Effect.gen(function* () {
538 const env = yield* GcpEnvironment.current;
539 const items = yield* listOwned(env.project, env.region);
540 return items.map((item) => toAttrs(item, env.project, env.region));
541 }),
542
543 reconcile: Effect.fn(function* ({ id, news, output }) {
544 const env = yield* GcpEnvironment.current;
545 const backupId = yield* toId(id, news.backupId, output?.backupId);
546 const ref = parseInstanceRef(
547 news.instance,
548 env.project,
549 news.location ?? output?.location,
550 env.region,
551 );
552 if (ref.instanceId.length === 0) {
553 return yield* new InstancesBackupInstanceMissing({
554 message:
555 "GCP.Looker.InstancesBackup requires `instance` (instance id or full resource name)",
556 });
557 }
558 const name = resourceName(ref.instanceName, backupId);
559
560 let current = yield* getByName(output?.name ?? name);
561
562 if (current === undefined) {
563 const created = yield* looker
564 .createProjectsLocationsInstancesBackups({
565 parent: ref.instanceName,
566 body: { name },
567 })
568 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
569 if (created !== undefined) {
570 yield* waitForOperation(created);
571 }
572 current = yield* waitUntilExists(output?.name ?? name);
573 }
574
575 if (current === undefined) {
576 return yield* new InstancesBackupNotResolved({ name });
577 }
578
579 const currentName = current.name ?? name;
580 if (!isAvailable(current.state)) {
581 current = yield* waitUntilReady(currentName);
582 }
583
584 return toAttrs(current, env.project, env.region);
585 }),
586
587 delete: Effect.fn(function* ({ output }) {
588 const operation = yield* looker
589 .deleteProjectsLocationsInstancesBackups({ name: output.name })
590 .pipe(
591 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
592 Effect.retry({
593 while: (error) => error._tag === "Conflict",
594 times: 8,
595 schedule: Schedule.spaced("5 seconds"),
596 }),
597 );
598 if (operation !== undefined) {
599 yield* waitForOperation(operation, { notFoundOk: true });
600 }
601 yield* waitUntilGone(output.name);
602 }),
603 });