1import * as baremetalsolution from "@distilled.cloud/gcp/baremetalsolution_v2";
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_SIZE_GIB,
19 DEFAULT_STORAGE_TYPE,
20 differs,
21 fieldMask,
22 gibOf,
23 isDeletingState,
24 listAtLocation,
25 listLabeledPages,
26 networkOf,
27 normalizeLocation,
28 parentOf,
29 parseName,
30 replaceOnIdentity,
31 ResourceNotResolved,
32 sameText,
33 toPhysicalId,
34 userLabels,
35 waitForOperation,
36 waitUntilExists,
37 waitUntilGone,
38 waitUntilReady,
39} from "./internal.ts";
40
41export type AllowedClientProps = {
42 /**
43 * Bare Metal Solution network the access point sits on. Full name
44 * `projects/{project}/locations/{location}/networks/{network}` or the
45 * network id (combined with the share location).
46 */
47 network: string;
48 /**
49 * Subnet of IP addresses permitted to mount the share (CIDR).
50 */
51 allowedClientsCidr: string;
52 /**
53 * Mount permissions for the client.
54 * @default "READ_WRITE"
55 */
56 mountPermissions?:
57 | baremetalsolution.AllowedClientMountPermissionsEnum
58 | (string & {});
59 /**
60 * Allow creation of devices (`allow_dev`).
61 */
62 allowDev?: boolean;
63 /**
64 * Allow the setuid flag.
65 */
66 allowSuid?: boolean;
67 /**
68 * Disable NFS root squashing for this client.
69 */
70 noRootSquash?: boolean;
71};
72
73export type NfsShareProps = {
74 /**
75 * NFS share id (the `{nfsshare}` segment of
76 * `projects/{project}/locations/{location}/nfsShares/{nfsshare}`).
77 * If omitted, a unique RFC1035 name is generated from the stack, stage,
78 * and logical id. Immutable — changing it replaces the share.
79 */
80 nfsShareId?: string;
81 /**
82 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
83 * replaces the share. `US-CENTRAL1` is accepted and normalized to
84 * `us-central1`.
85 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
86 */
87 location?: string;
88 /**
89 * Requested size of the underlying volume, in GiB. Minimum is 100.
90 * @default 100
91 */
92 requestedSizeGib?: number | string;
93 /**
94 * Storage type of the underlying volume. Immutable — changing it
95 * replaces the share.
96 * @default "SSD"
97 */
98 storageType?: baremetalsolution.NfsShareStorageTypeEnum | (string & {});
99 /**
100 * Independent infrastructure pod. The share can only attach to networks
101 * and instances in the same pod. Immutable — changing it replaces the
102 * share.
103 */
104 pod?: string;
105 /**
106 * Access points allowed to mount the share. At least one client is
107 * required to create a share.
108 */
109 allowedClients: AllowedClientProps[];
110 /**
111 * User labels. Alchemy ownership labels are merged in automatically.
112 */
113 labels?: Record<string, string>;
114};
115
116export type AllowedClient = {
117 /** Network the access point sits on. */
118 network: string | undefined;
119 /** Client CIDR. */
120 allowedClientsCidr: string | undefined;
121 /** Mount permissions. */
122 mountPermissions: string | undefined;
123 /** Whether device creation is allowed. */
124 allowDev: boolean | undefined;
125 /** Whether the setuid flag is allowed. */
126 allowSuid: boolean | undefined;
127 /** Whether root squashing is disabled. */
128 noRootSquash: boolean | undefined;
129 /** Share IP assigned on this network. */
130 shareIp: string | undefined;
131 /** NFS path (`shareIp:/instanceId`). */
132 nfsPath: string | undefined;
133};
134
135export type NfsShare = Resource<
136 "GCP.BareMetalSolution.NfsShare",
137 NfsShareProps,
138 {
139 /** Full resource name. */
140 name: string;
141 /** NFS share id (last path segment). */
142 nfsShareId: string;
143 /** Project id. */
144 project: string;
145 /** Location id. */
146 location: string;
147 /** Requested size in GiB. */
148 requestedSizeGib: string | undefined;
149 /** Storage type of the underlying volume. */
150 storageType: string | undefined;
151 /** Infrastructure pod. */
152 pod: string | undefined;
153 /** Allowed access points. */
154 allowedClients: AllowedClient[];
155 /** User labels (Alchemy ownership labels stripped). */
156 labels: Record<string, string>;
157 /** Backend-generated identifier. */
158 id: string | undefined;
159 /** Underlying volume created for the share. */
160 volume: string | undefined;
161 /** Server-reported state. */
162 state: string | undefined;
163 },
164 never,
165 Providers
166>;
167
168/**
169 * A Bare Metal Solution NFS share — file storage attached to BMS
170 * networks in the same region and pod.
171 *
172 * Changing `nfsShareId`, `location`, `storageType`, or `pod` replaces the
173 * share. Labels and allowed clients update in place.
174 *
175 * Creating a share requires a Bare Metal Solution environment (physical
176 * hardware and a client network). Accounts without BMS entitlement are
177 * rejected by the API (`ServiceDisabled` when the API is off).
178 *
179 * ### Creating an NFS Share
180 * **Example:** Generated name
181 * ```typescript
182 * const share = yield* GCP.BareMetalSolution.NfsShare("Share", {
183 * allowedClients: [
184 * {
185 * network: "client-net",
186 * allowedClientsCidr: "10.0.0.0/24",
187 * mountPermissions: "READ_WRITE",
188 * noRootSquash: true,
189 * },
190 * ],
191 * });
192 * ```
193 *
194 * **Example:** Explicit id, size, and labels
195 * ```typescript
196 * const share = yield* GCP.BareMetalSolution.NfsShare("Share", {
197 * nfsShareId: "app-nfs",
198 * requestedSizeGib: 256,
199 * storageType: "SSD",
200 * allowedClients: [
201 * {
202 * network: network.name,
203 * allowedClientsCidr: "10.200.0.0/28",
204 * mountPermissions: "READ_WRITE",
205 * allowDev: true,
206 * noRootSquash: true,
207 * },
208 * ],
209 * labels: { env: "prod" },
210 * });
211 * ```
212 *
213 * ### Updating an NFS Share
214 * **Example:** Labels and allowed clients
215 * ```typescript
216 * const share = yield* GCP.BareMetalSolution.NfsShare("Share", {
217 * allowedClients: [
218 * {
219 * network: network.name,
220 * allowedClientsCidr: "10.200.0.0/28",
221 * mountPermissions: "READ_ONLY",
222 * },
223 * ],
224 * labels: { env: "prod", team: "storage" },
225 * });
226 * ```
227 *
228 * @resource
229 * @category BareMetalSolution
230 */
231export const NfsShare = Resource<NfsShare>("GCP.BareMetalSolution.NfsShare");
232
233const resourceName = (project: string, location: string, nfsShareId: string) =>
234 `${parentOf(project, location)}/nfsShares/${nfsShareId}`;
235
236const toAllowedClient = (
237 client: baremetalsolution.AllowedClient,
238): AllowedClient => ({
239 network: client.network,
240 allowedClientsCidr: client.allowedClientsCidr,
241 mountPermissions: client.mountPermissions,
242 allowDev: client.allowDev,
243 allowSuid: client.allowSuid,
244 noRootSquash: client.noRootSquash,
245 shareIp: client.shareIp,
246 nfsPath: client.nfsPath,
247});
248
249const desiredClient = (
250 client: AllowedClientProps,
251 project: string,
252 location: string,
253): baremetalsolution.AllowedClient => ({
254 network: networkOf(client.network, project, location),
255 allowedClientsCidr: client.allowedClientsCidr,
256 mountPermissions: client.mountPermissions,
257 allowDev: client.allowDev,
258 allowSuid: client.allowSuid,
259 noRootSquash: client.noRootSquash,
260});
261
262const toAttrs = (
263 item: baremetalsolution.NfsShare,
264 project: string,
265 region: string,
266) => {
267 const name = item.name ?? "";
268 const parsed = parseName(name, "nfsShares", region);
269 return {
270 name,
271 nfsShareId: parsed.id || item.nfsShareId || item.id || "",
272 project: parsed.project || project,
273 location: parsed.location,
274 requestedSizeGib: item.requestedSizeGib,
275 storageType: item.storageType,
276 pod: item.pod,
277 allowedClients: (item.allowedClients ?? []).map(toAllowedClient),
278 labels: userLabels(item.labels),
279 id: item.id ?? item.nfsShareId,
280 volume: item.volume,
281 state: item.state,
282 };
283};
284
285const getByName = (name: string) =>
286 Effect.gen(function* () {
287 if (name.length === 0 || name.includes("//")) {
288 return undefined;
289 }
290 return yield* baremetalsolution
291 .getProjectsLocationsNfsShares({ name })
292 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
293 });
294
295const listOwned = (project: string, region: string) =>
296 listAtLocation(project, region, (parent) =>
297 listLabeledPages(
298 baremetalsolution.listProjectsLocationsNfsShares.pages({
299 parent,
300 pageSize: 1000,
301 }),
302 (page) => page.nfsShares,
303 (item) => item.labels,
304 ),
305 );
306
307export const NfsShareProvider = () =>
308 Provider.succeed(NfsShare, {
309 stables: ["name", "nfsShareId", "project", "location", "id", "volume"],
310
311 diff: Effect.fn(function* ({ news, olds, output }) {
312 if (!isResolved(news)) return undefined;
313 const env = yield* GcpEnvironment.current;
314 const previousType = olds?.storageType ?? output?.storageType;
315 const nextType = news.storageType ?? previousType;
316 const previousPod = olds?.pod ?? output?.pod;
317 const nextPod = news.pod ?? previousPod;
318 return replaceOnIdentity({
319 previousId: olds?.nfsShareId ?? output?.nfsShareId,
320 nextId: news.nfsShareId ?? olds?.nfsShareId ?? output?.nfsShareId,
321 previousLocation: normalizeLocation(
322 olds?.location ?? output?.location,
323 env.region,
324 ),
325 nextLocation: normalizeLocation(
326 news.location ?? olds?.location ?? output?.location,
327 env.region,
328 ),
329 extra:
330 (previousType !== undefined &&
331 nextType !== undefined &&
332 previousType !== nextType) ||
333 (previousPod !== undefined &&
334 nextPod !== undefined &&
335 previousPod !== nextPod),
336 });
337 }),
338
339 read: Effect.fn(function* ({ id, olds, output }) {
340 const env = yield* GcpEnvironment.current;
341 const nfsShareId = yield* toPhysicalId(
342 id,
343 olds?.nfsShareId,
344 output?.nfsShareId,
345 "nfsshare",
346 );
347 const location = normalizeLocation(
348 olds?.location ?? output?.location,
349 env.region,
350 );
351 const name =
352 output?.name ?? resourceName(env.project, location, nfsShareId);
353 const existing = yield* getByName(name);
354 if (existing === undefined) return undefined;
355 const attrs = toAttrs(existing, env.project, env.region);
356 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
357 ? attrs
358 : Unowned(attrs);
359 }),
360
361 list: () =>
362 Effect.gen(function* () {
363 const env = yield* GcpEnvironment.current;
364 const items = yield* listOwned(env.project, env.region);
365 return items.map((item) => toAttrs(item, env.project, env.region));
366 }),
367
368 reconcile: Effect.fn(function* ({ id, news, output }) {
369 const env = yield* GcpEnvironment.current;
370 const nfsShareId = yield* toPhysicalId(
371 id,
372 news.nfsShareId,
373 output?.nfsShareId,
374 "nfsshare",
375 );
376 const location = normalizeLocation(
377 news.location ?? output?.location,
378 env.region,
379 );
380 const name = resourceName(env.project, location, nfsShareId);
381 const desiredLabels = {
382 ...toLabels(news.labels),
383 ...(yield* createInternalLabels(id)),
384 };
385 const size = gibOf(news.requestedSizeGib) ?? DEFAULT_SIZE_GIB;
386 const storageType = news.storageType ?? DEFAULT_STORAGE_TYPE;
387 const desiredClients = news.allowedClients.map((client) =>
388 desiredClient(client, env.project, location),
389 );
390
391 let current = yield* getByName(output?.name ?? name);
392
393 if (current !== undefined && isDeletingState(current.state)) {
394 yield* waitUntilGone(
395 getByName(current.name ?? name),
396 current.name ?? name,
397 );
398 current = undefined;
399 }
400
401 if (current === undefined) {
402 const created = yield* baremetalsolution
403 .createProjectsLocationsNfsShares({
404 parent: parentOf(env.project, location),
405 body: {
406 name,
407 requestedSizeGib: size,
408 storageType,
409 pod: news.pod,
410 allowedClients: desiredClients,
411 labels: desiredLabels,
412 },
413 })
414 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
415 if (created !== undefined) {
416 yield* waitForOperation(created);
417 }
418 current = yield* waitUntilExists(getByName(name), name);
419 }
420
421 if (current === undefined) {
422 return yield* new ResourceNotResolved({ name });
423 }
424
425 const ready = yield* waitUntilReady(
426 getByName(current.name ?? name),
427 current.name ?? name,
428 (item) => item.state,
429 );
430
431 const observedLabels = tagRecord(ready.labels);
432 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
433 const labelsChanged = upsert.length > 0 || removed.length > 0;
434 const clientsChanged = differs(
435 (ready.allowedClients ?? []).map((client) => ({
436 network: client.network,
437 allowedClientsCidr: client.allowedClientsCidr,
438 mountPermissions: client.mountPermissions,
439 allowDev: client.allowDev,
440 allowSuid: client.allowSuid,
441 noRootSquash: client.noRootSquash,
442 })),
443 desiredClients,
444 );
445 const sizeChanged = !sameText(ready.requestedSizeGib, size);
446 const mask = fieldMask([
447 labelsChanged && "labels",
448 clientsChanged && "allowedClients",
449 sizeChanged && "requestedSizeGib",
450 ]);
451
452 if (mask.length > 0) {
453 const operation =
454 yield* baremetalsolution.patchProjectsLocationsNfsShares({
455 name: ready.name ?? name,
456 updateMask: mask,
457 body: {
458 name: ready.name ?? name,
459 labels: desiredLabels,
460 allowedClients: desiredClients,
461 requestedSizeGib: size,
462 },
463 });
464 yield* waitForOperation(operation);
465 const patched = yield* waitUntilReady(
466 getByName(ready.name ?? name),
467 ready.name ?? name,
468 (item) => item.state,
469 );
470 return toAttrs(patched, env.project, env.region);
471 }
472
473 return toAttrs(ready, env.project, env.region);
474 }),
475
476 delete: Effect.fn(function* ({ output }) {
477 if (!output.name || output.name.includes("//")) return;
478 const operation = yield* baremetalsolution
479 .deleteProjectsLocationsNfsShares({ name: output.name })
480 .pipe(
481 Effect.retry({
482 while: (error) => error._tag === "Conflict",
483 times: 8,
484 schedule: Schedule.spaced("2 seconds"),
485 }),
486 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
487 );
488 if (operation !== undefined) {
489 yield* waitForOperation(operation, { notFoundOk: true });
490 }
491 yield* waitUntilGone(getByName(output.name), output.name);
492 }),
493 });