| 1 | import * as deploymentmanager from "@distilled.cloud/gcp/deploymentmanager_v2"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import type { GcpOpContext } from "@distilled.cloud/gcp/Protocol"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Schedule from "effect/Schedule"; |
| 6 | import * as Stream from "effect/Stream"; |
| 7 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 8 | import { tagRecord } from "../../Tags.ts"; |
| 9 | import { stripInternalLabels } from "../Labels.ts"; |
| 10 | import { waitForOperation as waitForLongRunningOperation } from "../Operation.ts"; |
| 11 | |
| 12 | export const MAX_NAME_LENGTH = 63; |
| 13 | |
| 14 | export class ResourceNotResolved extends Data.TaggedError( |
| 15 | "GCP.DeploymentManager.ResourceNotResolved", |
| 16 | )<{ |
| 17 | name: string; |
| 18 | }> {} |
| 19 | |
| 20 | export class ResourceStillExists extends Data.TaggedError( |
| 21 | "GCP.DeploymentManager.ResourceStillExists", |
| 22 | )<{ |
| 23 | name: 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 rfc1035 = (name: string, fallback = "deployment"): string => { |
| 33 | let next = name |
| 34 | .toLowerCase() |
| 35 | .replace(/[^a-z0-9-]/g, "-") |
| 36 | .replace(/-+/g, "-") |
| 37 | .replace(/^-+|-+$/g, ""); |
| 38 | if (!/^[a-z]/.test(next)) next = `d${next}`; |
| 39 | next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, ""); |
| 40 | if (next.length === 0) return fallback; |
| 41 | if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`; |
| 42 | return next.slice(0, MAX_NAME_LENGTH); |
| 43 | }; |
| 44 | |
| 45 | export const toPhysicalId = ( |
| 46 | id: string, |
| 47 | explicit: string | undefined, |
| 48 | existing: string | undefined, |
| 49 | ) => |
| 50 | Effect.gen(function* () { |
| 51 | if (explicit !== undefined) return rfc1035(explicit); |
| 52 | if (existing !== undefined) return existing; |
| 53 | return rfc1035( |
| 54 | yield* createPhysicalName({ |
| 55 | id, |
| 56 | maxLength: MAX_NAME_LENGTH, |
| 57 | lowercase: true, |
| 58 | }), |
| 59 | ); |
| 60 | }); |
| 61 | |
| 62 | export const userLabels = ( |
| 63 | labels: Record<string, string | undefined> | null | undefined, |
| 64 | ): Record<string, string> => stripInternalLabels(tagRecord(labels)); |
| 65 | |
| 66 | export const labelsToRecord = ( |
| 67 | labels: readonly deploymentmanager.DeploymentLabelEntry[] | undefined, |
| 68 | ): Record<string, string> => |
| 69 | Object.fromEntries( |
| 70 | (labels ?? []) |
| 71 | .filter( |
| 72 | (entry): entry is { key: string; value: string } => |
| 73 | typeof entry.key === "string" && |
| 74 | entry.key.length > 0 && |
| 75 | typeof entry.value === "string", |
| 76 | ) |
| 77 | .map((entry) => [entry.key, entry.value]), |
| 78 | ); |
| 79 | |
| 80 | export const recordToLabels = ( |
| 81 | labels: Record<string, string>, |
| 82 | ): deploymentmanager.DeploymentLabelEntry[] => |
| 83 | Object.entries(labels).map(([key, value]) => ({ key, value })); |
| 84 | |
| 85 | export const sameText = (left: string | undefined, right: string | undefined) => |
| 86 | (left ?? "") === (right ?? ""); |
| 87 | |
| 88 | export const canonical = (value: unknown): unknown => { |
| 89 | if (value === undefined || value === null) return undefined; |
| 90 | if (typeof value === "boolean" || typeof value === "number") return value; |
| 91 | if (typeof value === "string") { |
| 92 | const trimmed = value.trim(); |
| 93 | return trimmed.length === 0 ? undefined : trimmed; |
| 94 | } |
| 95 | if (Array.isArray(value)) { |
| 96 | const items = value.map(canonical).filter((item) => item !== undefined); |
| 97 | return items.length === 0 ? undefined : items; |
| 98 | } |
| 99 | if (typeof value === "object") { |
| 100 | const entries = Object.entries(value as Record<string, unknown>) |
| 101 | .map(([key, item]) => [key, canonical(item)] as const) |
| 102 | .filter(([, item]) => item !== undefined) |
| 103 | .sort(([left], [right]) => left.localeCompare(right)); |
| 104 | if (entries.length === 0) return undefined; |
| 105 | return Object.fromEntries(entries); |
| 106 | } |
| 107 | return undefined; |
| 108 | }; |
| 109 | |
| 110 | export const fingerprint = (value: unknown): string => |
| 111 | JSON.stringify(canonical(value) ?? null); |
| 112 | |
| 113 | /** |
| 114 | * Wait on a Deployment Manager operation (Compute-style `status: "DONE"`). |
| 115 | * Deployments of a handful of resources finish in a minute or two. |
| 116 | * `*ALREADY_EXISTS` counts as success; so does a not-found error when |
| 117 | * `notFoundOk` (deletes). Returns the final operation. |
| 118 | */ |
| 119 | export const waitForOperation = ( |
| 120 | project: string, |
| 121 | operation: deploymentmanager.Operation, |
| 122 | options?: { notFoundOk?: boolean }, |
| 123 | ) => |
| 124 | Effect.suspend(() => { |
| 125 | let latest = operation; |
| 126 | return waitForLongRunningOperation( |
| 127 | operation, |
| 128 | (name) => { |
| 129 | const get = deploymentmanager |
| 130 | .getOperations({ project, operation: lastSegment(name) }) |
| 131 | .pipe( |
| 132 | Effect.tap((current) => |
| 133 | Effect.sync(() => { |
| 134 | latest = current; |
| 135 | }), |
| 136 | ), |
| 137 | ); |
| 138 | const observe: Effect.Effect< |
| 139 | deploymentmanager.Operation, |
| 140 | deploymentmanager.GetOperationsError, |
| 141 | GcpOpContext |
| 142 | > = |
| 143 | options?.notFoundOk === true |
| 144 | ? get.pipe( |
| 145 | Effect.catchTag("NotFound", () => |
| 146 | Effect.succeed<deploymentmanager.Operation>({ |
| 147 | name, |
| 148 | status: "DONE", |
| 149 | }), |
| 150 | ), |
| 151 | ) |
| 152 | : get.pipe( |
| 153 | // A just-returned operation can briefly 404 on read. |
| 154 | Effect.retry({ |
| 155 | while: (error) => error._tag === "NotFound", |
| 156 | times: 5, |
| 157 | schedule: Schedule.exponential("250 millis"), |
| 158 | }), |
| 159 | ); |
| 160 | return observe; |
| 161 | }, |
| 162 | { budget: "20 minutes" }, |
| 163 | ).pipe( |
| 164 | Effect.map(() => latest), |
| 165 | Effect.catchTag("GCP.OperationFailed", (error) => { |
| 166 | const reason = (error.reason ?? "").toUpperCase(); |
| 167 | return reason.endsWith("ALREADY_EXISTS") || |
| 168 | (options?.notFoundOk === true && |
| 169 | (reason === "NOT_FOUND" || reason === "RESOURCE_NOT_FOUND")) |
| 170 | ? Effect.succeed(latest) |
| 171 | : Effect.fail(error); |
| 172 | }), |
| 173 | ); |
| 174 | }); |
| 175 | |
| 176 | export const waitUntilExists = <A, E extends { readonly _tag: string }, R>( |
| 177 | get: Effect.Effect<A | undefined, E, R>, |
| 178 | name: string, |
| 179 | ): Effect.Effect<NonNullable<A>, E | ResourceNotResolved, R> => |
| 180 | get.pipe( |
| 181 | Effect.filterOrFail( |
| 182 | (value): value is NonNullable<A> => value != null, |
| 183 | () => new ResourceNotResolved({ name }), |
| 184 | ), |
| 185 | Effect.retry({ |
| 186 | while: (error) => |
| 187 | error._tag === "GCP.DeploymentManager.ResourceNotResolved", |
| 188 | times: 8, |
| 189 | schedule: Schedule.spaced("1 second"), |
| 190 | }), |
| 191 | ); |
| 192 | |
| 193 | export const waitUntilGone = <A, E extends { readonly _tag: string }, R>( |
| 194 | get: Effect.Effect<A | undefined, E, R>, |
| 195 | name: string, |
| 196 | ): Effect.Effect<void, E | ResourceStillExists, R> => |
| 197 | get.pipe( |
| 198 | Effect.filterOrFail( |
| 199 | (value) => value === undefined, |
| 200 | () => new ResourceStillExists({ name }), |
| 201 | ), |
| 202 | Effect.retry({ |
| 203 | while: (error) => |
| 204 | error._tag === "GCP.DeploymentManager.ResourceStillExists", |
| 205 | times: 10, |
| 206 | schedule: Schedule.spaced("2 seconds"), |
| 207 | }), |
| 208 | Effect.asVoid, |
| 209 | ); |
| 210 | |
| 211 | export const collectPages = <Page, A, E, R>( |
| 212 | pages: Stream.Stream<Page, E, R>, |
| 213 | items: (page: Page) => readonly A[] | undefined, |
| 214 | ) => |
| 215 | pages.pipe( |
| 216 | Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])), |
| 217 | Stream.runCollect, |
| 218 | Effect.map((chunk) => Array.from(chunk)), |
| 219 | ); |
| 220 | |
| 221 | export const getDeployment = (project: string, deployment: string) => |
| 222 | deploymentmanager |
| 223 | .getDeployments({ project, deployment }) |
| 224 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 225 | |
| 226 | export const listOwnedDeployments = (project: string) => |
| 227 | collectPages( |
| 228 | deploymentmanager.listDeployments.pages({ |
| 229 | project, |
| 230 | maxResults: 500, |
| 231 | }), |
| 232 | (page) => page.deployments, |
| 233 | ).pipe( |
| 234 | Effect.map((items) => |
| 235 | items.filter((item) => |
| 236 | Object.keys(labelsToRecord(item.labels)).some((key) => |
| 237 | key.startsWith("alchemy-"), |
| 238 | ), |
| 239 | ), |
| 240 | ), |
| 241 | Effect.catchTag("NotFound", () => |
| 242 | Effect.succeed([] as deploymentmanager.Deployment[]), |
| 243 | ), |
| 244 | ); |
| 245 | |
| 246 | export const getManifest = ( |
| 247 | project: string, |
| 248 | deployment: string, |
| 249 | manifest: string | undefined, |
| 250 | ) => { |
| 251 | if (manifest === undefined || manifest.length === 0) { |
| 252 | return Effect.succeed(undefined); |
| 253 | } |
| 254 | return deploymentmanager |
| 255 | .getManifests({ |
| 256 | project, |
| 257 | deployment, |
| 258 | manifest: lastSegment(manifest), |
| 259 | }) |
| 260 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 261 | }; |
| 262 | |
| 263 | export const settleDeployment = ( |
| 264 | project: string, |
| 265 | item: deploymentmanager.Deployment, |
| 266 | ) => |
| 267 | Effect.gen(function* () { |
| 268 | const operation = item.operation; |
| 269 | if (operation !== undefined && operation.status !== "DONE") { |
| 270 | yield* waitForOperation(project, operation, { notFoundOk: true }); |
| 271 | } |
| 272 | }); |