1import * as file from "@distilled.cloud/gcp/file_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 createInternalLabels,
13 diffLabels,
14 hasAlchemyLabels,
15 toLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import {
19 DEFAULT_SHARE_NAME,
20 DEFAULT_ZONE,
21 expandParent,
22 fieldMask,
23 isDeletingState,
24 lastSegment,
25 listLabeledPages,
26 normalizeLocation,
27 parseName,
28 regionOf,
29 replaceOnIdentity,
30 ResourceNotResolved,
31 toPhysicalId,
32 userLabels,
33 waitForOperation,
34 waitUntilExists,
35 waitUntilGone,
36 waitUntilReady,
37} from "./internal.ts";
38
39export type BackupProps = {
40 /**
41 * Source Filestore instance. Full name
42 * `projects/{project}/locations/{location}/instances/{instance}` or
43 * the instance id (combined with `instanceLocation`). Immutable —
44 * changing it replaces the backup.
45 */
46 sourceInstance: string;
47 /**
48 * File share on the source instance to back up. Immutable — changing
49 * it replaces the backup.
50 * @default "share1"
51 */
52 sourceFileShare?: string;
53 /**
54 * Zone of the source instance when `sourceInstance` is a bare id.
55 * Ignored when `sourceInstance` is a full resource name.
56 * @default "us-central1-a"
57 */
58 instanceLocation?: string;
59 /**
60 * Region that stores the backup (`us-central1`, …). Backups are
61 * regional and may live in a different region than the instance.
62 * Immutable — changing it replaces the backup. `US-CENTRAL1` is
63 * accepted and normalized to `us-central1`.
64 * @default the region of the source instance
65 */
66 location?: string;
67 /**
68 * Backup id (the `{backup}` segment of
69 * `projects/{project}/locations/{location}/backups/{backup}`). If
70 * omitted, a unique RFC1035 name is generated from the stack, stage,
71 * and logical id. Must start with a letter, be 1-63 characters, and
72 * end with a letter or digit. Immutable — changing it replaces the
73 * backup.
74 */
75 backupId?: string;
76 /**
77 * Human-readable description (2048 characters or less).
78 */
79 description?: string;
80 /**
81 * User labels. Alchemy ownership labels are merged in automatically.
82 */
83 labels?: Record<string, string>;
84 /**
85 * Customer-managed KMS key for backup encryption
86 * (`projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`).
87 * Immutable — changing it replaces the backup.
88 */
89 kmsKey?: string;
90 /**
91 * Resource Manager tags (namespaced key to short value). Input-only
92 * and immutable.
93 */
94 tags?: Record<string, string>;
95};
96
97export type Backup = Resource<
98 "GCP.Filestore.Backup",
99 BackupProps,
100 {
101 /** Full resource name `projects/{project}/locations/{location}/backups/{backup}`. */
102 name: string;
103 /** Backup id (last path segment). */
104 backupId: string;
105 /** Project id. */
106 project: string;
107 /** Backup region. */
108 location: string;
109 /** Source instance resource name. */
110 sourceInstance: string | undefined;
111 /** Source file share name. */
112 sourceFileShare: string | undefined;
113 /** Source instance service tier. */
114 sourceInstanceTier: string | undefined;
115 /** File protocol of the source instance. */
116 fileSystemProtocol: string | undefined;
117 /** Source share capacity in GiB when the backup was taken. */
118 capacityGb: string | undefined;
119 /** Bytes occupied by this backup (shared storage; can change). */
120 storageBytes: string | undefined;
121 /** Bytes downloaded when restoring this backup. */
122 downloadBytes: string | undefined;
123 /** Customer-managed KMS key, if any. */
124 kmsKey: string | undefined;
125 /** Human-readable description. */
126 description: string | undefined;
127 /** User labels (Alchemy ownership labels stripped). */
128 labels: Record<string, string>;
129 /** Server-reported state (`READY`, `CREATING`, `FINALIZING`, …). */
130 state: string | undefined;
131 /** RFC3339 creation timestamp. */
132 createTime: string | undefined;
133 },
134 never,
135 Providers
136>;
137
138/**
139 * A Cloud Filestore backup of an instance file share.
140 *
141 * Changing `backupId`, `location`, `sourceInstance`, `sourceFileShare`,
142 * or `kmsKey` replaces the backup. Description and labels update in
143 * place.
144 *
145 * Backups are regional. Creating a backup of a Basic HDD share typically
146 * takes several minutes.
147 *
148 * ### Creating a Backup
149 * **Example:** Backup the default share
150 * ```typescript
151 * const nfs = yield* GCP.Filestore.Instance("Nfs", {});
152 * const backup = yield* GCP.Filestore.Backup("Nightly", {
153 * sourceInstance: nfs.name,
154 * });
155 * ```
156 *
157 * **Example:** Explicit id, share, labels, and description
158 * ```typescript
159 * const backup = yield* GCP.Filestore.Backup("Nightly", {
160 * sourceInstance: nfs.name,
161 * sourceFileShare: "share1",
162 * backupId: "app-nfs-nightly",
163 * location: "us-central1",
164 * description: "nightly backup",
165 * labels: { env: "prod" },
166 * });
167 * ```
168 *
169 * ### Updating a Backup
170 * Re-declare the same logical id with changed props; the engine keeps the
171 * physical resource and updates it in place.
172 *
173 * **Example:** Description and labels
174 * ```typescript
175 * const backup = yield* GCP.Filestore.Backup("Nightly", {
176 * sourceInstance: nfs.name,
177 * description: "nightly backup v2",
178 * labels: { env: "prod", team: "storage" },
179 * });
180 * ```
181 *
182 * @resource
183 * @category Filestore
184 */
185export const Backup = Resource<Backup>("GCP.Filestore.Backup");
186
187export class BackupSourceMissing extends Data.TaggedError(
188 "GCP.Filestore.BackupSourceMissing",
189)<{
190 message: string;
191}> {}
192
193const resourceName = (project: string, location: string, backupId: string) =>
194 `projects/${project}/locations/${location}/backups/${backupId}`;
195
196const parseInstanceRef = (
197 value: string,
198 fallbackProject: string,
199 fallbackLocation: string,
200) => {
201 const trimmed = value.trim();
202 if (trimmed.includes("/")) {
203 const parsed = parseName(trimmed, "instances", DEFAULT_ZONE);
204 const instanceLocation = normalizeLocation(parsed.location, DEFAULT_ZONE);
205 return {
206 project: parsed.project || fallbackProject,
207 location: instanceLocation,
208 instanceId: parsed.id,
209 instanceName: expandParent(
210 parsed.id,
211 parsed.project || fallbackProject,
212 instanceLocation,
213 "instances",
214 ),
215 };
216 }
217 const location = normalizeLocation(fallbackLocation, DEFAULT_ZONE);
218 const instanceId = lastSegment(trimmed);
219 return {
220 project: fallbackProject,
221 location,
222 instanceId,
223 instanceName: expandParent(
224 instanceId,
225 fallbackProject,
226 location,
227 "instances",
228 ),
229 };
230};
231
232const backupLocationOf = (
233 newsLocation: string | undefined,
234 outputLocation: string | undefined,
235 instanceLocation: string,
236) =>
237 normalizeLocation(newsLocation ?? outputLocation, regionOf(instanceLocation));
238
239const toAttrs = (backup: file.Backup, project: string, region: string) => {
240 const name = backup.name ?? "";
241 const parsed = parseName(name, "backups", region);
242 return {
243 name,
244 backupId: parsed.id,
245 project: parsed.project || project,
246 location: parsed.location,
247 sourceInstance: backup.sourceInstance,
248 sourceFileShare: backup.sourceFileShare,
249 sourceInstanceTier: backup.sourceInstanceTier,
250 fileSystemProtocol: backup.fileSystemProtocol,
251 capacityGb: backup.capacityGb,
252 storageBytes: backup.storageBytes,
253 downloadBytes: backup.downloadBytes,
254 kmsKey: backup.kmsKey,
255 description: backup.description,
256 labels: userLabels(backup.labels),
257 state: backup.state,
258 createTime: backup.createTime,
259 };
260};
261
262const isPlaceholder = (backup: file.Backup) => {
263 const name = backup.name ?? "";
264 return (
265 name.length === 0 ||
266 name.endsWith("/backups/-") ||
267 name.endsWith("/backups/")
268 );
269};
270
271const getByName = (name: string) =>
272 Effect.gen(function* () {
273 if (name.length === 0 || name.includes("//")) return undefined;
274 return yield* file
275 .getProjectsLocationsBackups({ name })
276 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
277 });
278
279export const BackupProvider = () =>
280 Provider.succeed(Backup, {
281 stables: [
282 "name",
283 "backupId",
284 "project",
285 "location",
286 "sourceInstance",
287 "sourceFileShare",
288 "kmsKey",
289 "createTime",
290 ],
291
292 diff: Effect.fn(function* ({ news, olds, output }) {
293 if (!isResolved(news)) return undefined;
294 const env = yield* GcpEnvironment.current;
295 const previousInstance = parseInstanceRef(
296 olds?.sourceInstance ?? output?.sourceInstance ?? "",
297 env.project,
298 olds?.instanceLocation ?? output?.location ?? DEFAULT_ZONE,
299 );
300 const nextInstance = parseInstanceRef(
301 news.sourceInstance,
302 env.project,
303 news.instanceLocation ??
304 olds?.instanceLocation ??
305 previousInstance.location,
306 );
307 const previousLocation = normalizeLocation(
308 olds?.location ?? output?.location,
309 regionOf(previousInstance.location),
310 );
311 const nextLocation = backupLocationOf(
312 news.location,
313 olds?.location ?? output?.location,
314 nextInstance.location,
315 );
316 const previousShare =
317 olds?.sourceFileShare ?? output?.sourceFileShare ?? DEFAULT_SHARE_NAME;
318 const nextShare = news.sourceFileShare ?? previousShare;
319 const previousKey = olds?.kmsKey ?? output?.kmsKey ?? "";
320 const nextKey = news.kmsKey ?? previousKey;
321 return replaceOnIdentity({
322 previousId: olds?.backupId ?? output?.backupId,
323 nextId: news.backupId ?? olds?.backupId ?? output?.backupId,
324 previousLocation,
325 nextLocation,
326 extra:
327 previousInstance.instanceName !== nextInstance.instanceName ||
328 previousShare !== nextShare ||
329 previousKey !== nextKey,
330 });
331 }),
332
333 read: Effect.fn(function* ({ id, olds, output }) {
334 const env = yield* GcpEnvironment.current;
335 const instance = parseInstanceRef(
336 olds?.sourceInstance ?? output?.sourceInstance ?? "",
337 env.project,
338 olds?.instanceLocation ?? output?.location ?? DEFAULT_ZONE,
339 );
340 const location = backupLocationOf(
341 olds?.location,
342 output?.location,
343 instance.location,
344 );
345 const backupId = yield* toPhysicalId(
346 id,
347 olds?.backupId,
348 output?.backupId,
349 "backup",
350 );
351 const name =
352 output?.name ?? resourceName(env.project, location, backupId);
353 const existing = yield* getByName(name);
354 if (existing === undefined || isPlaceholder(existing)) return undefined;
355 const attrs = toAttrs(existing, env.project, env.region);
356 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
357 ? attrs
358 : Unowned(attrs);
359 }),
360
361 list: () =>
362 Effect.gen(function* () {
363 const env = yield* GcpEnvironment.current;
364 const items = yield* listLabeledPages(
365 file.listProjectsLocationsBackups.pages({
366 parent: `projects/${env.project}/locations/-`,
367 pageSize: 1000,
368 }),
369 (page) => page.backups,
370 (item) => item.labels,
371 );
372 return items
373 .filter((item) => !isPlaceholder(item))
374 .map((item) => toAttrs(item, env.project, env.region));
375 }),
376
377 reconcile: Effect.fn(function* ({ id, news, output }) {
378 const env = yield* GcpEnvironment.current;
379 if (news.sourceInstance.trim().length === 0) {
380 return yield* new BackupSourceMissing({
381 message: "sourceInstance is required",
382 });
383 }
384 const instance = parseInstanceRef(
385 news.sourceInstance,
386 env.project,
387 news.instanceLocation ?? DEFAULT_ZONE,
388 );
389 const location = backupLocationOf(
390 news.location,
391 output?.location,
392 instance.location,
393 );
394 const backupId = yield* toPhysicalId(
395 id,
396 news.backupId,
397 output?.backupId,
398 "backup",
399 );
400 const name = resourceName(env.project, location, backupId);
401 const sourceFileShare = news.sourceFileShare ?? DEFAULT_SHARE_NAME;
402 const desiredLabels = {
403 ...toLabels(news.labels),
404 ...(yield* createInternalLabels(id)),
405 };
406
407 let current = yield* getByName(output?.name ?? name);
408
409 if (current !== undefined && isDeletingState(current.state)) {
410 yield* waitUntilGone(
411 getByName(current.name ?? name),
412 current.name ?? name,
413 );
414 current = undefined;
415 }
416
417 if (current === undefined) {
418 const created = yield* file
419 .createProjectsLocationsBackups({
420 parent: `projects/${env.project}/locations/${location}`,
421 backupId,
422 body: {
423 sourceInstance: instance.instanceName,
424 sourceFileShare,
425 description: news.description,
426 labels: desiredLabels,
427 kmsKey: news.kmsKey,
428 tags: news.tags,
429 },
430 })
431 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
432 if (created !== undefined) {
433 yield* waitForOperation(created);
434 }
435 current = yield* waitUntilExists(getByName(name), name);
436 }
437
438 if (current === undefined) {
439 return yield* new ResourceNotResolved({ name });
440 }
441
442 current = yield* waitUntilReady(
443 getByName(current.name ?? name),
444 current.name ?? name,
445 (item) => item.state,
446 );
447 if (current === undefined) {
448 return yield* new ResourceNotResolved({ name });
449 }
450
451 const observedLabels = tagRecord(current.labels);
452 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
453 const mask = fieldMask([
454 (upsert.length > 0 || removed.length > 0) && "labels",
455 (current.description ?? "") !== (news.description ?? "") &&
456 "description",
457 ]);
458
459 if (mask.length > 0) {
460 const operation = yield* file.patchProjectsLocationsBackups({
461 name: current.name ?? name,
462 updateMask: mask,
463 body: {
464 name: current.name ?? name,
465 labels: desiredLabels,
466 description: news.description,
467 },
468 });
469 yield* waitForOperation(operation);
470 current = yield* waitUntilReady(
471 getByName(current.name ?? name),
472 current.name ?? name,
473 (item) => item.state,
474 );
475 if (current === undefined) {
476 return yield* new ResourceNotResolved({ name });
477 }
478 }
479
480 return toAttrs(current, env.project, env.region);
481 }),
482
483 delete: Effect.fn(function* ({ output }) {
484 if (!output.name || output.name.includes("//")) return;
485 const operation = yield* file
486 .deleteProjectsLocationsBackups({ name: output.name })
487 .pipe(
488 Effect.retry({
489 while: (error) => error._tag === "Conflict",
490 times: 8,
491 schedule: Schedule.spaced("5 seconds"),
492 }),
493 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
494 );
495 if (operation !== undefined) {
496 yield* waitForOperation(operation, { notFoundOk: true });
497 }
498 yield* waitUntilGone(getByName(output.name), output.name);
499 }),
500 });