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 { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 backupPlanOf,
12 collectPages,
13 hasAlchemyLabelMap,
14 listAtLocation,
15 normalizeLocation,
16 parentOf,
17 parseName,
18 replaceOnIdentity,
19 ResourceNotResolved,
20 sameText,
21 toPhysicalId,
22 waitForOperation,
23 waitUntilExists,
24 waitUntilGone,
25 waitUntilReady,
26} from "./internal.ts";
27
28export type RuleConfigInfo = {
29 /** Backup rule id from the plan. */
30 ruleId?: string;
31 /** Last backup state for the rule. */
32 lastBackupState?: string;
33 /** Last backup error message. */
34 lastBackupError?: string;
35 /** Last successful backup consistency time. */
36 lastSuccessfulBackupConsistencyTime?: string;
37};
38
39export type BackupPlanAssociationProps = {
40 /**
41 * Association id (the `{backupPlanAssociation}` segment of
42 * `projects/{project}/locations/{location}/backupPlanAssociations/{backupPlanAssociation}`).
43 * If omitted, a unique RFC1035 name is generated from the stack, stage,
44 * and logical id. Immutable — changing it replaces the association.
45 */
46 backupPlanAssociationId?: string;
47 /**
48 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
49 * replaces the association. `US-CENTRAL1` is accepted and normalized to
50 * `us-central1`.
51 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
52 */
53 location?: string;
54 /**
55 * Workload resource the plan is applied to. Resource name
56 * (`projects/{project}/zones/{zone}/instances/{instance}`) or full URI.
57 * Immutable — changing it replaces the association.
58 */
59 resource: string;
60 /**
61 * Workload resource type, e.g. `compute.googleapis.com/Instance`.
62 * Immutable — changing it replaces the association.
63 */
64 resourceType: string;
65 /**
66 * Backup plan to apply. Full name
67 * `projects/{project}/locations/{location}/backupPlans/{backupPlan}`
68 * or a plan id combined with `location`.
69 */
70 backupPlan: string;
71};
72
73export type BackupPlanAssociation = Resource<
74 "GCP.BackupDR.BackupPlanAssociation",
75 BackupPlanAssociationProps,
76 {
77 /** Full resource name. */
78 name: string;
79 /** Association id (last path segment). */
80 backupPlanAssociationId: string;
81 /** Project id. */
82 project: string;
83 /** Location id. */
84 location: string;
85 /** Workload resource name. */
86 resource: string | undefined;
87 /** Workload resource type. */
88 resourceType: string | undefined;
89 /** Backup plan name. */
90 backupPlan: string | undefined;
91 /** Data source used as the backup storage location. */
92 dataSource: string | undefined;
93 /** Backup plan revision name. */
94 backupPlanRevisionName: string | undefined;
95 /** User-friendly revision id (`v0`, `v1`, …). */
96 backupPlanRevisionId: string | undefined;
97 /** Per-rule backup status. */
98 rulesConfigInfo: RuleConfigInfo[];
99 /** Cloud SQL instance create time, if applicable. */
100 cloudSqlInstanceCreateTime: string | undefined;
101 /** Filestore instance create time, if applicable. */
102 filestoreInstanceCreateTime: string | undefined;
103 /** AlloyDB cluster uid, if applicable. */
104 alloydbClusterUid: string | undefined;
105 /** Server-reported state. */
106 state: string | undefined;
107 /** RFC3339 creation timestamp. */
108 createTime: string | undefined;
109 /** RFC3339 last-update timestamp. */
110 updateTime: string | undefined;
111 },
112 never,
113 Providers
114>;
115
116/**
117 * A Backup and DR backup plan association — applies a Backup Plan to a
118 * workload (Compute instance, Cloud SQL, AlloyDB, Filestore, or disk).
119 *
120 * Associations have no labels field. Nuke discovers them by following the
121 * referenced Backup Plan's Alchemy ownership labels.
122 *
123 * Changing `backupPlanAssociationId`, `location`, `resource`, or
124 * `resourceType` replaces the association. `backupPlan` updates in place.
125 *
126 * ### Creating an Association
127 * **Example:** Protect a Compute instance
128 * ```typescript
129 * const association = yield* GCP.BackupDR.BackupPlanAssociation("VmPlan", {
130 * resource: instance.selfLink,
131 * resourceType: "compute.googleapis.com/Instance",
132 * backupPlan: plan.name,
133 * });
134 * ```
135 *
136 * ### Updating an Association
137 * **Example:** Point at a new plan
138 * ```typescript
139 * // Same logical id, changed props: the engine updates it in place.
140 * const association = yield* GCP.BackupDR.BackupPlanAssociation("VmPlan", {
141 * resource: instance.selfLink,
142 * resourceType: "compute.googleapis.com/Instance",
143 * backupPlan: otherPlan.name,
144 * });
145 * ```
146 *
147 * @resource
148 * @category BackupDR
149 */
150export const BackupPlanAssociation = Resource<BackupPlanAssociation>(
151 "GCP.BackupDR.BackupPlanAssociation",
152);
153
154const resourceName = (
155 project: string,
156 location: string,
157 backupPlanAssociationId: string,
158) =>
159 `projects/${project}/locations/${location}/backupPlanAssociations/${backupPlanAssociationId}`;
160
161const toRules = (
162 rules: readonly backupdr.RuleConfigInfo[] | undefined,
163): RuleConfigInfo[] =>
164 (rules ?? []).map((rule) => ({
165 ruleId: rule.ruleId,
166 lastBackupState: rule.lastBackupState,
167 lastBackupError: rule.lastBackupError?.message,
168 lastSuccessfulBackupConsistencyTime:
169 rule.lastSuccessfulBackupConsistencyTime,
170 }));
171
172const toAttrs = (
173 item: backupdr.BackupPlanAssociation,
174 project: string,
175 region: string,
176) => {
177 const name = item.name ?? "";
178 const parsed = parseName(name, "backupPlanAssociations", region);
179 return {
180 name,
181 backupPlanAssociationId: parsed.id,
182 project: parsed.project || project,
183 location: parsed.location,
184 resource: item.resource,
185 resourceType: item.resourceType,
186 backupPlan: item.backupPlan,
187 dataSource: item.dataSource,
188 backupPlanRevisionName: item.backupPlanRevisionName,
189 backupPlanRevisionId: item.backupPlanRevisionId,
190 rulesConfigInfo: toRules(item.rulesConfigInfo),
191 cloudSqlInstanceCreateTime:
192 item.cloudSqlInstanceBackupPlanAssociationProperties?.instanceCreateTime,
193 filestoreInstanceCreateTime:
194 item.filestoreInstanceBackupPlanAssociationProperties?.instanceCreateTime,
195 alloydbClusterUid:
196 item.alloydbClusterBackupPlanAssociationProperties?.clusterUid,
197 state: item.state,
198 createTime: item.createTime,
199 updateTime: item.updateTime,
200 };
201};
202
203const getByName = (name: string) =>
204 backupdr
205 .getProjectsLocationsBackupPlanAssociations({ name })
206 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
207
208const getPlan = (name: string | undefined) =>
209 name === undefined || name.length === 0
210 ? Effect.succeed(undefined)
211 : backupdr
212 .getProjectsLocationsBackupPlans({ name })
213 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
214
215const listOwned = (project: string, region: string) =>
216 Effect.gen(function* () {
217 const items = yield* listAtLocation(project, region, (parent) =>
218 collectPages(
219 backupdr.listProjectsLocationsBackupPlanAssociations.pages({
220 parent,
221 pageSize: 1000,
222 }),
223 (page) => page.backupPlanAssociations,
224 ),
225 );
226 const planNames = [
227 ...new Set(
228 items
229 .map((item) => item.backupPlan)
230 .filter(
231 (name): name is string => name !== undefined && name.length > 0,
232 ),
233 ),
234 ];
235 const ownedPlans = yield* Effect.forEach(
236 planNames,
237 (name) =>
238 getPlan(name).pipe(
239 Effect.map((plan) =>
240 plan !== undefined && hasAlchemyLabelMap(plan.labels)
241 ? name
242 : undefined,
243 ),
244 ),
245 { concurrency: 8 },
246 );
247 const owned = new Set(
248 ownedPlans.filter((name): name is string => name !== undefined),
249 );
250 return items.filter(
251 (item) => item.backupPlan !== undefined && owned.has(item.backupPlan),
252 );
253 });
254
255export const BackupPlanAssociationProvider = () =>
256 Provider.succeed(BackupPlanAssociation, {
257 stables: [
258 "name",
259 "backupPlanAssociationId",
260 "project",
261 "location",
262 "createTime",
263 ],
264
265 diff: Effect.fn(function* ({ news, olds, output }) {
266 if (!isResolved(news)) return undefined;
267 const env = yield* GcpEnvironment.current;
268 const previousResource = olds?.resource ?? output?.resource;
269 const previousType = olds?.resourceType ?? output?.resourceType;
270 return replaceOnIdentity({
271 previousId:
272 olds?.backupPlanAssociationId ?? output?.backupPlanAssociationId,
273 nextId:
274 news.backupPlanAssociationId ??
275 olds?.backupPlanAssociationId ??
276 output?.backupPlanAssociationId,
277 previousLocation: normalizeLocation(
278 olds?.location ?? output?.location,
279 env.region,
280 ),
281 nextLocation: normalizeLocation(
282 news.location ?? olds?.location ?? output?.location,
283 env.region,
284 ),
285 extra:
286 (previousType !== undefined &&
287 news.resourceType !== undefined &&
288 previousType !== news.resourceType) ||
289 (previousResource !== undefined &&
290 news.resource !== undefined &&
291 previousResource !== news.resource &&
292 !previousResource.endsWith(`/${news.resource}`)),
293 });
294 }),
295
296 read: Effect.fn(function* ({ id, olds, output }) {
297 const env = yield* GcpEnvironment.current;
298 const backupPlanAssociationId = yield* toPhysicalId(
299 id,
300 olds?.backupPlanAssociationId,
301 output?.backupPlanAssociationId,
302 "backupplanassoc",
303 );
304 const location = normalizeLocation(
305 olds?.location ?? output?.location,
306 env.region,
307 );
308 const name =
309 output?.name ??
310 resourceName(env.project, location, backupPlanAssociationId);
311 const existing = yield* getByName(name);
312 if (existing === undefined) return undefined;
313 const attrs = toAttrs(existing, env.project, env.region);
314 if (output !== undefined) return attrs;
315 const plan = yield* getPlan(existing.backupPlan);
316 return plan !== undefined && hasAlchemyLabelMap(plan.labels)
317 ? attrs
318 : Unowned(attrs);
319 }),
320
321 list: () =>
322 Effect.gen(function* () {
323 const env = yield* GcpEnvironment.current;
324 const items = yield* listOwned(env.project, env.region);
325 return items.map((item) => toAttrs(item, env.project, env.region));
326 }),
327
328 reconcile: Effect.fn(function* ({ id, news, output }) {
329 const env = yield* GcpEnvironment.current;
330 const backupPlanAssociationId = yield* toPhysicalId(
331 id,
332 news.backupPlanAssociationId,
333 output?.backupPlanAssociationId,
334 "backupplanassoc",
335 );
336 const location = normalizeLocation(
337 news.location ?? output?.location,
338 env.region,
339 );
340 const name = resourceName(env.project, location, backupPlanAssociationId);
341 const backupPlan = backupPlanOf(news.backupPlan, env.project, location);
342
343 let current = yield* getByName(output?.name ?? name);
344
345 if (current === undefined) {
346 const created = yield* backupdr
347 .createProjectsLocationsBackupPlanAssociations({
348 parent: parentOf(env.project, location),
349 backupPlanAssociationId,
350 body: {
351 resource: news.resource,
352 resourceType: news.resourceType,
353 backupPlan,
354 },
355 })
356 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
357 if (created !== undefined) {
358 // ALREADY_EXISTS (6): a concurrent create won the race.
359 yield* waitForOperation(created).pipe(
360 Effect.catchTag("GCP.OperationFailed", (error) =>
361 error.code === 6 ? Effect.void : Effect.fail(error),
362 ),
363 );
364 }
365 current = yield* waitUntilExists(getByName(name), name);
366 }
367
368 if (current === undefined) {
369 return yield* new ResourceNotResolved({ name });
370 }
371
372 current = yield* waitUntilReady(
373 getByName(current.name ?? name),
374 current.name ?? name,
375 (item) => item.state,
376 );
377
378 if (!sameText(current.backupPlan, backupPlan)) {
379 const operation =
380 yield* backupdr.patchProjectsLocationsBackupPlanAssociations({
381 name: current.name ?? name,
382 updateMask: "backupPlan",
383 body: { backupPlan },
384 });
385 yield* waitForOperation(operation);
386 current = yield* waitUntilReady(
387 getByName(current.name ?? name),
388 current.name ?? name,
389 (item) => item.state,
390 );
391 }
392
393 return toAttrs(current, env.project, env.region);
394 }),
395
396 delete: Effect.fn(function* ({ output }) {
397 const operation = yield* backupdr
398 .deleteProjectsLocationsBackupPlanAssociations({ name: output.name })
399 .pipe(
400 Effect.retry({
401 while: (error) => error._tag === "Conflict",
402 times: 8,
403 schedule: Schedule.spaced("2 seconds"),
404 }),
405 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
406 );
407 if (operation !== undefined) {
408 // NOT_FOUND (5): already gone.
409 yield* waitForOperation(operation).pipe(
410 Effect.catchTag("GCP.OperationFailed", (error) =>
411 error.code === 5 ? Effect.void : Effect.fail(error),
412 ),
413 );
414 }
415 yield* waitUntilGone(getByName(output.name), output.name);
416 }),
417 });