1import * as vmwareengine from "@distilled.cloud/gcp/vmwareengine_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 VmwareengineNotResolved,
12 changedFields,
13 collectPages,
14 createInternalLabels,
15 encodeOwnership,
16 hasAlchemyLabels,
17 hasOwnershipMarker,
18 listAcrossLocations,
19 normalizeLocation,
20 parentOf,
21 parseName,
22 parseOwnership,
23 replaceOnIdentity,
24 rfc1035,
25 sameJson,
26 toPhysicalId,
27 waitForOperation,
28 waitUntilGone,
29 waitUntilPresent,
30 waitUntilReady,
31} from "./internal.ts";
32
33const COLLECTION = "datastores";
34
35export type GoogleFileService = {
36 /** NetApp Volume resource name. */
37 netappVolume?: string;
38 /** Filestore instance resource name. */
39 filestoreInstance?: string;
40};
41
42export type ThirdPartyFileService = {
43 /** NFS server IP addresses or DNS names. */
44 servers?: string[];
45 /** VPC used for NFS access. */
46 network?: string;
47 /** Mount folder name. */
48 fileShare?: string;
49};
50
51export type NfsDatastore = {
52 /** Google-managed file service (NetApp or Filestore). */
53 googleFileService?: GoogleFileService;
54 /** GCVE-managed file service. */
55 googleVmwareFileService?: Record<string, never>;
56 /** Third-party NFS file service. */
57 thirdPartyFileService?: ThirdPartyFileService;
58};
59
60export type DatastoreProps = {
61 /**
62 * Datastore id (the `{datastore}` segment of
63 * `projects/{project}/locations/{location}/datastores/{datastore}`).
64 * If omitted, a unique RFC1035 name is generated. Immutable — changing
65 * it replaces the datastore.
66 */
67 datastoreId?: string;
68 /**
69 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
70 * replaces the datastore. `US-CENTRAL1` is accepted and normalized.
71 * @default the stack's GCP region (`GCP.Region`, else the profile
72 * region, else `us-central1`)
73 */
74 location?: string;
75 /**
76 * NFS backend. Required. Choose Google Filestore/NetApp, a GCVE file
77 * service, or a third-party NFS server.
78 */
79 nfsDatastore: NfsDatastore;
80 /**
81 * Human-readable description. Datastores have no labels field, so
82 * Alchemy stamps ownership into a `[alchemy …]` prefix and strips it
83 * from attributes.
84 */
85 description?: string;
86};
87
88export type Datastore = Resource<
89 "GCP.VMwareEngine.Datastore",
90 DatastoreProps,
91 {
92 /** Full resource name. */
93 name: string;
94 /** Datastore id (last path segment). */
95 datastoreId: string;
96 /** Project id. */
97 project: string;
98 /** Location id. */
99 location: string;
100 /** NFS backend currently configured. */
101 nfsDatastore: NfsDatastore | undefined;
102 /** Clusters the datastore is attached to. */
103 clusters: string[];
104 /** User description with the Alchemy ownership prefix stripped. */
105 description: string | undefined;
106 /** Server-reported state. */
107 state: string | undefined;
108 /** Server etag. */
109 etag: string | undefined;
110 /** System-generated unique identifier. */
111 uid: string | undefined;
112 /** RFC3339 creation timestamp. */
113 createTime: string | undefined;
114 /** RFC3339 last-update timestamp. */
115 updateTime: string | undefined;
116 },
117 never,
118 Providers
119>;
120
121/**
122 * A Google Cloud VMware Engine NFS datastore.
123 *
124 * Datastores have no labels field, so Alchemy stamps ownership into the
125 * description for `list` / nuke. Changing `datastoreId`, `location`, or
126 * the NFS backend identity replaces the datastore. Description updates
127 * in place.
128 *
129 * ### Creating a Datastore
130 * **Example:** Filestore-backed NFS datastore
131 * ```typescript
132 * const datastore = yield* GCP.VMwareEngine.Datastore("Nfs", {
133 * nfsDatastore: {
134 * googleFileService: {
135 * filestoreInstance:
136 * "projects/my-project/locations/us-central1-a/instances/share",
137 * },
138 * },
139 * description: "app nfs datastore",
140 * });
141 * ```
142 *
143 * **Example:** Third-party NFS
144 * ```typescript
145 * const datastore = yield* GCP.VMwareEngine.Datastore("VendorNfs", {
146 * nfsDatastore: {
147 * thirdPartyFileService: {
148 * servers: ["10.1.2.3"],
149 * network: "projects/my-project/global/networks/default",
150 * fileShare: "vol1",
151 * },
152 * },
153 * });
154 * ```
155 *
156 * @resource
157 * @category VMwareEngine
158 */
159export const Datastore = Resource<Datastore>("GCP.VMwareEngine.Datastore");
160
161const resourceName = (project: string, location: string, datastoreId: string) =>
162 `${parentOf(project, location)}/${COLLECTION}/${datastoreId}`;
163
164const nfsOf = (
165 value: vmwareengine.NfsDatastore | NfsDatastore | undefined,
166): NfsDatastore | undefined => {
167 if (value === undefined) return undefined;
168 return {
169 googleFileService:
170 value.googleFileService === undefined
171 ? undefined
172 : {
173 netappVolume: value.googleFileService.netappVolume,
174 filestoreInstance: value.googleFileService.filestoreInstance,
175 },
176 googleVmwareFileService:
177 value.googleVmwareFileService === undefined ? undefined : {},
178 thirdPartyFileService:
179 value.thirdPartyFileService === undefined
180 ? undefined
181 : {
182 servers: value.thirdPartyFileService.servers,
183 network: value.thirdPartyFileService.network,
184 fileShare: value.thirdPartyFileService.fileShare,
185 },
186 };
187};
188
189const toAttrs = (
190 item: vmwareengine.Datastore,
191 project: string,
192 region: string,
193) => {
194 const name = item.name ?? "";
195 const parsed = parseName(name, COLLECTION, region);
196 const ownership = parseOwnership(item.description);
197 return {
198 name,
199 datastoreId: parsed.id,
200 project: parsed.project || project,
201 location: parsed.location,
202 nfsDatastore: nfsOf(item.nfsDatastore),
203 clusters: item.clusters ?? [],
204 description: ownership.text,
205 state: item.state,
206 etag: item.etag,
207 uid: item.uid,
208 createTime: item.createTime,
209 updateTime: item.updateTime,
210 };
211};
212
213const getByName = (name: string) =>
214 vmwareengine
215 .getProjectsLocationsDatastores({ name })
216 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
217
218export const DatastoreProvider = () =>
219 Provider.succeed(Datastore, {
220 stables: [
221 "name",
222 "datastoreId",
223 "project",
224 "location",
225 "uid",
226 "createTime",
227 ],
228
229 diff: Effect.fn(function* ({ news, olds, output }) {
230 if (!isResolved(news)) return undefined;
231 const env = yield* GcpEnvironment.current;
232 return replaceOnIdentity({
233 previousId: olds?.datastoreId ?? output?.datastoreId,
234 nextId: news.datastoreId
235 ? rfc1035(news.datastoreId, "datastore")
236 : (olds?.datastoreId ?? output?.datastoreId),
237 previousLocation: normalizeLocation(
238 olds?.location ?? output?.location,
239 env.region,
240 ),
241 nextLocation: normalizeLocation(
242 news.location ?? olds?.location ?? output?.location,
243 env.region,
244 ),
245 extra: !sameJson(news.nfsDatastore, olds?.nfsDatastore),
246 });
247 }),
248
249 read: Effect.fn(function* ({ id, olds, output }) {
250 const env = yield* GcpEnvironment.current;
251 const datastoreId = yield* toPhysicalId(
252 id,
253 olds?.datastoreId,
254 output?.datastoreId,
255 "datastore",
256 );
257 const location = normalizeLocation(
258 olds?.location ?? output?.location,
259 env.region,
260 );
261 const name =
262 output?.name ?? resourceName(env.project, location, datastoreId);
263 const existing = yield* getByName(name);
264 if (existing === undefined) return undefined;
265 const attrs = toAttrs(existing, env.project, env.region);
266 const { labels } = parseOwnership(existing.description);
267 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
268 }),
269
270 list: () =>
271 Effect.gen(function* () {
272 const env = yield* GcpEnvironment.current;
273 const items = yield* listAcrossLocations(
274 env.project,
275 env.region,
276 (parent) =>
277 collectPages(
278 vmwareengine.listProjectsLocationsDatastores.pages({
279 parent,
280 pageSize: 1000,
281 }),
282 (page) => page.datastores,
283 ),
284 );
285 return items
286 .filter((item) => hasOwnershipMarker(item.description))
287 .map((item) => toAttrs(item, env.project, env.region));
288 }),
289
290 reconcile: Effect.fn(function* ({ id, news, output }) {
291 const env = yield* GcpEnvironment.current;
292 const datastoreId = yield* toPhysicalId(
293 id,
294 news.datastoreId,
295 output?.datastoreId,
296 "datastore",
297 );
298 const location = normalizeLocation(
299 news.location ?? output?.location,
300 env.region,
301 );
302 const name = resourceName(env.project, location, datastoreId);
303 const ownership = yield* createInternalLabels(id);
304 const desiredDescription = encodeOwnership(ownership, news.description);
305
306 let current = yield* getByName(output?.name ?? name);
307
308 if (current === undefined) {
309 const created = yield* vmwareengine
310 .createProjectsLocationsDatastores({
311 parent: parentOf(env.project, location),
312 datastoreId,
313 body: {
314 nfsDatastore: news.nfsDatastore,
315 description: desiredDescription,
316 },
317 })
318 .pipe(
319 Effect.retry({
320 while: (error) => error._tag === "Conflict",
321 times: 5,
322 schedule: Schedule.spaced("2 seconds"),
323 }),
324 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
325 );
326 if (created !== undefined) {
327 yield* waitForOperation(created);
328 }
329 current = yield* waitUntilPresent(getByName(name), name);
330 }
331
332 if (current === undefined) {
333 return yield* new VmwareengineNotResolved({ name });
334 }
335
336 current = yield* waitUntilReady(
337 getByName(current.name ?? name),
338 current.name ?? name,
339 (item) => item.state,
340 );
341
342 const descriptionChanged =
343 (current.description ?? "") !== desiredDescription;
344 const nfsChanged = !sameJson(
345 nfsOf(current.nfsDatastore),
346 nfsOf(news.nfsDatastore),
347 );
348 const updateMask = changedFields([
349 ["description", descriptionChanged],
350 ["nfsDatastore", nfsChanged],
351 ]);
352
353 if (updateMask.length > 0) {
354 const operation = yield* vmwareengine.patchProjectsLocationsDatastores({
355 name: current.name ?? name,
356 updateMask: updateMask.join(","),
357 body: {
358 name: current.name ?? name,
359 description: desiredDescription,
360 nfsDatastore: news.nfsDatastore,
361 etag: current.etag,
362 },
363 });
364 yield* waitForOperation(operation);
365 current = yield* waitUntilReady(
366 getByName(current.name ?? name),
367 current.name ?? name,
368 (item) => item.state,
369 );
370 }
371
372 return toAttrs(current, env.project, env.region);
373 }),
374
375 delete: Effect.fn(function* ({ output }) {
376 const operation = yield* vmwareengine
377 .deleteProjectsLocationsDatastores({
378 name: output.name,
379 etag: output.etag,
380 })
381 .pipe(
382 Effect.retry({
383 while: (error) => error._tag === "Conflict",
384 times: 8,
385 schedule: Schedule.spaced("2 seconds"),
386 }),
387 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
388 );
389 if (operation !== undefined) {
390 yield* waitForOperation(operation, { notFoundOk: true });
391 }
392 yield* waitUntilGone(getByName(output.name), output.name);
393 }),
394 });