1import * as netapp from "@distilled.cloud/gcp/netapp_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 { 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 fieldMask,
19 listAtLocation,
20 listLabeledPages,
21 normalizeLocation,
22 parentOf,
23 parseName,
24 replaceOnIdentity,
25 ResourceNotResolved,
26 sameStringList,
27 toPhysicalId,
28 userLabels,
29 waitForDeleteOperation,
30 waitForOperation,
31 waitUntilExists,
32 waitUntilGone,
33 waitUntilReady,
34} from "./internal.ts";
35
36const DEFAULT_TYPE: netapp.HostGroupTypeEnum = "ISCSI_INITIATOR";
37const DEFAULT_OS: netapp.HostGroupOsTypeEnum = "LINUX";
38
39export type HostGroupProps = {
40 /**
41 * Host group id (the `{hostGroup}` segment of
42 * `projects/{project}/locations/{location}/hostGroups/{hostGroup}`).
43 * If omitted, a unique RFC1035 name is generated from the stack, stage,
44 * and logical id. Immutable — changing it replaces the group.
45 */
46 hostGroupId?: string;
47 /**
48 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
49 * replaces the group. `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 * IQN (iSCSI) or NQN (NVMe) host identifiers in the group.
56 */
57 hosts: string[];
58 /**
59 * Host group type. Immutable — changing it replaces the group.
60 * @default "ISCSI_INITIATOR"
61 */
62 type?: netapp.HostGroupTypeEnum | (string & {});
63 /**
64 * OS type shared by every host. Immutable — changing it replaces the
65 * group.
66 * @default "LINUX"
67 */
68 osType?: netapp.HostGroupOsTypeEnum | (string & {});
69 /**
70 * Human-readable description.
71 */
72 description?: string;
73 /**
74 * User labels. Alchemy ownership labels are merged in automatically.
75 */
76 labels?: Record<string, string>;
77};
78
79export type HostGroup = Resource<
80 "GCP.NetApp.HostGroup",
81 HostGroupProps,
82 {
83 /** Full resource name. */
84 name: string;
85 /** Host group id (last path segment). */
86 hostGroupId: string;
87 /** Project id. */
88 project: string;
89 /** Location id. */
90 location: string;
91 /** Host identifiers. */
92 hosts: string[];
93 /** Host group type. */
94 type: string | undefined;
95 /** OS type. */
96 osType: string | undefined;
97 /** Human-readable description. */
98 description: string | undefined;
99 /** User labels (Alchemy ownership labels stripped). */
100 labels: Record<string, string>;
101 /** Server-reported state. */
102 state: string | undefined;
103 /** RFC3339 creation timestamp. */
104 createTime: string | undefined;
105 },
106 never,
107 Providers
108>;
109
110/**
111 * A Cloud NetApp Volumes host group — a set of iSCSI initiators that can
112 * mount a block volume.
113 *
114 * Changing `hostGroupId`, `location`, `type`, or `osType` replaces the
115 * group. Hosts, description, and labels update in place.
116 *
117 * ### Creating a Host Group
118 * **Example:** Generated name
119 * ```typescript
120 * const group = yield* GCP.NetApp.HostGroup("Initiators", {
121 * hosts: ["iqn.1993-08.org.debian:01:db"],
122 * });
123 * ```
124 *
125 * **Example:** Explicit id and OS
126 * ```typescript
127 * const group = yield* GCP.NetApp.HostGroup("Initiators", {
128 * hostGroupId: "db-hosts",
129 * hosts: ["iqn.1993-08.org.debian:01:db"],
130 * osType: "LINUX",
131 * description: "database initiators",
132 * labels: { env: "prod" },
133 * });
134 * ```
135 *
136 * ### Updating a Host Group
137 * **Example:** Hosts and labels
138 * ```typescript
139 * const group = yield* GCP.NetApp.HostGroup("Initiators", {
140 * hosts: [
141 * "iqn.1993-08.org.debian:01:db",
142 * "iqn.1993-08.org.debian:01:db2",
143 * ],
144 * labels: { env: "prod", team: "storage" },
145 * });
146 * ```
147 *
148 * @resource
149 * @category NetApp
150 */
151export const HostGroup = Resource<HostGroup>("GCP.NetApp.HostGroup");
152
153const resourceName = (project: string, location: string, hostGroupId: string) =>
154 `projects/${project}/locations/${location}/hostGroups/${hostGroupId}`;
155
156const toAttrs = (item: netapp.HostGroup, project: string, region: string) => {
157 const name = item.name ?? "";
158 const parsed = parseName(name, "hostGroups", region);
159 return {
160 name,
161 hostGroupId: parsed.id,
162 project: parsed.project || project,
163 location: parsed.location,
164 hosts: item.hosts ?? [],
165 type: item.type,
166 osType: item.osType,
167 description: item.description,
168 labels: userLabels(item.labels),
169 state: item.state,
170 createTime: item.createTime,
171 };
172};
173
174const getByName = (name: string) =>
175 netapp
176 .getProjectsLocationsHostGroups({ name })
177 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
178
179const listOwned = (project: string) =>
180 listAtLocation(project, (parent) =>
181 listLabeledPages(
182 netapp.listProjectsLocationsHostGroups.pages({
183 parent,
184 pageSize: 1000,
185 }),
186 (page) => page.hostGroups,
187 (item) => item.labels,
188 ),
189 );
190
191export const HostGroupProvider = () =>
192 Provider.succeed(HostGroup, {
193 stables: ["name", "hostGroupId", "project", "location", "createTime"],
194
195 diff: Effect.fn(function* ({ news, olds, output }) {
196 if (!isResolved(news)) return undefined;
197 const env = yield* GcpEnvironment.current;
198 const previousType = olds?.type ?? output?.type;
199 const previousOs = olds?.osType ?? output?.osType;
200 const nextType = news.type ?? DEFAULT_TYPE;
201 const nextOs = news.osType ?? DEFAULT_OS;
202 return replaceOnIdentity({
203 previousId: olds?.hostGroupId ?? output?.hostGroupId,
204 nextId: news.hostGroupId ?? olds?.hostGroupId ?? output?.hostGroupId,
205 previousLocation: normalizeLocation(
206 olds?.location ?? output?.location,
207 env.region,
208 ),
209 nextLocation: normalizeLocation(
210 news.location ?? olds?.location ?? output?.location,
211 env.region,
212 ),
213 extra:
214 (previousType !== undefined && nextType !== previousType) ||
215 (previousOs !== undefined && nextOs !== previousOs),
216 });
217 }),
218
219 read: Effect.fn(function* ({ id, olds, output }) {
220 const env = yield* GcpEnvironment.current;
221 const hostGroupId = yield* toPhysicalId(
222 id,
223 olds?.hostGroupId,
224 output?.hostGroupId,
225 "hostgroup",
226 );
227 const location = normalizeLocation(
228 olds?.location ?? output?.location,
229 env.region,
230 );
231 const name =
232 output?.name ?? resourceName(env.project, location, hostGroupId);
233 const existing = yield* getByName(name);
234 if (existing === undefined) return undefined;
235 const attrs = toAttrs(existing, env.project, env.region);
236 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
237 ? attrs
238 : Unowned(attrs);
239 }),
240
241 list: () =>
242 Effect.gen(function* () {
243 const env = yield* GcpEnvironment.current;
244 const items = yield* listOwned(env.project);
245 return items.map((item) => toAttrs(item, env.project, env.region));
246 }),
247
248 reconcile: Effect.fn(function* ({ id, news, output }) {
249 const env = yield* GcpEnvironment.current;
250 const hostGroupId = yield* toPhysicalId(
251 id,
252 news.hostGroupId,
253 output?.hostGroupId,
254 "hostgroup",
255 );
256 const location = normalizeLocation(
257 news.location ?? output?.location,
258 env.region,
259 );
260 const name = resourceName(env.project, location, hostGroupId);
261 const type = news.type ?? DEFAULT_TYPE;
262 const osType = news.osType ?? DEFAULT_OS;
263 const desiredLabels = {
264 ...toLabels(news.labels),
265 ...(yield* createInternalLabels(id)),
266 };
267
268 let current = yield* getByName(output?.name ?? name);
269
270 if (current === undefined) {
271 const created = yield* netapp
272 .createProjectsLocationsHostGroups({
273 parent: parentOf(env.project, location),
274 hostGroupId,
275 body: {
276 hosts: news.hosts,
277 type,
278 osType,
279 description: news.description,
280 labels: desiredLabels,
281 },
282 })
283 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
284 if (created !== undefined) {
285 yield* waitForOperation(created);
286 }
287 current = yield* waitUntilExists(getByName(name), name);
288 }
289
290 if (current === undefined) {
291 return yield* new ResourceNotResolved({ name });
292 }
293
294 current = yield* waitUntilReady(
295 getByName(current.name ?? name),
296 current.name ?? name,
297 (item) => item.state,
298 );
299
300 const observedLabels = tagRecord(current.labels);
301 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
302 const mask = fieldMask([
303 (upsert.length > 0 || removed.length > 0) && "labels",
304 (current.description ?? "") !== (news.description ?? "") &&
305 "description",
306 !sameStringList(current.hosts, news.hosts) && "hosts",
307 ]);
308
309 if (mask.length > 0) {
310 const operation = yield* netapp.patchProjectsLocationsHostGroups({
311 name: current.name ?? name,
312 updateMask: mask,
313 body: {
314 name: current.name ?? name,
315 labels: desiredLabels,
316 description: news.description,
317 hosts: news.hosts,
318 },
319 });
320 yield* waitForOperation(operation);
321 current = yield* waitUntilReady(
322 getByName(current.name ?? name),
323 current.name ?? name,
324 (item) => item.state,
325 );
326 }
327
328 return toAttrs(current, env.project, env.region);
329 }),
330
331 delete: Effect.fn(function* ({ output }) {
332 const operation = yield* netapp
333 .deleteProjectsLocationsHostGroups({ name: output.name })
334 .pipe(
335 Effect.retry({
336 while: (error) => error._tag === "Conflict",
337 times: 8,
338 schedule: Schedule.spaced("2 seconds"),
339 }),
340 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
341 );
342 if (operation !== undefined) {
343 yield* waitForDeleteOperation(operation);
344 }
345 yield* waitUntilGone(getByName(output.name), output.name);
346 }),
347 });