1import * as clouddeploy from "@distilled.cloud/gcp/clouddeploy_v1";
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.CloudDeploy.ResourceNotResolved",
16)<{
17 name: string;
18}> {}
19
20export class ResourceStillExists extends Data.TaggedError(
21 "GCP.CloudDeploy.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 = "clouddeploy"): 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 = `c${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 normalizeLocation = (location: string) =>
46 lastSegment(location).toLowerCase();
47
48export const parentOf = (project: string, location: string) =>
49 `projects/${project}/locations/${normalizeLocation(location)}`;
50
51export const toPhysicalId = (
52 id: string,
53 explicit: string | undefined,
54 existing: string | undefined,
55 fallback = "clouddeploy",
56) =>
57 Effect.gen(function* () {
58 if (explicit !== undefined) return rfc1035(explicit, fallback);
59 if (existing !== undefined) return existing;
60 return rfc1035(
61 yield* createPhysicalName({
62 id,
63 maxLength: MAX_NAME_LENGTH,
64 lowercase: true,
65 }),
66 fallback,
67 );
68 });
69
70export const parseName = (name: string, collection: string) => {
71 const parts = name.split("/").filter((part) => part.length > 0);
72 const collectionAt = parts.lastIndexOf(collection);
73 const locationsAt = parts.lastIndexOf("locations");
74 const projectsAt = parts.lastIndexOf("projects");
75 return {
76 project:
77 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
78 location:
79 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
80 id:
81 collectionAt >= 0 && parts[collectionAt + 1]
82 ? parts[collectionAt + 1]!
83 : lastSegment(name),
84 parent:
85 collectionAt > 0
86 ? parts.slice(0, collectionAt).join("/")
87 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
88 };
89};
90
91export const expandParent = (
92 value: string,
93 project: string,
94 location: string,
95 collection: string,
96) => {
97 if (value.includes("/")) return value.replace(/\/+$/, "");
98 return `projects/${project}/locations/${location}/${collection}/${value}`;
99};
100
101export const gkeClusterName = (
102 value: string,
103 project: string,
104 location: string,
105) => {
106 if (value.includes("/")) return value.replace(/\/+$/, "");
107 return `projects/${project}/locations/${location}/clusters/${value}`;
108};
109
110export const runLocationName = (
111 value: string,
112 project: string,
113 location: string,
114) => {
115 if (value.startsWith("projects/")) return value.replace(/\/+$/, "");
116 return `projects/${project}/locations/${lastSegment(value) || location}`;
117};
118
119export const membershipName = (
120 value: string,
121 project: string,
122 location: string,
123) => {
124 if (value.includes("/")) return value.replace(/\/+$/, "");
125 return `projects/${project}/locations/${location}/memberships/${value}`;
126};
127
128export const userLabels = (
129 labels: Record<string, string | undefined> | null | undefined,
130): Record<string, string> => stripInternalLabels(tagRecord(labels));
131
132export const stringMap = (
133 value: Record<string, string | undefined> | null | undefined,
134): Record<string, string> =>
135 Object.fromEntries(
136 Object.entries(value ?? {}).filter(
137 (entry): entry is [string, string] => typeof entry[1] === "string",
138 ),
139 );
140
141export const hasAlchemyLabelMap = (
142 labels: Record<string, string | undefined> | null | undefined,
143) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
144
145export const canonical = (value: unknown): unknown => {
146 if (value === undefined || value === null) return undefined;
147 if (typeof value === "boolean" || typeof value === "number") return value;
148 if (typeof value === "string") {
149 return value.length === 0 ? undefined : value;
150 }
151 if (Array.isArray(value)) {
152 const items = value.map(canonical).filter((item) => item !== undefined);
153 return items.length === 0 ? undefined : items;
154 }
155 if (typeof value === "object") {
156 const entries = Object.entries(value as Record<string, unknown>)
157 .map(([key, item]) => [key, canonical(item)] as const)
158 .filter(([, item]) => item !== undefined)
159 .sort(([left], [right]) => left.localeCompare(right));
160 if (entries.length === 0) return undefined;
161 return Object.fromEntries(entries);
162 }
163 return undefined;
164};
165
166export const fingerprint = (value: unknown): string =>
167 JSON.stringify(canonical(value) ?? null);
168
169export const fieldMask = (fields: Array<string | false | undefined>) =>
170 fields
171 .filter((field): field is string => typeof field === "string")
172 .join(",");
173
174export const sameText = (left: string | undefined, right: string | undefined) =>
175 (left ?? "") === (right ?? "");
176
177export const sameBool = (
178 left: boolean | undefined,
179 right: boolean | undefined,
180) => (left ?? false) === (right ?? false);
181
182export const replaceOnIdentity = (input: {
183 previousId: string | undefined;
184 nextId: string | undefined;
185 previousLocation: string;
186 nextLocation: string;
187 extra?: boolean;
188 previousParent?: string;
189 nextParent?: string;
190}) => {
191 const parentChanged =
192 (input.previousParent ?? "") !== "" &&
193 (input.nextParent ?? "") !== "" &&
194 (input.previousParent ?? "") !== (input.nextParent ?? "");
195 const replace =
196 (input.extra ?? false) ||
197 parentChanged ||
198 (input.previousId !== undefined &&
199 input.nextId !== undefined &&
200 input.nextId !== input.previousId) ||
201 input.previousLocation !== input.nextLocation;
202 if (!replace) return undefined;
203 const samePhysical =
204 input.previousLocation === input.nextLocation &&
205 !parentChanged &&
206 input.previousId !== undefined &&
207 input.nextId === input.previousId;
208 return {
209 action: "replace" as const,
210 deleteFirst: samePhysical,
211 };
212};
213
214export const stripAutomationRule = (
215 rule: clouddeploy.AutomationRule,
216): clouddeploy.AutomationRule => ({
217 advanceRolloutRule: rule.advanceRolloutRule
218 ? {
219 id: rule.advanceRolloutRule.id,
220 wait: rule.advanceRolloutRule.wait,
221 sourcePhases: rule.advanceRolloutRule.sourcePhases,
222 }
223 : undefined,
224 promoteReleaseRule: rule.promoteReleaseRule
225 ? {
226 id: rule.promoteReleaseRule.id,
227 destinationTargetId: rule.promoteReleaseRule.destinationTargetId,
228 destinationPhase: rule.promoteReleaseRule.destinationPhase,
229 wait: rule.promoteReleaseRule.wait,
230 }
231 : undefined,
232 repairRolloutRule: rule.repairRolloutRule
233 ? {
234 id: rule.repairRolloutRule.id,
235 phases: rule.repairRolloutRule.phases,
236 repairPhases: rule.repairRolloutRule.repairPhases,
237 jobs: rule.repairRolloutRule.jobs,
238 }
239 : undefined,
240 timedPromoteReleaseRule: rule.timedPromoteReleaseRule
241 ? {
242 id: rule.timedPromoteReleaseRule.id,
243 schedule: rule.timedPromoteReleaseRule.schedule,
244 destinationTargetId: rule.timedPromoteReleaseRule.destinationTargetId,
245 destinationPhase: rule.timedPromoteReleaseRule.destinationPhase,
246 timeZone: rule.timedPromoteReleaseRule.timeZone,
247 }
248 : undefined,
249});
250
251export const stripAutomationRules = (
252 rules: readonly clouddeploy.AutomationRule[] | undefined,
253) => (rules ?? []).map(stripAutomationRule);
254
255/**
256 * Wait on a Cloud Deploy long-running operation (control-plane, usually under a minute).
257 * `ALREADY_EXISTS` (a create race) counts as success; so does `NOT_FOUND`
258 * when `notFoundOk` (deletes). Returns the final operation.
259 */
260export const waitForOperation = (
261 operation: clouddeploy.Operation,
262 options?: { notFoundOk?: boolean },
263) =>
264 Effect.suspend(() => {
265 let latest = operation;
266 return waitForLongRunningOperation(
267 operation,
268 (name) => {
269 const get = clouddeploy.getProjectsLocationsOperations({ name }).pipe(
270 Effect.tap((current) =>
271 Effect.sync(() => {
272 latest = current;
273 }),
274 ),
275 );
276 const observe: Effect.Effect<
277 clouddeploy.Operation,
278 clouddeploy.GetProjectsLocationsOperationsError,
279 GcpOpContext
280 > =
281 options?.notFoundOk === true
282 ? get.pipe(
283 Effect.catchTag("NotFound", () =>
284 Effect.succeed<clouddeploy.Operation>({ name, done: true }),
285 ),
286 )
287 : get.pipe(
288 // A just-returned operation can briefly 404 on read.
289 Effect.retry({
290 while: (error) => error._tag === "NotFound",
291 times: 5,
292 schedule: Schedule.exponential("250 millis"),
293 }),
294 );
295 return observe;
296 },
297 { budget: "10 minutes" },
298 ).pipe(
299 Effect.map(() => latest),
300 // google.rpc.Code ALREADY_EXISTS = 6, NOT_FOUND = 5.
301 Effect.catchTag("GCP.OperationFailed", (error) =>
302 error.code === 6 || (options?.notFoundOk === true && error.code === 5)
303 ? Effect.succeed(latest)
304 : Effect.fail(error),
305 ),
306 );
307 });
308
309export const waitUntilExists = <A, E, R>(
310 get: Effect.Effect<A | undefined, E, R>,
311 name: string,
312): Effect.Effect<Exclude<A, undefined>, E | ResourceNotResolved, R> =>
313 get.pipe(
314 Effect.filterOrFail(
315 (value): value is Exclude<A, undefined> => value !== undefined,
316 () => new ResourceNotResolved({ name }),
317 ),
318 Effect.retry({
319 while: (error) => error instanceof ResourceNotResolved,
320 times: 8,
321 schedule: Schedule.spaced("1 second"),
322 }),
323 );
324
325export const waitUntilGone = <A, E, R>(
326 get: Effect.Effect<A | undefined, E, R>,
327 name: string,
328): Effect.Effect<void, E | ResourceStillExists, R> =>
329 get.pipe(
330 Effect.filterOrFail(
331 (value) => value === undefined,
332 () => new ResourceStillExists({ name }),
333 ),
334 Effect.asVoid,
335 Effect.retry({
336 while: (error) => error instanceof ResourceStillExists,
337 times: 10,
338 schedule: Schedule.spaced("2 seconds"),
339 }),
340 );
341
342export const listAtLocation = <A, E, R>(
343 project: string,
344 region: string,
345 list: (parent: string) => Effect.Effect<A[], E, R>,
346): Effect.Effect<A[], E, R> =>
347 // Some collections reject the `-` location wildcard; fall back to the
348 // region.
349 Effect.firstSuccessOf([
350 list(`projects/${project}/locations/-`),
351 list(`projects/${project}/locations/${region}`),
352 ]);
353
354export const listAtNested = <A, E, R>(
355 project: string,
356 region: string,
357 nested: string,
358 list: (parent: string) => Effect.Effect<A[], E, R>,
359): Effect.Effect<A[], E, R> =>
360 // Some collections reject the `-` location wildcard; fall back to the
361 // region.
362 Effect.firstSuccessOf([
363 list(`projects/${project}/locations/-/${nested}`),
364 list(`projects/${project}/locations/${region}/${nested}`),
365 ]);
366
367export const listLabeledPages = <Page, A, E, R>(
368 pages: Stream.Stream<Page, E, R>,
369 items: (page: Page) => readonly A[] | undefined,
370 labelsOf: (item: A) => Record<string, string | undefined> | null | undefined,
371): Effect.Effect<A[], E, R> =>
372 pages.pipe(
373 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
374 Stream.filter((item) => hasAlchemyLabelMap(labelsOf(item))),
375 Stream.runCollect,
376 Effect.map((chunk) => Array.from(chunk)),
377 );