| 1 | import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 8 | import * as Provider from "../../Provider.ts"; |
| 9 | import { Resource } from "../../Resource.ts"; |
| 10 | import { tagRecord } from "../../Tags.ts"; |
| 11 | import type { Providers } from "../Providers.ts"; |
| 12 | import { |
| 13 | createInternalLabels, |
| 14 | diffLabels, |
| 15 | hasAlchemyLabels, |
| 16 | stripInternalLabels, |
| 17 | toLabels, |
| 18 | } from "../Labels.ts"; |
| 19 | import { |
| 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 | |
| 35 | export 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 | |
| 60 | export 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 | */ |
| 130 | export const Project = Resource<Project>("GCP.ResourceManager.Project"); |
| 131 | |
| 132 | export class ProjectNotResolved extends Data.TaggedError( |
| 133 | "GCP.ResourceManager.ProjectNotResolved", |
| 134 | )<{ |
| 135 | name: string; |
| 136 | }> {} |
| 137 | |
| 138 | export class ProjectStillExists extends Data.TaggedError( |
| 139 | "GCP.ResourceManager.ProjectStillExists", |
| 140 | )<{ |
| 141 | name: string; |
| 142 | }> {} |
| 143 | |
| 144 | const userLabels = ( |
| 145 | labels: Record<string, string | undefined> | null | undefined, |
| 146 | ): Record<string, string> => stripInternalLabels(tagRecord(labels)); |
| 147 | |
| 148 | const 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 | |
| 164 | const 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 | |
| 180 | const 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 | |
| 195 | const 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 | |
| 209 | const getByName = (name: string) => |
| 210 | resourcemanager |
| 211 | .getProjects({ name }) |
| 212 | .pipe( |
| 213 | Effect.catchTag(["NotFound", "ProjectNotFound"], () => |
| 214 | Effect.succeed(undefined), |
| 215 | ), |
| 216 | ); |
| 217 | |
| 218 | const 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 | |
| 227 | const 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 | |
| 243 | const 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 | |
| 257 | const 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 | |
| 273 | export 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 | }); |