1import * as deploymentmanager from "@distilled.cloud/gcp/deploymentmanager_v2";
2import * as Data from "effect/Data";
3import type { GcpOpContext } from "@distilled.cloud/gcp/Protocol";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import { tagRecord } from "../../Tags.ts";
9import { stripInternalLabels } from "../Labels.ts";
10import { waitForOperation as waitForLongRunningOperation } from "../Operation.ts";
11
12export const MAX_NAME_LENGTH = 63;
13
14export class ResourceNotResolved extends Data.TaggedError(
15 "GCP.DeploymentManager.ResourceNotResolved",
16)<{
17 name: string;
18}> {}
19
20export class ResourceStillExists extends Data.TaggedError(
21 "GCP.DeploymentManager.ResourceStillExists",
22)<{
23 name: string;
24}> {}
25
26export const lastSegment = (value: string) => {
27 const trimmed = value.replace(/\/+$/, "");
28 const parts = trimmed.split("/");
29 return parts[parts.length - 1] || trimmed;
30};
31
32export 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
45export 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
62export const userLabels = (
63 labels: Record<string, string | undefined> | null | undefined,
64): Record<string, string> => stripInternalLabels(tagRecord(labels));
65
66export 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
80export const recordToLabels = (
81 labels: Record<string, string>,
82): deploymentmanager.DeploymentLabelEntry[] =>
83 Object.entries(labels).map(([key, value]) => ({ key, value }));
84
85export const sameText = (left: string | undefined, right: string | undefined) =>
86 (left ?? "") === (right ?? "");
87
88export 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
110export 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 */
119export 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
176export 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
193export 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
211export 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
221export const getDeployment = (project: string, deployment: string) =>
222 deploymentmanager
223 .getDeployments({ project, deployment })
224 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
225
226export 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
246export 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
263export 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 });