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 expandParent,
19 fieldMask,
20 listAtNested,
21 listLabeledPages,
22 normalizeLocation,
23 parseName,
24 replaceOnIdentity,
25 ResourceNotResolved,
26 toPhysicalId,
27 userLabels,
28 waitForDeleteOperation,
29 waitForOperation,
30 waitUntilExists,
31 waitUntilGone,
32 waitUntilReady,
33} from "./internal.ts";
34
35const DEFAULT_TYPE: netapp.QuotaRuleTypeEnum = "DEFAULT_USER_QUOTA";
36const DEFAULT_DISK_LIMIT_MIB = 1024;
37
38export type VolumesQuotaRuleProps = {
39 /**
40 * Parent volume. Full name
41 * `projects/{project}/locations/{location}/volumes/{volume}` or the
42 * volume id (combined with `location`). Immutable — changing it
43 * replaces the rule.
44 */
45 volume: string;
46 /**
47 * Region used when `volume` is a bare id.
48 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
49 */
50 location?: string;
51 /**
52 * Quota rule id (the `{quotaRule}` segment). If omitted, a unique
53 * RFC1035 name is generated from the stack, stage, and logical id.
54 * Immutable — changing it replaces the rule.
55 */
56 quotaRuleId?: string;
57 /**
58 * Quota type. Immutable — changing it replaces the rule.
59 * @default "DEFAULT_USER_QUOTA"
60 */
61 type?: netapp.QuotaRuleTypeEnum | (string & {});
62 /**
63 * Maximum disk space in MiB.
64 * @default 1024
65 */
66 diskLimitMib?: number;
67 /**
68 * Unix UID/GID, Windows SID, or omitted for a default quota.
69 * Immutable — changing it replaces the rule.
70 */
71 target?: string;
72 /**
73 * Human-readable description.
74 */
75 description?: string;
76 /**
77 * User labels. Alchemy ownership labels are merged in automatically.
78 */
79 labels?: Record<string, string>;
80};
81
82export type VolumesQuotaRule = Resource<
83 "GCP.NetApp.VolumesQuotaRule",
84 VolumesQuotaRuleProps,
85 {
86 /** Full resource name. */
87 name: string;
88 /** Quota rule id (last path segment). */
89 quotaRuleId: string;
90 /** Parent volume name. */
91 volume: string;
92 /** Project id. */
93 project: string;
94 /** Location id. */
95 location: string;
96 /** Quota type. */
97 type: string | undefined;
98 /** Disk limit in MiB. */
99 diskLimitMib: number | undefined;
100 /** Target user or group. */
101 target: string | undefined;
102 /** Human-readable description. */
103 description: string | undefined;
104 /** User labels (Alchemy ownership labels stripped). */
105 labels: Record<string, string>;
106 /** Server-reported state. */
107 state: string | undefined;
108 /** State details. */
109 stateDetails: string | undefined;
110 /** RFC3339 creation timestamp. */
111 createTime: string | undefined;
112 },
113 never,
114 Providers
115>;
116
117/**
118 * A Cloud NetApp Volumes quota rule that caps disk usage for a user or
119 * group on a volume.
120 *
121 * Changing `quotaRuleId`, `volume`, `location`, `type`, or `target`
122 * replaces the rule. Disk limit, description, and labels update in place.
123 *
124 * ### Creating a Quota Rule
125 * **Example:** Default user quota
126 * ```typescript
127 * const rule = yield* GCP.NetApp.VolumesQuotaRule("DefaultUser", {
128 * volume: volume.name,
129 * type: "DEFAULT_USER_QUOTA",
130 * diskLimitMib: 1024,
131 * });
132 * ```
133 *
134 * **Example:** Individual user quota
135 * ```typescript
136 * const rule = yield* GCP.NetApp.VolumesQuotaRule("Alice", {
137 * volume: volume.name,
138 * type: "INDIVIDUAL_USER_QUOTA",
139 * target: "1001",
140 * diskLimitMib: 2048,
141 * labels: { env: "prod" },
142 * });
143 * ```
144 *
145 * ### Updating a Quota Rule
146 * **Example:** Disk limit and labels
147 * ```typescript
148 * const rule = yield* GCP.NetApp.VolumesQuotaRule("DefaultUser", {
149 * volume: volume.name,
150 * diskLimitMib: 4096,
151 * description: "default user v2",
152 * labels: { env: "prod", team: "storage" },
153 * });
154 * ```
155 *
156 * @resource
157 * @category NetApp
158 */
159export const VolumesQuotaRule = Resource<VolumesQuotaRule>(
160 "GCP.NetApp.VolumesQuotaRule",
161);
162
163const resourceName = (volume: string, quotaRuleId: string) =>
164 `${volume}/quotaRules/${quotaRuleId}`;
165
166const toAttrs = (item: netapp.QuotaRule, project: string, region: string) => {
167 const name = item.name ?? "";
168 const parsed = parseName(name, "quotaRules", region);
169 return {
170 name,
171 quotaRuleId: parsed.id,
172 volume: parsed.parent,
173 project: parsed.project || project,
174 location: parsed.location,
175 type: item.type,
176 diskLimitMib: item.diskLimitMib,
177 target: item.target,
178 description: item.description,
179 labels: userLabels(item.labels),
180 state: item.state,
181 stateDetails: item.stateDetails,
182 createTime: item.createTime,
183 };
184};
185
186const getByName = (name: string) =>
187 netapp
188 .getProjectsLocationsVolumesQuotaRules({ name })
189 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
190
191const listOwned = (project: string) =>
192 listAtNested(project, "volumes/-", (parent) =>
193 listLabeledPages(
194 netapp.listProjectsLocationsVolumesQuotaRules.pages({
195 parent,
196 pageSize: 1000,
197 }),
198 (page) => page.quotaRules,
199 (item) => item.labels,
200 ),
201 );
202
203export const VolumesQuotaRuleProvider = () =>
204 Provider.succeed(VolumesQuotaRule, {
205 stables: [
206 "name",
207 "quotaRuleId",
208 "volume",
209 "project",
210 "location",
211 "createTime",
212 ],
213
214 diff: Effect.fn(function* ({ news, olds, output }) {
215 if (!isResolved(news)) return undefined;
216 const env = yield* GcpEnvironment.current;
217 const previousType = olds?.type ?? output?.type;
218 const previousTarget = olds?.target ?? output?.target;
219 const nextType = news.type ?? DEFAULT_TYPE;
220 return replaceOnIdentity({
221 previousId: olds?.quotaRuleId ?? output?.quotaRuleId,
222 nextId: news.quotaRuleId ?? olds?.quotaRuleId ?? output?.quotaRuleId,
223 previousLocation: normalizeLocation(
224 olds?.location ?? output?.location,
225 env.region,
226 ),
227 nextLocation: normalizeLocation(
228 news.location ?? olds?.location ?? output?.location,
229 env.region,
230 ),
231 previousParent: olds?.volume ?? output?.volume,
232 nextParent: news.volume,
233 extra:
234 (previousType !== undefined && nextType !== previousType) ||
235 (previousTarget ?? "") !== (news.target ?? previousTarget ?? ""),
236 });
237 }),
238
239 read: Effect.fn(function* ({ id, olds, output }) {
240 const env = yield* GcpEnvironment.current;
241 const quotaRuleId = yield* toPhysicalId(
242 id,
243 olds?.quotaRuleId,
244 output?.quotaRuleId,
245 "quotarule",
246 );
247 const location = normalizeLocation(
248 olds?.location ?? output?.location,
249 env.region,
250 );
251 const parent = olds?.volume ?? output?.volume;
252 // A row whose parent never resolved (failed create) has nothing to read.
253 if (output?.name === undefined && !parent) return undefined;
254 const volume = expandParent(
255 parent ?? "",
256 env.project,
257 location,
258 "volumes",
259 );
260 const name = output?.name ?? resourceName(volume, quotaRuleId);
261 const existing = yield* getByName(name);
262 if (existing === undefined) return undefined;
263 const attrs = toAttrs(existing, env.project, env.region);
264 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
265 ? attrs
266 : Unowned(attrs);
267 }),
268
269 list: () =>
270 Effect.gen(function* () {
271 const env = yield* GcpEnvironment.current;
272 const items = yield* listOwned(env.project);
273 return items.map((item) => toAttrs(item, env.project, env.region));
274 }),
275
276 reconcile: Effect.fn(function* ({ id, news, output }) {
277 const env = yield* GcpEnvironment.current;
278 const quotaRuleId = yield* toPhysicalId(
279 id,
280 news.quotaRuleId,
281 output?.quotaRuleId,
282 "quotarule",
283 );
284 const location = normalizeLocation(
285 news.location ?? output?.location,
286 env.region,
287 );
288 const volume = expandParent(
289 news.volume,
290 env.project,
291 location,
292 "volumes",
293 );
294 const name = resourceName(volume, quotaRuleId);
295 const type = news.type ?? DEFAULT_TYPE;
296 const diskLimitMib = news.diskLimitMib ?? DEFAULT_DISK_LIMIT_MIB;
297 const desiredLabels = {
298 ...toLabels(news.labels),
299 ...(yield* createInternalLabels(id)),
300 };
301
302 let current = yield* getByName(output?.name ?? name);
303
304 if (current === undefined) {
305 const created = yield* netapp
306 .createProjectsLocationsVolumesQuotaRules({
307 parent: volume,
308 quotaRuleId,
309 body: {
310 type,
311 diskLimitMib,
312 target: news.target,
313 description: news.description,
314 labels: desiredLabels,
315 },
316 })
317 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
318 if (created !== undefined) {
319 yield* waitForOperation(created);
320 }
321 current = yield* waitUntilExists(getByName(name), name);
322 }
323
324 if (current === undefined) {
325 return yield* new ResourceNotResolved({ name });
326 }
327
328 current = yield* waitUntilReady(
329 getByName(current.name ?? name),
330 current.name ?? name,
331 (item) => item.state,
332 (item) => item.stateDetails,
333 );
334
335 const observedLabels = tagRecord(current.labels);
336 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
337 const mask = fieldMask([
338 (upsert.length > 0 || removed.length > 0) && "labels",
339 (current.description ?? "") !== (news.description ?? "") &&
340 "description",
341 (current.diskLimitMib ?? DEFAULT_DISK_LIMIT_MIB) !== diskLimitMib &&
342 "diskLimitMib",
343 ]);
344
345 if (mask.length > 0) {
346 const operation = yield* netapp.patchProjectsLocationsVolumesQuotaRules(
347 {
348 name: current.name ?? name,
349 updateMask: mask,
350 body: {
351 name: current.name ?? name,
352 labels: desiredLabels,
353 description: news.description,
354 diskLimitMib,
355 },
356 },
357 );
358 yield* waitForOperation(operation);
359 current = yield* waitUntilReady(
360 getByName(current.name ?? name),
361 current.name ?? name,
362 (item) => item.state,
363 (item) => item.stateDetails,
364 );
365 }
366
367 return toAttrs(current, env.project, env.region);
368 }),
369
370 delete: Effect.fn(function* ({ output }) {
371 const operation = yield* netapp
372 .deleteProjectsLocationsVolumesQuotaRules({ name: output.name })
373 .pipe(
374 Effect.retry({
375 while: (error) => error._tag === "Conflict",
376 times: 8,
377 schedule: Schedule.spaced("2 seconds"),
378 }),
379 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
380 );
381 if (operation !== undefined) {
382 yield* waitForDeleteOperation(operation);
383 }
384 yield* waitUntilGone(getByName(output.name), output.name);
385 }),
386 });