1import * as metastore from "@distilled.cloud/gcp/metastore_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 { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 encodeOwnership,
13 expandParent,
14 hasOwnershipMarker,
15 listAtNested,
16 listOwnedPages,
17 normalizeLocation,
18 ownedByAlchemy,
19 parseName,
20 parseOwnership,
21 replaceOnIdentity,
22 ResourceNotResolved,
23 toPhysicalId,
24 waitForOperation,
25 waitUntilExists,
26 waitUntilGone,
27 waitUntilReady,
28} from "./internal.ts";
29
30export type ServicesBackupProps = {
31 /**
32 * Parent Dataproc Metastore service. Full name
33 * `projects/{project}/locations/{location}/services/{service}` or the
34 * service id (combined with `location`). Immutable — changing it
35 * replaces the backup.
36 */
37 service: string;
38 /**
39 * Region used when `service` is a bare id.
40 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
41 */
42 location?: string;
43 /**
44 * Backup id (the `{backup}` segment). If omitted, a unique RFC1035 name
45 * is generated. Must begin with a letter and end with a letter or
46 * number. Immutable — changing it replaces the backup.
47 */
48 backupId?: string;
49 /**
50 * Human-readable description. Backups have no labels field and no
51 * update API, so Alchemy stamps ownership into the description at
52 * create and cannot change it in place.
53 */
54 description?: string;
55};
56
57export type ServicesBackup = Resource<
58 "GCP.Metastore.ServicesBackup",
59 ServicesBackupProps,
60 {
61 /** Full resource name. */
62 name: string;
63 /** Backup id (last path segment). */
64 backupId: string;
65 /** Parent service name. */
66 service: string;
67 /** Project id. */
68 project: string;
69 /** Location id. */
70 location: string;
71 /** User description with the Alchemy ownership prefix stripped. */
72 description: string | undefined;
73 /** Server-reported state. */
74 state: string | undefined;
75 /** Services currently restoring from this backup. */
76 restoringServices: string[];
77 /** RFC3339 creation timestamp. */
78 createTime: string | undefined;
79 /** RFC3339 completion timestamp. */
80 endTime: string | undefined;
81 },
82 never,
83 Providers
84>;
85
86/**
87 * A Dataproc Metastore service backup — a point-in-time capture of a
88 * Hive metastore.
89 *
90 * Changing `backupId`, `service`, or `location` replaces the backup.
91 * Description is set at create; backups have no update API.
92 *
93 * ### Creating a Backup
94 * **Example:** Manual backup
95 * ```typescript
96 * const backup = yield* GCP.Metastore.ServicesBackup("Nightly", {
97 * service: hive.name,
98 * description: "pre-release",
99 * });
100 * ```
101 *
102 * **Example:** Explicit id
103 * ```typescript
104 * const backup = yield* GCP.Metastore.ServicesBackup("Nightly", {
105 * service: hive.name,
106 * backupId: "nightly-01",
107 * });
108 * ```
109 *
110 * @resource
111 * @category Metastore
112 */
113export const ServicesBackup = Resource<ServicesBackup>(
114 "GCP.Metastore.ServicesBackup",
115);
116
117const resourceName = (service: string, backupId: string) =>
118 `${service}/backups/${backupId}`;
119
120const toAttrs = (item: metastore.Backup, project: string, region: string) => {
121 const name = item.name ?? "";
122 const parsed = parseName(name, "backups", region);
123 const { text } = parseOwnership(item.description);
124 return {
125 name,
126 backupId: parsed.id,
127 service: parsed.parent,
128 project: parsed.project || project,
129 location: parsed.location,
130 description: text,
131 state: item.state,
132 restoringServices: item.restoringServices ?? [],
133 createTime: item.createTime,
134 endTime: item.endTime,
135 };
136};
137
138const getByName = (name: string) =>
139 name.length === 0
140 ? Effect.succeed(undefined)
141 : metastore
142 .getProjectsLocationsServicesBackups({ name })
143 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
144
145const listOwned = (project: string, region: string) =>
146 listAtNested(project, "services/-", (parent) =>
147 listOwnedPages(
148 metastore.listProjectsLocationsServicesBackups.pages({
149 parent,
150 pageSize: 1000,
151 }),
152 (page) => page.backups,
153 (item) => hasOwnershipMarker(item.description),
154 ),
155 );
156
157export const ServicesBackupProvider = () =>
158 Provider.succeed(ServicesBackup, {
159 stables: [
160 "name",
161 "backupId",
162 "service",
163 "project",
164 "location",
165 "createTime",
166 ],
167
168 diff: Effect.fn(function* ({ news, olds, output }) {
169 if (!isResolved(news)) return undefined;
170 const env = yield* GcpEnvironment.current;
171 return replaceOnIdentity({
172 previousId: olds?.backupId ?? output?.backupId,
173 nextId: news.backupId ?? olds?.backupId ?? output?.backupId,
174 previousLocation: normalizeLocation(
175 olds?.location ?? output?.location,
176 env.region,
177 ),
178 nextLocation: normalizeLocation(
179 news.location ?? olds?.location ?? output?.location,
180 env.region,
181 ),
182 previousParent: olds?.service ?? output?.service,
183 nextParent: news.service,
184 });
185 }),
186
187 read: Effect.fn(function* ({ id, olds, output }) {
188 const env = yield* GcpEnvironment.current;
189 const backupId = yield* toPhysicalId(
190 id,
191 olds?.backupId,
192 output?.backupId,
193 "backup",
194 );
195 const location = normalizeLocation(
196 olds?.location ?? output?.location,
197 env.region,
198 );
199 const service = expandParent(
200 olds?.service ?? output?.service ?? "",
201 env.project,
202 location,
203 "services",
204 );
205 const name = output?.name ?? resourceName(service, backupId);
206 const existing = yield* getByName(name);
207 if (existing === undefined) return undefined;
208 const attrs = toAttrs(existing, env.project, env.region);
209 return (yield* ownedByAlchemy(id, existing.description))
210 ? attrs
211 : Unowned(attrs);
212 }),
213
214 list: () =>
215 Effect.gen(function* () {
216 const env = yield* GcpEnvironment.current;
217 const items = yield* listOwned(env.project, env.region);
218 return items.map((item) => toAttrs(item, env.project, env.region));
219 }),
220
221 reconcile: Effect.fn(function* ({ id, news, output }) {
222 const env = yield* GcpEnvironment.current;
223 const backupId = yield* toPhysicalId(
224 id,
225 news.backupId,
226 output?.backupId,
227 "backup",
228 );
229 const location = normalizeLocation(
230 news.location ?? output?.location,
231 env.region,
232 );
233 const service = expandParent(
234 news.service,
235 env.project,
236 location,
237 "services",
238 );
239 const name = resourceName(service, backupId);
240 const desiredLabels = yield* createInternalLabels(id);
241 const description = encodeOwnership(desiredLabels, news.description);
242
243 let current = yield* getByName(output?.name ?? name);
244
245 if (current === undefined) {
246 const created = yield* metastore
247 .createProjectsLocationsServicesBackups({
248 parent: service,
249 backupId,
250 body: { description },
251 })
252 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
253 if (created !== undefined) {
254 // ALREADY_EXISTS (6): a concurrent create won the race.
255 yield* waitForOperation(created).pipe(
256 Effect.catchTag("GCP.OperationFailed", (error) =>
257 error.code === 6 ? Effect.void : Effect.fail(error),
258 ),
259 );
260 }
261 current = yield* waitUntilExists(getByName(name), name);
262 current = yield* waitUntilReady(
263 getByName(name),
264 name,
265 (item) => item.state,
266 );
267 }
268
269 if (current === undefined) {
270 return yield* new ResourceNotResolved({ name });
271 }
272
273 return toAttrs(current, env.project, env.region);
274 }),
275
276 delete: Effect.fn(function* ({ output }) {
277 const operation = yield* metastore
278 .deleteProjectsLocationsServicesBackups({ name: output.name })
279 .pipe(
280 Effect.retry({
281 while: (error) => error._tag === "Conflict",
282 times: 8,
283 schedule: Schedule.spaced("2 seconds"),
284 }),
285 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
286 );
287 if (operation !== undefined) {
288 // NOT_FOUND (5): already gone.
289 yield* waitForOperation(operation).pipe(
290 Effect.catchTag("GCP.OperationFailed", (error) =>
291 error.code === 5 ? Effect.void : Effect.fail(error),
292 ),
293 );
294 }
295 yield* waitUntilGone(getByName(output.name), output.name);
296 }),
297 });