1import * as backupdr from "@distilled.cloud/gcp/backupdr_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 DEFAULT_RETENTION,
19 fieldMask,
20 listAtLocation,
21 listLabeledPages,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 replaceOnIdentity,
26 ResourceNotResolved,
27 sameText,
28 toPhysicalId,
29 userLabels,
30 waitForOperation,
31 waitUntilExists,
32 waitUntilGone,
33 waitUntilReady,
34} from "./internal.ts";
35
36export type EncryptionConfig = {
37 /**
38 * Cloud KMS key used to encrypt backups in this vault. Must be in the
39 * same region. Format:
40 * `projects/{project}/locations/{location}/keyRings/{ring}/cryptoKeys/{key}`.
41 * Immutable after create.
42 */
43 kmsKeyName?: string;
44};
45
46export type BackupVaultProps = {
47 /**
48 * Backup vault id (the `{backupvault}` segment of
49 * `projects/{project}/locations/{location}/backupVaults/{backupvault}`).
50 * If omitted, a unique RFC1035 name is generated from the stack, stage,
51 * and logical id. 3-63 characters. Immutable — changing it replaces the
52 * vault.
53 */
54 backupVaultId?: string;
55 /**
56 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
57 * replaces the vault. `US-CENTRAL1` is accepted and normalized to
58 * `us-central1`.
59 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
60 */
61 location?: string;
62 /**
63 * Default and minimum enforced retention for each backup. Duration
64 * string (`86400s`, `259200s`, …). Longer values raise storage cost.
65 * @default "86400s"
66 */
67 backupMinimumEnforcedRetentionDuration?: string;
68 /**
69 * How a backup's enforced retention end time is inherited.
70 */
71 backupRetentionInheritance?:
72 | backupdr.BackupVaultBackupRetentionInheritanceEnum
73 | (string & {});
74 /**
75 * Access restriction for restores. Default is `WITHIN_ORGANIZATION`
76 * when omitted at create.
77 */
78 accessRestriction?: backupdr.BackupVaultAccessRestrictionEnum | (string & {});
79 /**
80 * Customer-managed encryption. Immutable after create.
81 */
82 encryptionConfig?: EncryptionConfig;
83 /**
84 * Time after which the vault is locked (RFC3339).
85 */
86 effectiveTime?: string;
87 /**
88 * Human-readable description (2048 characters or less).
89 */
90 description?: string;
91 /**
92 * User labels. Alchemy ownership labels are merged in automatically.
93 */
94 labels?: Record<string, string>;
95 /**
96 * User annotations (AIP-128).
97 */
98 annotations?: Record<string, string>;
99};
100
101export type BackupVault = Resource<
102 "GCP.BackupDR.BackupVault",
103 BackupVaultProps,
104 {
105 /** Full resource name. */
106 name: string;
107 /** Backup vault id (last path segment). */
108 backupVaultId: string;
109 /** Project id. */
110 project: string;
111 /** Location id. */
112 location: string;
113 /** Minimum enforced retention duration. */
114 backupMinimumEnforcedRetentionDuration: string | undefined;
115 /** Retention inheritance setting. */
116 backupRetentionInheritance: string | undefined;
117 /** Access restriction. */
118 accessRestriction: string | undefined;
119 /** Encryption config. */
120 encryptionConfig: EncryptionConfig | undefined;
121 /** Lock time. */
122 effectiveTime: string | undefined;
123 /** Human-readable description. */
124 description: string | undefined;
125 /** User labels (Alchemy ownership labels stripped). */
126 labels: Record<string, string>;
127 /** User annotations. */
128 annotations: Record<string, string>;
129 /** Service account used by the vault. */
130 serviceAccount: string | undefined;
131 /** Number of backups in the vault. */
132 backupCount: string | undefined;
133 /** Total stored bytes. */
134 totalStoredBytes: string | undefined;
135 /** Whether the vault has no nested backups. */
136 deletable: boolean | undefined;
137 /** Server-reported state. */
138 state: string | undefined;
139 /** Server-generated resource uid. */
140 uid: string | undefined;
141 /** RFC3339 creation timestamp. */
142 createTime: string | undefined;
143 /** RFC3339 last-update timestamp. */
144 updateTime: string | undefined;
145 },
146 never,
147 Providers
148>;
149
150/**
151 * A Backup and DR backup vault — the storage location for backups created
152 * by Backup Plans.
153 *
154 * Changing `backupVaultId`, `location`, or `encryptionConfig` replaces
155 * the vault. Description, labels, annotations, retention, access
156 * restriction, and inheritance update in place.
157 *
158 * ### Creating a Backup Vault
159 * **Example:** Generated name
160 * ```typescript
161 * const vault = yield* GCP.BackupDR.BackupVault("Vault", {});
162 * ```
163 *
164 * **Example:** Explicit id, retention, and labels
165 * ```typescript
166 * const vault = yield* GCP.BackupDR.BackupVault("Vault", {
167 * backupVaultId: "app-backups",
168 * backupMinimumEnforcedRetentionDuration: "86400s",
169 * description: "nightly backups",
170 * labels: { env: "prod" },
171 * });
172 * ```
173 *
174 * ### Updating a Backup Vault
175 * **Example:** Description and labels
176 * ```typescript
177 * // Same logical id, changed props: the engine updates it in place.
178 * const vault = yield* GCP.BackupDR.BackupVault("Vault", {
179 * description: "nightly backups v2",
180 * labels: { env: "prod", team: "platform" },
181 * });
182 * ```
183 *
184 * @resource
185 * @category BackupDR
186 */
187export const BackupVault = Resource<BackupVault>("GCP.BackupDR.BackupVault");
188
189const resourceName = (
190 project: string,
191 location: string,
192 backupVaultId: string,
193) => `projects/${project}/locations/${location}/backupVaults/${backupVaultId}`;
194
195const toEncryption = (
196 config: backupdr.EncryptionConfig | EncryptionConfig | undefined,
197): EncryptionConfig | undefined =>
198 config === undefined ? undefined : { kmsKeyName: config.kmsKeyName };
199
200const toAttrs = (
201 item: backupdr.BackupVault,
202 project: string,
203 region: string,
204) => {
205 const name = item.name ?? "";
206 const parsed = parseName(name, "backupVaults", region);
207 return {
208 name,
209 backupVaultId: parsed.id,
210 project: parsed.project || project,
211 location: parsed.location,
212 backupMinimumEnforcedRetentionDuration:
213 item.backupMinimumEnforcedRetentionDuration,
214 backupRetentionInheritance: item.backupRetentionInheritance,
215 accessRestriction: item.accessRestriction,
216 encryptionConfig: toEncryption(item.encryptionConfig),
217 effectiveTime: item.effectiveTime,
218 description: item.description,
219 labels: userLabels(item.labels),
220 annotations: tagRecord(item.annotations),
221 serviceAccount: item.serviceAccount,
222 backupCount: item.backupCount,
223 totalStoredBytes: item.totalStoredBytes,
224 deletable: item.deletable,
225 state: item.state,
226 uid: item.uid,
227 createTime: item.createTime,
228 updateTime: item.updateTime,
229 };
230};
231
232const getByName = (name: string) =>
233 backupdr
234 .getProjectsLocationsBackupVaults({ name })
235 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
236
237const listOwned = (project: string, region: string) =>
238 listAtLocation(project, region, (parent) =>
239 listLabeledPages(
240 backupdr.listProjectsLocationsBackupVaults.pages({
241 parent,
242 pageSize: 1000,
243 }),
244 (page) => page.backupVaults,
245 (item) => item.labels,
246 ),
247 );
248
249export const BackupVaultProvider = () =>
250 Provider.succeed(BackupVault, {
251 stables: [
252 "name",
253 "backupVaultId",
254 "project",
255 "location",
256 "uid",
257 "createTime",
258 ],
259
260 diff: Effect.fn(function* ({ news, olds, output }) {
261 if (!isResolved(news)) return undefined;
262 const env = yield* GcpEnvironment.current;
263 const previousKey =
264 olds?.encryptionConfig?.kmsKeyName ??
265 output?.encryptionConfig?.kmsKeyName;
266 const nextKey = news.encryptionConfig?.kmsKeyName;
267 return replaceOnIdentity({
268 previousId: olds?.backupVaultId ?? output?.backupVaultId,
269 nextId:
270 news.backupVaultId ?? olds?.backupVaultId ?? output?.backupVaultId,
271 previousLocation: normalizeLocation(
272 olds?.location ?? output?.location,
273 env.region,
274 ),
275 nextLocation: normalizeLocation(
276 news.location ?? olds?.location ?? output?.location,
277 env.region,
278 ),
279 extra:
280 previousKey !== undefined &&
281 nextKey !== undefined &&
282 previousKey !== nextKey,
283 });
284 }),
285
286 read: Effect.fn(function* ({ id, olds, output }) {
287 const env = yield* GcpEnvironment.current;
288 const backupVaultId = yield* toPhysicalId(
289 id,
290 olds?.backupVaultId,
291 output?.backupVaultId,
292 "backupvault",
293 );
294 const location = normalizeLocation(
295 olds?.location ?? output?.location,
296 env.region,
297 );
298 const name =
299 output?.name ?? resourceName(env.project, location, backupVaultId);
300 const existing = yield* getByName(name);
301 if (existing === undefined) return undefined;
302 const attrs = toAttrs(existing, env.project, env.region);
303 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
304 ? attrs
305 : Unowned(attrs);
306 }),
307
308 list: () =>
309 Effect.gen(function* () {
310 const env = yield* GcpEnvironment.current;
311 const items = yield* listOwned(env.project, env.region);
312 return items.map((item) => toAttrs(item, env.project, env.region));
313 }),
314
315 reconcile: Effect.fn(function* ({ id, news, output }) {
316 const env = yield* GcpEnvironment.current;
317 const backupVaultId = yield* toPhysicalId(
318 id,
319 news.backupVaultId,
320 output?.backupVaultId,
321 "backupvault",
322 );
323 const location = normalizeLocation(
324 news.location ?? output?.location,
325 env.region,
326 );
327 const name = resourceName(env.project, location, backupVaultId);
328 const desiredLabels = {
329 ...toLabels(news.labels),
330 ...(yield* createInternalLabels(id)),
331 };
332 const retention =
333 news.backupMinimumEnforcedRetentionDuration ?? DEFAULT_RETENTION;
334 const annotations = news.annotations;
335
336 let current = yield* getByName(output?.name ?? name);
337
338 if (current === undefined) {
339 const created = yield* backupdr
340 .createProjectsLocationsBackupVaults({
341 parent: parentOf(env.project, location),
342 backupVaultId,
343 body: {
344 backupMinimumEnforcedRetentionDuration: retention,
345 backupRetentionInheritance: news.backupRetentionInheritance,
346 accessRestriction: news.accessRestriction,
347 encryptionConfig: news.encryptionConfig,
348 effectiveTime: news.effectiveTime,
349 description: news.description,
350 labels: desiredLabels,
351 annotations,
352 },
353 })
354 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
355 if (created !== undefined) {
356 // ALREADY_EXISTS (6): a concurrent create won the race.
357 yield* waitForOperation(created).pipe(
358 Effect.catchTag("GCP.OperationFailed", (error) =>
359 error.code === 6 ? Effect.void : Effect.fail(error),
360 ),
361 );
362 }
363 current = yield* waitUntilExists(getByName(name), name);
364 }
365
366 if (current === undefined) {
367 return yield* new ResourceNotResolved({ name });
368 }
369
370 current = yield* waitUntilReady(
371 getByName(current.name ?? name),
372 current.name ?? name,
373 (item) => item.state,
374 );
375
376 const observedLabels = tagRecord(current.labels);
377 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
378 const observedAnnotations = tagRecord(current.annotations);
379 const desiredAnnotations = annotations ?? {};
380 const annotationDiff = diffLabels(
381 observedAnnotations,
382 desiredAnnotations,
383 );
384 const mask = fieldMask([
385 (upsert.length > 0 || removed.length > 0) && "labels",
386 (annotationDiff.upsert.length > 0 ||
387 annotationDiff.removed.length > 0) &&
388 news.annotations !== undefined &&
389 "annotations",
390 !sameText(current.description, news.description) && "description",
391 !sameText(current.backupMinimumEnforcedRetentionDuration, retention) &&
392 "backupMinimumEnforcedRetentionDuration",
393 news.backupRetentionInheritance !== undefined &&
394 current.backupRetentionInheritance !==
395 news.backupRetentionInheritance &&
396 "backupRetentionInheritance",
397 news.accessRestriction !== undefined &&
398 current.accessRestriction !== news.accessRestriction &&
399 "accessRestriction",
400 news.effectiveTime !== undefined &&
401 !sameText(current.effectiveTime, news.effectiveTime) &&
402 "effectiveTime",
403 ]);
404
405 if (mask.length > 0) {
406 const operation = yield* backupdr.patchProjectsLocationsBackupVaults({
407 name: current.name ?? name,
408 updateMask: mask,
409 force: true,
410 body: {
411 name: current.name ?? name,
412 etag: current.etag,
413 labels: desiredLabels,
414 annotations: annotations ?? current.annotations,
415 description: news.description,
416 backupMinimumEnforcedRetentionDuration: retention,
417 backupRetentionInheritance: news.backupRetentionInheritance,
418 accessRestriction: news.accessRestriction,
419 effectiveTime: news.effectiveTime,
420 },
421 });
422 yield* waitForOperation(operation);
423 current = yield* waitUntilReady(
424 getByName(current.name ?? name),
425 current.name ?? name,
426 (item) => item.state,
427 );
428 }
429
430 return toAttrs(current, env.project, env.region);
431 }),
432
433 delete: Effect.fn(function* ({ output }) {
434 const operation = yield* backupdr
435 .deleteProjectsLocationsBackupVaults({
436 name: output.name,
437 force: true,
438 ignoreBackupPlanReferences: true,
439 })
440 .pipe(
441 Effect.retry({
442 while: (error) => error._tag === "Conflict",
443 times: 8,
444 schedule: Schedule.spaced("2 seconds"),
445 }),
446 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
447 );
448 if (operation !== undefined) {
449 // NOT_FOUND (5): already gone.
450 yield* waitForOperation(operation).pipe(
451 Effect.catchTag("GCP.OperationFailed", (error) =>
452 error.code === 5 ? Effect.void : Effect.fail(error),
453 ),
454 );
455 }
456 yield* waitUntilGone(getByName(output.name), output.name);
457 }),
458 });