| 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 Stream from "effect/Stream"; |
| 5 | import { GcpEnvironment } from "../Environment.ts"; |
| 6 | import { waitForOperation as waitForGcpOperation } from "../Operation.ts"; |
| 7 | |
| 8 | export const FOLDER_DISPLAY_MAX = 30; |
| 9 | export const PROJECT_ID_MIN = 6; |
| 10 | export const PROJECT_ID_MAX = 30; |
| 11 | export const PROJECT_DISPLAY_MIN = 4; |
| 12 | export const PROJECT_DISPLAY_MAX = 30; |
| 13 | export const LIEN_REASON_MAX = 200; |
| 14 | export const LIEN_ORIGIN_MAX = 200; |
| 15 | export const DEFAULT_LIEN_ORIGIN = "alchemy.effect"; |
| 16 | export const DEFAULT_LIEN_RESTRICTIONS = [ |
| 17 | "resourcemanager.projects.delete", |
| 18 | ] as const; |
| 19 | |
| 20 | export class ParentRequired extends Data.TaggedError( |
| 21 | "GCP.ResourceManager.ParentRequired", |
| 22 | )<{ |
| 23 | project: string; |
| 24 | }> {} |
| 25 | |
| 26 | export const lastSegment = (value: string) => { |
| 27 | const trimmed = value.replace(/\/+$/, ""); |
| 28 | const parts = trimmed.split("/"); |
| 29 | return parts[parts.length - 1] || trimmed; |
| 30 | }; |
| 31 | |
| 32 | export const organizationParent = (value: string) => |
| 33 | value.startsWith("organizations/") |
| 34 | ? value |
| 35 | : `organizations/${lastSegment(value)}`; |
| 36 | |
| 37 | export const folderParent = (value: string) => |
| 38 | value.startsWith("folders/") ? value : `folders/${lastSegment(value)}`; |
| 39 | |
| 40 | export const projectParent = (value: string) => |
| 41 | value.startsWith("projects/") ? value : `projects/${lastSegment(value)}`; |
| 42 | |
| 43 | export const normalizeHierarchyParent = (value: string) => { |
| 44 | if ( |
| 45 | value.startsWith("organizations/") || |
| 46 | value.startsWith("folders/") || |
| 47 | value.startsWith("projects/") |
| 48 | ) { |
| 49 | return value; |
| 50 | } |
| 51 | if (value.startsWith("orgs/")) { |
| 52 | return `organizations/${value.slice("orgs/".length)}`; |
| 53 | } |
| 54 | return folderParent(value); |
| 55 | }; |
| 56 | |
| 57 | export const sameHierarchyParent = ( |
| 58 | left: string | undefined, |
| 59 | right: string | undefined, |
| 60 | ) => { |
| 61 | if (left === undefined || right === undefined) return left === right; |
| 62 | if (left === right) return true; |
| 63 | const leftKind = left.split("/")[0]; |
| 64 | const rightKind = right.split("/")[0]; |
| 65 | return leftKind === rightKind && lastSegment(left) === lastSegment(right); |
| 66 | }; |
| 67 | |
| 68 | export const isDeleteRequested = (state: string | undefined) => |
| 69 | state === "DELETE_REQUESTED"; |
| 70 | |
| 71 | export const resourceNameFromOperation = ( |
| 72 | operation: resourcemanager.Operation, |
| 73 | prefix: string, |
| 74 | ): string | undefined => { |
| 75 | const name = operation.response?.name; |
| 76 | return typeof name === "string" && name.startsWith(prefix) ? name : undefined; |
| 77 | }; |
| 78 | |
| 79 | export const projectIdFromOperation = ( |
| 80 | operation: resourcemanager.Operation, |
| 81 | ): string | undefined => { |
| 82 | const projectId = operation.response?.projectId; |
| 83 | return typeof projectId === "string" && projectId.length > 0 |
| 84 | ? projectId |
| 85 | : undefined; |
| 86 | }; |
| 87 | |
| 88 | const parentOf = (name: string) => |
| 89 | name.startsWith("projects/") |
| 90 | ? resourcemanager.getProjects({ name }).pipe( |
| 91 | Effect.map((resource) => resource.parent), |
| 92 | Effect.catchTag(["NotFound", "Forbidden", "ProjectNotFound"], () => |
| 93 | Effect.succeed(undefined), |
| 94 | ), |
| 95 | ) |
| 96 | : name.startsWith("folders/") |
| 97 | ? resourcemanager.getFolders({ name }).pipe( |
| 98 | Effect.map((folder) => folder.parent), |
| 99 | Effect.catchTag(["NotFound", "Forbidden", "FolderNotFound"], () => |
| 100 | Effect.succeed(undefined), |
| 101 | ), |
| 102 | ) |
| 103 | : Effect.succeed(undefined); |
| 104 | |
| 105 | export const tryResolveParent = () => |
| 106 | Effect.gen(function* () { |
| 107 | const env = yield* GcpEnvironment.current; |
| 108 | return yield* parentOf(`projects/${env.project}`).pipe( |
| 109 | Effect.map((parent) => { |
| 110 | if (parent === undefined || parent.length === 0) return undefined; |
| 111 | if ( |
| 112 | parent.startsWith("folders/") || |
| 113 | parent.startsWith("organizations/") |
| 114 | ) { |
| 115 | return parent; |
| 116 | } |
| 117 | return undefined; |
| 118 | }), |
| 119 | ); |
| 120 | }); |
| 121 | |
| 122 | export const resolveParent = ( |
| 123 | explicit: string | undefined, |
| 124 | existing: string | undefined, |
| 125 | ) => |
| 126 | Effect.gen(function* () { |
| 127 | if (explicit !== undefined && explicit.length > 0) { |
| 128 | return normalizeHierarchyParent(explicit); |
| 129 | } |
| 130 | if (existing !== undefined && existing.length > 0) { |
| 131 | return normalizeHierarchyParent(existing); |
| 132 | } |
| 133 | const resolved = yield* tryResolveParent(); |
| 134 | if (resolved === undefined) { |
| 135 | const env = yield* GcpEnvironment.current; |
| 136 | return yield* new ParentRequired({ project: env.project }); |
| 137 | } |
| 138 | return resolved; |
| 139 | }); |
| 140 | |
| 141 | export const projectNumberOf = (project: string) => |
| 142 | resourcemanager.getProjects({ name: `projects/${project}` }).pipe( |
| 143 | Effect.map((resource) => { |
| 144 | const number = lastSegment(resource.name ?? ""); |
| 145 | return /^\d+$/.test(number) ? number : project; |
| 146 | }), |
| 147 | Effect.catchTag("NotFound", () => Effect.succeed(project)), |
| 148 | ); |
| 149 | |
| 150 | export const sortedStrings = (values: readonly string[] | undefined) => |
| 151 | [...(values ?? [])].slice().sort(); |
| 152 | |
| 153 | export const sameStringList = ( |
| 154 | left: readonly string[] | undefined, |
| 155 | right: readonly string[] | undefined, |
| 156 | ) => |
| 157 | JSON.stringify(sortedStrings(left)) === JSON.stringify(sortedStrings(right)); |
| 158 | |
| 159 | export const collectPages = <Page, A, E, R>( |
| 160 | pages: Stream.Stream<Page, E, R>, |
| 161 | items: (page: Page) => readonly A[] | undefined, |
| 162 | ) => |
| 163 | pages.pipe( |
| 164 | Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])), |
| 165 | Stream.runCollect, |
| 166 | Effect.map((chunk) => Array.from(chunk)), |
| 167 | ); |
| 168 | |
| 169 | /** |
| 170 | * Wait for a Resource Manager operation; project and folder operations |
| 171 | * settle within a minute or two. With `allowAlreadyExists`, ALREADY_EXISTS |
| 172 | * (code 6) counts as success (create race); with `notFoundOk`, so does |
| 173 | * NOT_FOUND (code 5, delete race) and an operation that is already gone. |
| 174 | */ |
| 175 | export const waitForOperation = ( |
| 176 | operation: resourcemanager.Operation, |
| 177 | options?: { notFoundOk?: boolean; allowAlreadyExists?: boolean }, |
| 178 | ) => |
| 179 | waitForGcpOperation( |
| 180 | operation, |
| 181 | (name) => resourcemanager.getOperations({ name }), |
| 182 | { budget: "10 minutes" }, |
| 183 | ).pipe( |
| 184 | Effect.catchIf( |
| 185 | (error) => |
| 186 | (error._tag === "GCP.OperationFailed" && |
| 187 | ((options?.allowAlreadyExists === true && error.code === 6) || |
| 188 | (options?.notFoundOk === true && error.code === 5))) || |
| 189 | (options?.notFoundOk === true && error._tag === "NotFound"), |
| 190 | () => Effect.succeed(operation), |
| 191 | ), |
| 192 | ); |
| 193 | |
| 194 | /** |
| 195 | * Wait for a create operation, then re-read it for its typed `response` |
| 196 | * (the created resource's name / project id). |
| 197 | */ |
| 198 | export const waitForCreate = (operation: resourcemanager.Operation) => |
| 199 | waitForOperation(operation, { allowAlreadyExists: true }).pipe( |
| 200 | Effect.andThen(() => |
| 201 | operation.name === undefined || operation.name.length === 0 |
| 202 | ? Effect.succeed(operation) |
| 203 | : resourcemanager.getOperations({ name: operation.name }), |
| 204 | ), |
| 205 | ); |