1import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 FOLDER_DISPLAY_MAX,
12 collectPages,
13 isDeleteRequested,
14 resolveParent,
15 resourceNameFromOperation,
16 sameHierarchyParent,
17 waitForOperation,
18 waitForCreate,
19} from "./internal.ts";
20
21export type FolderProps = {
22 /**
23 * User-visible display name. Unique among sibling folders. 1-30
24 * characters, starting and ending with a letter or digit. If omitted,
25 * a unique name is generated from the stack, stage, and logical id.
26 */
27 displayName?: string;
28 /**
29 * Parent `organizations/{org}` or `folders/{folder}`. Defaults to the
30 * current project's parent. Changing parent moves the folder.
31 */
32 parent?: string;
33};
34
35export type Folder = Resource<
36 "GCP.ResourceManager.Folder",
37 FolderProps,
38 {
39 /** Resource name `folders/{folder_id}`. */
40 name: string;
41 /** User-visible display name as stored in Cloud Resource Manager. */
42 displayName: string;
43 /** Parent `organizations/{org}` or `folders/{folder}`. */
44 parent: string;
45 /** Lifecycle state (`ACTIVE` or `DELETE_REQUESTED`). */
46 state: string | undefined;
47 /** Server etag for optimistic concurrency. */
48 etag: string | undefined;
49 /** RFC3339 creation timestamp. */
50 createTime: string | undefined;
51 /** RFC3339 last-update timestamp. */
52 updateTime: string | undefined;
53 /** RFC3339 time a delete was requested, if any. */
54 deleteTime: string | undefined;
55 /** Configured folder capabilities, if any. */
56 configuredCapabilities: string[];
57 /** Management project when app-management is enabled. */
58 managementProject: string | undefined;
59 },
60 never,
61 Providers
62>;
63
64/**
65 * A Cloud Resource Manager folder — a node in an organization's resource
66 * hierarchy used to group projects and other folders.
67 *
68 * Folders have no labels, so a folder found without prior state is
69 * reported as unowned and only taken over with `--adopt`. `displayName`
70 * updates in place; changing `parent` moves the folder. Delete is a 30-day
71 * soft-delete (`DELETE_REQUESTED`).
72 *
73 * ### Creating a Folder
74 * **Example:** Generated display name under the current project's parent
75 * ```typescript
76 * const folder = yield* GCP.ResourceManager.Folder("Team", {});
77 * ```
78 *
79 * **Example:** Explicit display name and parent
80 * ```typescript
81 * const folder = yield* GCP.ResourceManager.Folder("Team", {
82 * displayName: "platform",
83 * parent: "organizations/123456789",
84 * });
85 * ```
86 *
87 * ### Updating a Folder
88 * **Example:** Rename
89 * ```typescript
90 * const folder = yield* GCP.ResourceManager.Folder("Team", {
91 * displayName: "platform-prod",
92 * });
93 * ```
94 *
95 * @resource
96 * @category ResourceManager
97 */
98export const Folder = Resource<Folder>("GCP.ResourceManager.Folder");
99
100export class FolderNotResolved extends Data.TaggedError(
101 "GCP.ResourceManager.FolderNotResolved",
102)<{
103 name: string;
104}> {}
105
106export class FolderStillExists extends Data.TaggedError(
107 "GCP.ResourceManager.FolderStillExists",
108)<{
109 name: string;
110}> {}
111
112const sanitizeDisplayName = (value: string) => {
113 let next = value
114 .replace(/[^a-zA-Z0-9_ \-]/g, "-")
115 .replace(/\s+/g, " ")
116 .trim();
117 if (next.length > FOLDER_DISPLAY_MAX) {
118 next = next.slice(0, FOLDER_DISPLAY_MAX);
119 }
120 if (!/^[a-zA-Z0-9]/.test(next))
121 next = `a${next}`.slice(0, FOLDER_DISPLAY_MAX);
122 next = next.replace(/[^a-zA-Z0-9]+$/g, "");
123 if (next.length === 0) next = "az0";
124 return next;
125};
126
127const toDisplayName = (
128 id: string,
129 displayName: string | undefined,
130 existing?: string,
131) =>
132 Effect.gen(function* () {
133 if (displayName !== undefined) return sanitizeDisplayName(displayName);
134 if (existing !== undefined) return existing;
135 const generated = yield* createPhysicalName({
136 id,
137 maxLength: FOLDER_DISPLAY_MAX,
138 lowercase: true,
139 suffixLength: 8,
140 });
141 return sanitizeDisplayName(generated);
142 });
143
144const toAttrs = (folder: resourcemanager.Folder) => ({
145 name: folder.name ?? "",
146 displayName: folder.displayName ?? "",
147 parent: folder.parent ?? "",
148 state: folder.state,
149 etag: folder.etag,
150 createTime: folder.createTime,
151 updateTime: folder.updateTime,
152 deleteTime: folder.deleteTime,
153 configuredCapabilities: folder.configuredCapabilities ?? [],
154 managementProject: folder.managementProject,
155});
156
157const getByName = (name: string) =>
158 resourcemanager
159 .getFolders({ name })
160 .pipe(
161 Effect.catchTag(["NotFound", "FolderNotFound"], () =>
162 Effect.succeed(undefined),
163 ),
164 );
165
166const listChildren = (parent: string, showDeleted = true) =>
167 collectPages(
168 resourcemanager.listFolders.pages({
169 parent,
170 pageSize: 300,
171 showDeleted,
172 }),
173 (page) => page.folders,
174 ).pipe(
175 Effect.catchTag("NotFound", () =>
176 Effect.succeed([] as resourcemanager.Folder[]),
177 ),
178 );
179
180const findByDisplayName = (
181 parent: string,
182 displayName: string,
183 resourceName?: string,
184) =>
185 Effect.gen(function* () {
186 if (resourceName !== undefined && resourceName.length > 0) {
187 const byName = yield* getByName(resourceName);
188 if (byName !== undefined) return byName;
189 }
190 const children = yield* listChildren(parent, true);
191 return children.find((folder) => folder.displayName === displayName);
192 });
193
194const observe = (
195 resourceName: string | undefined,
196 parent: string,
197 displayName: string,
198) =>
199 Effect.gen(function* () {
200 if (resourceName !== undefined && resourceName.length > 0) {
201 const byName = yield* getByName(resourceName);
202 if (byName !== undefined) return byName;
203 }
204 return yield* findByDisplayName(parent, displayName, resourceName);
205 });
206
207const waitUntilExists = (
208 resourceName: string | undefined,
209 parent: string,
210 displayName: string,
211) =>
212 observe(resourceName, parent, displayName).pipe(
213 Effect.filterOrFail(
214 (folder): folder is resourcemanager.Folder => folder !== undefined,
215 () =>
216 new FolderNotResolved({
217 name: resourceName ?? `${parent}/${displayName}`,
218 }),
219 ),
220 Effect.retry({
221 while: (error) => error._tag === "GCP.ResourceManager.FolderNotResolved",
222 times: 8,
223 schedule: Schedule.spaced("1 second"),
224 }),
225 );
226
227const waitUntilGone = (name: string) =>
228 getByName(name).pipe(
229 Effect.flatMap((folder) =>
230 folder === undefined || isDeleteRequested(folder.state)
231 ? Effect.void
232 : Effect.fail(new FolderStillExists({ name })),
233 ),
234 Effect.retry({
235 while: (error) => error._tag === "GCP.ResourceManager.FolderStillExists",
236 times: 10,
237 schedule: Schedule.spaced("1 second"),
238 }),
239 );
240
241const ensureActive = (folder: resourcemanager.Folder) =>
242 Effect.gen(function* () {
243 if (!isDeleteRequested(folder.state) || folder.name === undefined) {
244 return folder;
245 }
246 const operation = yield* resourcemanager.undeleteFolders({
247 name: folder.name,
248 body: {},
249 });
250 yield* waitForOperation(operation);
251 return (
252 (yield* waitUntilExists(
253 folder.name,
254 folder.parent ?? "",
255 folder.displayName ?? "",
256 )) ?? folder
257 );
258 });
259
260export const FolderProvider = () =>
261 Provider.succeed(Folder, {
262 stables: ["name", "createTime"],
263
264 read: Effect.fn(function* ({ id, olds, output }) {
265 const displayName = yield* toDisplayName(
266 id,
267 olds?.displayName,
268 output?.displayName,
269 );
270 const parent = yield* resolveParent(olds?.parent, output?.parent).pipe(
271 Effect.catchTag("GCP.ResourceManager.ParentRequired", () =>
272 Effect.succeed(output?.parent ?? ""),
273 ),
274 );
275 if (parent.length === 0 && output?.name === undefined) return undefined;
276 const existing = yield* observe(
277 output?.name,
278 parent || (output?.parent ?? ""),
279 displayName,
280 );
281 if (existing === undefined) return undefined;
282 const attrs = toAttrs(existing);
283 // No labels field: without prior state it may not be ours.
284 return output !== undefined ? attrs : Unowned(attrs);
285 }),
286
287 reconcile: Effect.fn(function* ({ id, news, output }) {
288 const displayName = yield* toDisplayName(
289 id,
290 news.displayName,
291 output?.displayName,
292 );
293 const parent = yield* resolveParent(news.parent, output?.parent);
294
295 let current = yield* observe(output?.name, parent, displayName);
296
297 if (current === undefined) {
298 const created = yield* resourcemanager
299 .createFolders({
300 body: {
301 parent,
302 displayName,
303 },
304 })
305 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
306 if (created !== undefined) {
307 const settled = yield* waitForCreate(created);
308 current = yield* waitUntilExists(
309 resourceNameFromOperation(settled, "folders/") ??
310 resourceNameFromOperation(created, "folders/"),
311 parent,
312 displayName,
313 );
314 } else {
315 current = yield* waitUntilExists(undefined, parent, displayName);
316 }
317 }
318
319 if (current === undefined || current.name === undefined) {
320 return yield* new FolderNotResolved({
321 name: output?.name ?? `${parent}/${displayName}`,
322 });
323 }
324
325 const name = current.name;
326 current = yield* ensureActive(current);
327
328 if ((current.displayName ?? "") !== displayName) {
329 const operation = yield* resourcemanager.patchFolders({
330 name: current.name ?? name,
331 updateMask: "displayName",
332 body: {
333 name: current.name ?? name,
334 displayName,
335 etag: current.etag,
336 },
337 });
338 yield* waitForOperation(operation);
339 current = yield* waitUntilExists(
340 current.name ?? name,
341 parent,
342 displayName,
343 );
344 }
345
346 if (
347 current.parent !== undefined &&
348 !sameHierarchyParent(current.parent, parent)
349 ) {
350 const operation = yield* resourcemanager.moveFolders({
351 name: current.name ?? name,
352 body: { destinationParent: parent },
353 });
354 yield* waitForOperation(operation);
355 current = yield* waitUntilExists(
356 current.name ?? name,
357 parent,
358 displayName,
359 );
360 }
361
362 if (current === undefined) {
363 return yield* new FolderNotResolved({
364 name: output?.name ?? `${parent}/${displayName}`,
365 });
366 }
367
368 return toAttrs(current);
369 }),
370
371 delete: Effect.fn(function* ({ output }) {
372 const operation = yield* resourcemanager
373 .deleteFolders({ name: output.name })
374 .pipe(
375 Effect.retry({
376 while: (error) => error._tag === "Conflict",
377 times: 8,
378 schedule: Schedule.spaced("2 seconds"),
379 }),
380 Effect.catchTag(["NotFound", "FolderNotFound"], () =>
381 Effect.succeed(undefined),
382 ),
383 );
384 if (operation !== undefined) {
385 yield* waitForOperation(operation, { notFoundOk: true });
386 }
387 yield* waitUntilGone(output.name);
388 }),
389 });