1import * as baremetalsolution from "@distilled.cloud/gcp/baremetalsolution_v2";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import { hasAlchemyLabels } from "../Labels.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 createInternalLabels,
12 encodeOwnership,
13 hasOwnershipMarker,
14 listVolumeSnapshots,
15 normalizeLocation,
16 parseName,
17 parseOwnership,
18 replaceOnIdentity,
19 ResourceNotResolved,
20 volumeOf,
21 waitUntilGone,
22} from "./internal.ts";
23
24export type VolumesSnapshotProps = {
25 /**
26 * Parent boot volume. Full name
27 * `projects/{project}/locations/{location}/volumes/{volume}` or the
28 * volume id (combined with `location`). Immutable — changing it
29 * replaces the snapshot. Only boot volumes accept snapshots.
30 */
31 volume: string;
32 /**
33 * Region used when `volume` is a bare id. Immutable — changing it
34 * replaces the snapshot. `US-CENTRAL1` is accepted and normalized to
35 * `us-central1`.
36 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
37 */
38 location?: string;
39 /**
40 * Snapshot id (the `{snapshot}` segment of
41 * `{volume}/snapshots/{snapshot}`). If omitted, the API assigns an id.
42 * Immutable — changing it replaces the snapshot.
43 */
44 snapshotId?: string;
45 /**
46 * Human-readable description. Volume snapshots have no labels field, so
47 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
48 * from attributes.
49 */
50 description?: string;
51};
52
53export type VolumesSnapshot = Resource<
54 "GCP.BareMetalSolution.VolumesSnapshot",
55 VolumesSnapshotProps,
56 {
57 /** Full resource name. */
58 name: string;
59 /** Snapshot id (last path segment). */
60 snapshotId: string;
61 /** Parent volume resource name. */
62 volume: string;
63 /** Project id. */
64 project: string;
65 /** Location id. */
66 location: string;
67 /** User description with the Alchemy ownership prefix stripped. */
68 description: string | undefined;
69 /** Snapshot type (`AD_HOC` or `SCHEDULED`). */
70 type: string | undefined;
71 /** Backend-generated identifier. */
72 id: string | undefined;
73 /** RFC3339 creation timestamp. */
74 createTime: string | undefined;
75 },
76 never,
77 Providers
78>;
79
80/**
81 * A Bare Metal Solution boot-volume snapshot — a point-in-time copy of a
82 * BMS boot volume.
83 *
84 * Snapshots have no labels field, so Alchemy stamps ownership into a
85 * `[alchemy …]` description prefix for `list` / nuke. The API does not
86 * expose an update method; description is set at create time. Changing
87 * `snapshotId`, `volume`, or `location` replaces the snapshot.
88 *
89 * Creating a snapshot requires an existing boot volume in a Bare Metal
90 * Solution environment. Non-boot volumes and unentitled accounts are
91 * rejected by the API (`ServiceDisabled` when the API is off).
92 *
93 * ### Creating a Volume Snapshot
94 * **Example:** Generated name
95 * ```typescript
96 * const snap = yield* GCP.BareMetalSolution.VolumesSnapshot("Nightly", {
97 * volume: volume.name,
98 * });
99 * ```
100 *
101 * **Example:** Description
102 * ```typescript
103 * const snap = yield* GCP.BareMetalSolution.VolumesSnapshot("Nightly", {
104 * volume: volume.name,
105 * description: "nightly boot snapshot",
106 * });
107 * ```
108 *
109 * @resource
110 * @category BareMetalSolution
111 */
112export const VolumesSnapshot = Resource<VolumesSnapshot>(
113 "GCP.BareMetalSolution.VolumesSnapshot",
114);
115
116const resourceName = (volume: string, snapshotId: string) =>
117 `${volume}/snapshots/${snapshotId}`;
118
119const toAttrs = (
120 snapshot: baremetalsolution.VolumeSnapshot,
121 project: string,
122 region: string,
123) => {
124 const name = snapshot.name ?? "";
125 const parsed = parseName(name, "snapshots", region);
126 const ownership = parseOwnership(snapshot.description);
127 return {
128 name,
129 snapshotId: parsed.id || snapshot.id || "",
130 volume: snapshot.storageVolume || parsed.parent,
131 project: parsed.project || project,
132 location: parsed.location,
133 description: ownership.text,
134 type: snapshot.type,
135 id: snapshot.id,
136 createTime: snapshot.createTime,
137 };
138};
139
140const getByName = (name: string) =>
141 Effect.gen(function* () {
142 if (name.length === 0 || name.includes("//")) {
143 return undefined;
144 }
145 return yield* baremetalsolution
146 .getProjectsLocationsVolumesSnapshots({ name })
147 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
148 });
149
150export const VolumesSnapshotProvider = () =>
151 Provider.succeed(VolumesSnapshot, {
152 stables: [
153 "name",
154 "snapshotId",
155 "volume",
156 "project",
157 "location",
158 "id",
159 "createTime",
160 ],
161
162 diff: Effect.fn(function* ({ news, olds, output }) {
163 if (!isResolved(news)) return undefined;
164 const env = yield* GcpEnvironment.current;
165 const previousLocation = normalizeLocation(
166 olds?.location ?? output?.location,
167 env.region,
168 );
169 const nextLocation = normalizeLocation(
170 news.location ?? olds?.location ?? output?.location,
171 env.region,
172 );
173 const previousVolume = volumeOf(
174 olds?.volume ?? output?.volume ?? "",
175 env.project,
176 previousLocation,
177 );
178 const nextVolume = volumeOf(
179 news.volume ?? olds?.volume ?? output?.volume ?? "",
180 env.project,
181 nextLocation,
182 );
183 return replaceOnIdentity({
184 previousId: olds?.snapshotId ?? output?.snapshotId,
185 nextId: news.snapshotId ?? olds?.snapshotId ?? output?.snapshotId,
186 previousLocation,
187 nextLocation,
188 previousParent: previousVolume,
189 nextParent: nextVolume,
190 });
191 }),
192
193 read: Effect.fn(function* ({ id, olds, output }) {
194 const env = yield* GcpEnvironment.current;
195 const location = normalizeLocation(
196 olds?.location ?? output?.location,
197 env.region,
198 );
199 const volumeHint = olds?.volume ?? output?.volume ?? "";
200 if (!output?.name && volumeHint.length === 0) return undefined;
201 const volume = volumeOf(volumeHint, env.project, location);
202 const snapshotId = olds?.snapshotId ?? output?.snapshotId;
203 const name =
204 output?.name ??
205 (snapshotId !== undefined ? resourceName(volume, snapshotId) : "");
206 if (name.length === 0) return undefined;
207 const existing = yield* getByName(name);
208 if (existing === undefined) return undefined;
209 const attrs = toAttrs(existing, env.project, env.region);
210 const { labels } = parseOwnership(existing.description);
211 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
212 }),
213
214 list: () =>
215 Effect.gen(function* () {
216 const env = yield* GcpEnvironment.current;
217 const items = yield* listVolumeSnapshots(env.project, env.region);
218 return items
219 .filter((item) => hasOwnershipMarker(item.description))
220 .map((item) => toAttrs(item, env.project, env.region));
221 }),
222
223 reconcile: Effect.fn(function* ({ id, news, output }) {
224 const env = yield* GcpEnvironment.current;
225 const locationHint = normalizeLocation(
226 news.location ?? output?.location,
227 env.region,
228 );
229 const volume = volumeOf(news.volume, env.project, locationHint);
230 const snapshotId = news.snapshotId ?? output?.snapshotId;
231 const name =
232 output?.name ??
233 (snapshotId !== undefined ? resourceName(volume, snapshotId) : "");
234 const ownership = yield* createInternalLabels(id);
235 const desiredDescription = encodeOwnership(ownership, news.description);
236
237 let current = yield* getByName(name);
238
239 if (current === undefined) {
240 const created = yield* baremetalsolution
241 .createProjectsLocationsVolumesSnapshots({
242 parent: volume,
243 body: {
244 name: name.length > 0 ? name : undefined,
245 description: desiredDescription,
246 },
247 })
248 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
249 current = created ?? undefined;
250 }
251
252 if (current === undefined) {
253 return yield* new ResourceNotResolved({
254 name: name.length > 0 ? name : `${volume}/snapshots`,
255 });
256 }
257
258 return toAttrs(current, env.project, env.region);
259 }),
260
261 delete: Effect.fn(function* ({ output }) {
262 if (!output.name || output.name.includes("//")) return;
263 yield* baremetalsolution
264 .deleteProjectsLocationsVolumesSnapshots({ name: output.name })
265 .pipe(Effect.catchTag("NotFound", () => Effect.void));
266 yield* waitUntilGone(getByName(output.name), output.name);
267 }),
268 });