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 { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { tagRecord } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 createInternalLabels,
14 diffLabels,
15 hasAlchemyLabels,
16 stripInternalLabels,
17 toLabels,
18} from "../Labels.ts";
19import {
20 PROJECT_DISPLAY_MAX,
21 PROJECT_DISPLAY_MIN,
22 PROJECT_ID_MAX,
23 PROJECT_ID_MIN,
24 collectPages,
25 isDeleteRequested,
26 lastSegment,
27 projectIdFromOperation,
28 resolveParent,
29 resourceNameFromOperation,
30 sameHierarchyParent,
31 waitForOperation,
32 waitForCreate,
33} from "./internal.ts";
34
35export type ProjectProps = {
36 /**
37 * Globally unique project id. 6-30 lowercase letters, digits, or
38 * hyphens; must start with a letter and must not end with a hyphen.
39 * If omitted, a unique id is generated from the stack, stage, and
40 * logical id. Immutable — changing it replaces the project.
41 */
42 projectId?: string;
43 /**
44 * Parent `organizations/{org}` or `folders/{folder}`. Defaults to the
45 * current project's parent. Changing parent moves the project.
46 */
47 parent?: string;
48 /**
49 * User-assigned display name (4-30 characters). Defaults to the
50 * project id when omitted.
51 */
52 displayName?: string;
53 /**
54 * User labels. Alchemy ownership labels (`alchemy-stack`,
55 * `alchemy-stage`, `alchemy-id`) are merged in automatically.
56 */
57 labels?: Record<string, string>;
58};
59
60export type Project = Resource<
61 "GCP.ResourceManager.Project",
62 ProjectProps,
63 {
64 /** Resource name `projects/{project_number}`. */
65 name: string;
66 /** User-assigned project id. */
67 projectId: string;
68 /** Parent `organizations/{org}` or `folders/{folder}`. */
69 parent: string;
70 /** User-assigned display name. */
71 displayName: string | undefined;
72 /** User labels (Alchemy ownership labels stripped). */
73 labels: Record<string, string>;
74 /** Lifecycle state (`ACTIVE` or `DELETE_REQUESTED`). */
75 state: string | undefined;
76 /** Server etag for optimistic concurrency. */
77 etag: string | undefined;
78 /** RFC3339 creation timestamp. */
79 createTime: string | undefined;
80 /** RFC3339 last-update timestamp. */
81 updateTime: string | undefined;
82 /** RFC3339 time a delete was requested, if any. */
83 deleteTime: string | undefined;
84 /** Configured capabilities when this is a management project. */
85 configuredCapabilities: string[];
86 },
87 never,
88 Providers
89>;
90
91/**
92 * A Google Cloud project — the container for APIs, IAM, billing, and
93 * other resources.
94 *
95 * `projectId` is globally unique and immutable; changing it replaces
96 * the project. `parent` is moved in place. `displayName` and `labels`
97 * update in place. Delete is a 30-day soft-delete (`DELETE_REQUESTED`).
98 * Alchemy ownership labels are applied so `list` / `pnpm nuke:gcp` can
99 * find leftover projects.
100 *
101 * ### Creating a Project
102 * **Example:** Generated project id under the current project's parent
103 * ```typescript
104 * const project = yield* GCP.ResourceManager.Project("Sandbox", {});
105 * ```
106 *
107 * **Example:** Explicit id, parent, and labels
108 * ```typescript
109 * const project = yield* GCP.ResourceManager.Project("Sandbox", {
110 * projectId: "my-app-sandbox",
111 * parent: "folders/123456789",
112 * displayName: "Sandbox",
113 * labels: { env: "test" },
114 * });
115 * ```
116 *
117 * ### Updating a Project
118 * **Example:** Change display name and labels
119 * ```typescript
120 * const project = yield* GCP.ResourceManager.Project("Sandbox", {
121 * projectId: "my-app-sandbox",
122 * displayName: "Sandbox prod",
123 * labels: { env: "prod" },
124 * });
125 * ```
126 *
127 * @resource
128 * @category ResourceManager
129 */
130export const Project = Resource<Project>("GCP.ResourceManager.Project");
131
132export class ProjectNotResolved extends Data.TaggedError(
133 "GCP.ResourceManager.ProjectNotResolved",
134)<{
135 name: string;
136}> {}
137
138export class ProjectStillExists extends Data.TaggedError(
139 "GCP.ResourceManager.ProjectStillExists",
140)<{
141 name: string;
142}> {}
143
144const userLabels = (
145 labels: Record<string, string | undefined> | null | undefined,
146): Record<string, string> => stripInternalLabels(tagRecord(labels));
147
148const sanitizeProjectId = (value: string) => {
149 let next = value
150 .toLowerCase()
151 .replace(/[^a-z0-9-]/g, "-")
152 .replace(/-+/g, "-");
153 if (!/^[a-z]/.test(next)) next = `p${next}`;
154 next = next.replace(/-+$/g, "");
155 if (next.length > PROJECT_ID_MAX) {
156 next = next.slice(0, PROJECT_ID_MAX).replace(/-+$/g, "");
157 }
158 if (next.length < PROJECT_ID_MIN) {
159 next = `${next}xxxxxx`.slice(0, PROJECT_ID_MIN);
160 }
161 return next;
162};
163
164const toProjectId = (
165 id: string,
166 projectId: string | undefined,
167 existing?: string,
168) =>
169 Effect.gen(function* () {
170 if (projectId !== undefined) return sanitizeProjectId(projectId);
171 if (existing !== undefined) return existing;
172 const generated = yield* createPhysicalName({
173 id,
174 maxLength: PROJECT_ID_MAX,
175 lowercase: true,
176 });
177 return sanitizeProjectId(generated);
178 });
179
180const sanitizeDisplayName = (value: string | undefined, fallback: string) => {
181 const source = (value ?? fallback).trim();
182 let next = source
183 .replace(/[^a-zA-Z0-9 '"\-\!]/g, " ")
184 .replace(/\s+/g, " ")
185 .trim();
186 if (next.length === 0) next = fallback;
187 if (next.length > PROJECT_DISPLAY_MAX)
188 next = next.slice(0, PROJECT_DISPLAY_MAX);
189 if (next.length < PROJECT_DISPLAY_MIN) {
190 next = `${next} proj`.slice(0, PROJECT_DISPLAY_MIN);
191 }
192 return next;
193};
194
195const toAttrs = (project: resourcemanager.Project) => ({
196 name: project.name ?? "",
197 projectId: project.projectId ?? lastSegment(project.name ?? ""),
198 parent: project.parent ?? "",
199 displayName: project.displayName,
200 labels: userLabels(project.labels),
201 state: project.state,
202 etag: project.etag,
203 createTime: project.createTime,
204 updateTime: project.updateTime,
205 deleteTime: project.deleteTime,
206 configuredCapabilities: project.configuredCapabilities ?? [],
207});
208
209const getByName = (name: string) =>
210 resourcemanager
211 .getProjects({ name })
212 .pipe(
213 Effect.catchTag(["NotFound", "ProjectNotFound"], () =>
214 Effect.succeed(undefined),
215 ),
216 );
217
218const observe = (resourceName: string | undefined, projectId: string) =>
219 Effect.gen(function* () {
220 if (resourceName !== undefined && resourceName.length > 0) {
221 const byName = yield* getByName(resourceName);
222 if (byName !== undefined) return byName;
223 }
224 return yield* getByName(`projects/${projectId}`);
225 });
226
227const waitUntilExists = (resourceName: string | undefined, projectId: string) =>
228 observe(resourceName, projectId).pipe(
229 Effect.filterOrFail(
230 (project): project is resourcemanager.Project => project !== undefined,
231 () =>
232 new ProjectNotResolved({
233 name: resourceName ?? `projects/${projectId}`,
234 }),
235 ),
236 Effect.retry({
237 while: (error) => error._tag === "GCP.ResourceManager.ProjectNotResolved",
238 times: 8,
239 schedule: Schedule.spaced("1 second"),
240 }),
241 );
242
243const waitUntilGone = (name: string) =>
244 getByName(name).pipe(
245 Effect.flatMap((project) =>
246 project === undefined || isDeleteRequested(project.state)
247 ? Effect.void
248 : Effect.fail(new ProjectStillExists({ name })),
249 ),
250 Effect.retry({
251 while: (error) => error._tag === "GCP.ResourceManager.ProjectStillExists",
252 times: 10,
253 schedule: Schedule.spaced("1 second"),
254 }),
255 );
256
257const ensureActive = (project: resourcemanager.Project) =>
258 Effect.gen(function* () {
259 if (!isDeleteRequested(project.state) || project.name === undefined) {
260 return project;
261 }
262 const operation = yield* resourcemanager.undeleteProjects({
263 name: project.name,
264 body: {},
265 });
266 yield* waitForOperation(operation);
267 return yield* waitUntilExists(
268 project.name,
269 project.projectId ?? lastSegment(project.name),
270 );
271 });
272
273export const ProjectProvider = () =>
274 Provider.succeed(Project, {
275 stables: ["name", "projectId", "createTime"],
276
277 diff: Effect.fn(function* ({ news, olds, output }) {
278 if (!isResolved(news)) return undefined;
279 const previousId = olds?.projectId ?? output?.projectId;
280 const nextId =
281 news.projectId !== undefined
282 ? sanitizeProjectId(news.projectId)
283 : previousId;
284 const idChanged =
285 previousId !== undefined &&
286 news.projectId !== undefined &&
287 nextId !== previousId;
288 if (!idChanged) return undefined;
289 return { action: "replace" as const, deleteFirst: false };
290 }),
291
292 read: Effect.fn(function* ({ id, olds, output }) {
293 const projectId = yield* toProjectId(
294 id,
295 olds?.projectId,
296 output?.projectId,
297 );
298 const existing = yield* observe(output?.name, projectId);
299 if (existing === undefined) return undefined;
300 const attrs = toAttrs(existing);
301 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
302 ? attrs
303 : Unowned(attrs);
304 }),
305
306 list: () =>
307 collectPages(
308 resourcemanager.searchProjects.pages({
309 query: "labels.alchemy-stack:*",
310 pageSize: 300,
311 }),
312 (page) => page.projects,
313 ).pipe(
314 Effect.map((projects) =>
315 projects
316 .filter((project) =>
317 Object.keys(project.labels ?? {}).some((key) =>
318 key.startsWith("alchemy-"),
319 ),
320 )
321 .map(toAttrs),
322 ),
323 Effect.catchTag("NotFound", () => Effect.succeed([])),
324 ),
325
326 reconcile: Effect.fn(function* ({ id, news, output }) {
327 const projectId = yield* toProjectId(
328 id,
329 news.projectId,
330 output?.projectId,
331 );
332 const parent = yield* resolveParent(news.parent, output?.parent);
333 const displayName = sanitizeDisplayName(news.displayName, projectId);
334 const desiredLabels = {
335 ...toLabels(news.labels),
336 ...(yield* createInternalLabels(id)),
337 };
338
339 let current = yield* observe(output?.name, projectId);
340
341 if (current === undefined) {
342 const created = yield* resourcemanager
343 .createProjects({
344 body: {
345 projectId,
346 parent,
347 displayName,
348 labels: desiredLabels,
349 },
350 })
351 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
352 if (created !== undefined) {
353 const settled = yield* waitForCreate(created);
354 current = yield* waitUntilExists(
355 resourceNameFromOperation(settled, "projects/") ??
356 resourceNameFromOperation(created, "projects/"),
357 projectIdFromOperation(settled) ??
358 projectIdFromOperation(created) ??
359 projectId,
360 );
361 } else {
362 current = yield* waitUntilExists(undefined, projectId);
363 }
364 }
365
366 if (current === undefined || current.name === undefined) {
367 return yield* new ProjectNotResolved({
368 name: `projects/${projectId}`,
369 });
370 }
371
372 const name = current.name;
373 current = yield* ensureActive(current);
374
375 const observedLabels = tagRecord(current.labels);
376 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
377 const labelsChanged = upsert.length > 0 || removed.length > 0;
378 const displayChanged = (current.displayName ?? "") !== displayName;
379
380 if (labelsChanged || displayChanged) {
381 const mask = [
382 labelsChanged ? "labels" : undefined,
383 displayChanged ? "displayName" : undefined,
384 ]
385 .filter((field): field is string => field !== undefined)
386 .join(",");
387 const operation = yield* resourcemanager.patchProjects({
388 name: current.name ?? name,
389 updateMask: mask,
390 body: {
391 name: current.name ?? name,
392 displayName,
393 labels: desiredLabels,
394 etag: current.etag,
395 },
396 });
397 yield* waitForOperation(operation);
398 current = yield* waitUntilExists(current.name ?? name, projectId);
399 }
400
401 if (
402 current.parent !== undefined &&
403 parent.length > 0 &&
404 !sameHierarchyParent(current.parent, parent)
405 ) {
406 const operation = yield* resourcemanager.moveProjects({
407 name: current.name ?? name,
408 body: { destinationParent: parent },
409 });
410 yield* waitForOperation(operation);
411 current = yield* waitUntilExists(current.name ?? name, projectId);
412 }
413
414 if (current === undefined) {
415 return yield* new ProjectNotResolved({
416 name: `projects/${projectId}`,
417 });
418 }
419
420 return toAttrs(current);
421 }),
422
423 delete: Effect.fn(function* ({ output }) {
424 const operation = yield* resourcemanager
425 .deleteProjects({ name: output.name })
426 .pipe(
427 Effect.retry({
428 while: (error) => error._tag === "Conflict",
429 times: 8,
430 schedule: Schedule.spaced("2 seconds"),
431 }),
432 Effect.catchTag(["NotFound", "ProjectNotFound"], () =>
433 Effect.succeed(undefined),
434 ),
435 );
436 if (operation !== undefined) {
437 yield* waitForOperation(operation, { notFoundOk: true });
438 }
439 yield* waitUntilGone(output.name);
440 }),
441 });