1import * as netapp from "@distilled.cloud/gcp/netapp_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
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 fieldMask,
20 hasAlchemyLabelMap,
21 listAtLocation,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 replaceOnIdentity,
26 ResourceNotResolved,
27 toPhysicalId,
28 userLabels,
29 waitForDeleteOperation,
30 waitForOperation,
31 waitUntilExists,
32 waitUntilGone,
33 waitUntilReady,
34} from "./internal.ts";
35
36const DEFAULT_DAILY = 2;
37const DEFAULT_WEEKLY = 1;
38const DEFAULT_MONTHLY = 1;
39
40export type BackupPolicyProps = {
41 /**
42 * Backup policy id (the `{backupPolicy}` segment of
43 * `projects/{project}/locations/{location}/backupPolicies/{backupPolicy}`).
44 * If omitted, a unique RFC1035 name is generated from the stack, stage,
45 * and logical id. Immutable — changing it replaces the policy.
46 */
47 backupPolicyId?: string;
48 /**
49 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
50 * replaces the policy. `US-CENTRAL1` is accepted and normalized to
51 * `us-central1`.
52 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
53 */
54 location?: string;
55 /**
56 * Number of daily backups to keep. Minimum is 2.
57 * @default 2
58 */
59 dailyBackupLimit?: number;
60 /**
61 * Number of weekly backups to keep.
62 * @default 1
63 */
64 weeklyBackupLimit?: number;
65 /**
66 * Number of monthly backups to keep.
67 * @default 1
68 */
69 monthlyBackupLimit?: number;
70 /**
71 * Whether scheduled backups are enabled for attached volumes.
72 * @default true
73 */
74 enabled?: boolean;
75 /**
76 * Human-readable description.
77 */
78 description?: string;
79 /**
80 * User labels. Alchemy ownership labels are merged in automatically.
81 */
82 labels?: Record<string, string>;
83};
84
85export type BackupPolicy = Resource<
86 "GCP.NetApp.BackupPolicy",
87 BackupPolicyProps,
88 {
89 /** Full resource name. */
90 name: string;
91 /** Backup policy id (last path segment). */
92 backupPolicyId: string;
93 /** Project id. */
94 project: string;
95 /** Location id. */
96 location: string;
97 /** Daily backup retention count. */
98 dailyBackupLimit: number | undefined;
99 /** Weekly backup retention count. */
100 weeklyBackupLimit: number | undefined;
101 /** Monthly backup retention count. */
102 monthlyBackupLimit: number | undefined;
103 /** Whether scheduled backups are enabled. */
104 enabled: boolean | undefined;
105 /** Number of volumes using this policy. */
106 assignedVolumeCount: number | undefined;
107 /** Human-readable description. */
108 description: string | undefined;
109 /** User labels (Alchemy ownership labels stripped). */
110 labels: Record<string, string>;
111 /** Server-reported state. */
112 state: string | undefined;
113 /** RFC3339 creation timestamp. */
114 createTime: string | undefined;
115 },
116 never,
117 Providers
118>;
119
120/**
121 * A Cloud NetApp Volumes backup policy that schedules daily, weekly, and
122 * monthly volume backups.
123 *
124 * Changing `backupPolicyId` or `location` replaces the policy. Retention
125 * limits, `enabled`, description, and labels update in place.
126 *
127 * ### Creating a Backup Policy
128 * **Example:** Generated name
129 * ```typescript
130 * const policy = yield* GCP.NetApp.BackupPolicy("Nightly", {});
131 * ```
132 *
133 * **Example:** Explicit id and limits
134 * ```typescript
135 * const policy = yield* GCP.NetApp.BackupPolicy("Nightly", {
136 * backupPolicyId: "app-nightly",
137 * dailyBackupLimit: 2,
138 * weeklyBackupLimit: 1,
139 * monthlyBackupLimit: 1,
140 * description: "nightly backups",
141 * labels: { env: "prod" },
142 * });
143 * ```
144 *
145 * ### Updating a Backup Policy
146 * **Example:** Description and labels
147 * ```typescript
148 * const policy = yield* GCP.NetApp.BackupPolicy("Nightly", {
149 * description: "nightly backups v2",
150 * labels: { env: "prod", team: "storage" },
151 * });
152 * ```
153 *
154 * @resource
155 * @category NetApp
156 */
157export const BackupPolicy = Resource<BackupPolicy>("GCP.NetApp.BackupPolicy");
158
159const resourceName = (
160 project: string,
161 location: string,
162 backupPolicyId: string,
163) =>
164 `projects/${project}/locations/${location}/backupPolicies/${backupPolicyId}`;
165
166const toAttrs = (
167 policy: netapp.BackupPolicy,
168 project: string,
169 region: string,
170) => {
171 const name = policy.name ?? "";
172 const parsed = parseName(name, "backupPolicies", region);
173 return {
174 name,
175 backupPolicyId: parsed.id,
176 project: parsed.project || project,
177 location: parsed.location,
178 dailyBackupLimit: policy.dailyBackupLimit,
179 weeklyBackupLimit: policy.weeklyBackupLimit,
180 monthlyBackupLimit: policy.monthlyBackupLimit,
181 enabled: policy.enabled,
182 assignedVolumeCount: policy.assignedVolumeCount,
183 description: policy.description,
184 labels: userLabels(policy.labels),
185 state: policy.state,
186 createTime: policy.createTime,
187 };
188};
189
190const getByName = (name: string) =>
191 netapp
192 .getProjectsLocationsBackupPolicies({ name })
193 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
194
195const listOwned = (project: string) =>
196 listAtLocation(project, (parent) =>
197 netapp.listProjectsLocationsBackupPolicies
198 .pages({ parent, pageSize: 1000 })
199 .pipe(
200 Stream.flatMap((page) =>
201 Stream.fromIterable(page.backupPolicies ?? []),
202 ),
203 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
204 Stream.runCollect,
205 Effect.map((chunk) => Array.from(chunk)),
206 Effect.catchTag("NotFound", () => Effect.succeed([])),
207 ),
208 );
209
210export const BackupPolicyProvider = () =>
211 Provider.succeed(BackupPolicy, {
212 stables: ["name", "backupPolicyId", "project", "location", "createTime"],
213
214 diff: Effect.fn(function* ({ news, olds, output }) {
215 if (!isResolved(news)) return undefined;
216 const env = yield* GcpEnvironment.current;
217 return replaceOnIdentity({
218 previousId: olds?.backupPolicyId ?? output?.backupPolicyId,
219 nextId:
220 news.backupPolicyId ?? olds?.backupPolicyId ?? output?.backupPolicyId,
221 previousLocation: normalizeLocation(
222 olds?.location ?? output?.location,
223 env.region,
224 ),
225 nextLocation: normalizeLocation(
226 news.location ?? olds?.location ?? output?.location,
227 env.region,
228 ),
229 });
230 }),
231
232 read: Effect.fn(function* ({ id, olds, output }) {
233 const env = yield* GcpEnvironment.current;
234 const backupPolicyId = yield* toPhysicalId(
235 id,
236 olds?.backupPolicyId,
237 output?.backupPolicyId,
238 "backuppolicy",
239 );
240 const location = normalizeLocation(
241 olds?.location ?? output?.location,
242 env.region,
243 );
244 const name =
245 output?.name ?? resourceName(env.project, location, backupPolicyId);
246 const existing = yield* getByName(name);
247 if (existing === undefined) return undefined;
248 const attrs = toAttrs(existing, env.project, env.region);
249 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
250 ? attrs
251 : Unowned(attrs);
252 }),
253
254 list: () =>
255 Effect.gen(function* () {
256 const env = yield* GcpEnvironment.current;
257 const items = yield* listOwned(env.project);
258 return items.map((item) => toAttrs(item, env.project, env.region));
259 }),
260
261 reconcile: Effect.fn(function* ({ id, news, output }) {
262 const env = yield* GcpEnvironment.current;
263 const backupPolicyId = yield* toPhysicalId(
264 id,
265 news.backupPolicyId,
266 output?.backupPolicyId,
267 "backuppolicy",
268 );
269 const location = normalizeLocation(
270 news.location ?? output?.location,
271 env.region,
272 );
273 const name = resourceName(env.project, location, backupPolicyId);
274 const dailyBackupLimit = news.dailyBackupLimit ?? DEFAULT_DAILY;
275 const weeklyBackupLimit = news.weeklyBackupLimit ?? DEFAULT_WEEKLY;
276 const monthlyBackupLimit = news.monthlyBackupLimit ?? DEFAULT_MONTHLY;
277 const enabled = news.enabled ?? true;
278 const desiredLabels = {
279 ...toLabels(news.labels),
280 ...(yield* createInternalLabels(id)),
281 };
282
283 let current = yield* getByName(output?.name ?? name);
284
285 if (current === undefined) {
286 const created = yield* netapp
287 .createProjectsLocationsBackupPolicies({
288 parent: parentOf(env.project, location),
289 backupPolicyId,
290 body: {
291 dailyBackupLimit,
292 weeklyBackupLimit,
293 monthlyBackupLimit,
294 enabled,
295 description: news.description,
296 labels: desiredLabels,
297 },
298 })
299 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
300 if (created !== undefined) {
301 yield* waitForOperation(created);
302 }
303 current = yield* waitUntilExists(getByName(name), name);
304 }
305
306 if (current === undefined) {
307 return yield* new ResourceNotResolved({ name });
308 }
309
310 current = yield* waitUntilReady(
311 getByName(current.name ?? name),
312 current.name ?? name,
313 (item) => item.state,
314 );
315
316 const observedLabels = tagRecord(current.labels);
317 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
318 const labelsChanged = upsert.length > 0 || removed.length > 0;
319 const descriptionChanged =
320 (current.description ?? "") !== (news.description ?? "");
321 const dailyChanged =
322 (current.dailyBackupLimit ?? DEFAULT_DAILY) !== dailyBackupLimit;
323 const weeklyChanged =
324 (current.weeklyBackupLimit ?? DEFAULT_WEEKLY) !== weeklyBackupLimit;
325 const monthlyChanged =
326 (current.monthlyBackupLimit ?? DEFAULT_MONTHLY) !== monthlyBackupLimit;
327 const enabledChanged = (current.enabled ?? true) !== enabled;
328 const mask = fieldMask([
329 labelsChanged && "labels",
330 descriptionChanged && "description",
331 dailyChanged && "dailyBackupLimit",
332 weeklyChanged && "weeklyBackupLimit",
333 monthlyChanged && "monthlyBackupLimit",
334 enabledChanged && "enabled",
335 ]);
336
337 if (mask.length > 0) {
338 const operation = yield* netapp.patchProjectsLocationsBackupPolicies({
339 name: current.name ?? name,
340 updateMask: mask,
341 body: {
342 name: current.name ?? name,
343 labels: desiredLabels,
344 description: news.description,
345 dailyBackupLimit,
346 weeklyBackupLimit,
347 monthlyBackupLimit,
348 enabled,
349 },
350 });
351 yield* waitForOperation(operation);
352 current = yield* waitUntilReady(
353 getByName(current.name ?? name),
354 current.name ?? name,
355 (item) => item.state,
356 );
357 }
358
359 return toAttrs(current, env.project, env.region);
360 }),
361
362 delete: Effect.fn(function* ({ output }) {
363 const operation = yield* netapp
364 .deleteProjectsLocationsBackupPolicies({ name: output.name })
365 .pipe(
366 Effect.retry({
367 while: (error) => error._tag === "Conflict",
368 times: 8,
369 schedule: Schedule.spaced("2 seconds"),
370 }),
371 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
372 );
373 if (operation !== undefined) {
374 yield* waitForDeleteOperation(operation);
375 }
376 yield* waitUntilGone(getByName(output.name), output.name);
377 }),
378 });