1import * as config from "@distilled.cloud/gcp/config_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import { tagRecord } from "../../Tags.ts";
8import { stripInternalLabels } from "../Labels.ts";
9import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
10
11export const MAX_NAME_LENGTH = 63;
12
13export class ResourceNotResolved extends Data.TaggedError(
14 "GCP.Config.ResourceNotResolved",
15)<{
16 name: string;
17}> {}
18
19export class ResourceStillExists extends Data.TaggedError(
20 "GCP.Config.ResourceStillExists",
21)<{
22 name: string;
23}> {}
24
25export const lastSegment = (value: string) => {
26 const trimmed = value.replace(/\/+$/, "");
27 const parts = trimmed.split("/");
28 return parts[parts.length - 1] || trimmed;
29};
30
31export const rfc1035 = (name: string, fallback = "config"): string => {
32 let next = name
33 .toLowerCase()
34 .replace(/[^a-z0-9-]/g, "-")
35 .replace(/-+/g, "-")
36 .replace(/^-+|-+$/g, "");
37 if (!/^[a-z]/.test(next)) next = `c${next}`;
38 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
39 if (next.length === 0) return fallback;
40 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
41 return next.slice(0, MAX_NAME_LENGTH);
42};
43
44export const normalizeLocation = (
45 location: string | undefined,
46 defaultLocation: string,
47) => lastSegment(location ?? defaultLocation).toLowerCase();
48
49export const parentOf = (project: string, location: string) =>
50 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
51
52export const toPhysicalId = (
53 id: string,
54 explicit: string | undefined,
55 existing: string | undefined,
56 fallback = "config",
57 maxLength = MAX_NAME_LENGTH,
58) =>
59 Effect.gen(function* () {
60 if (explicit !== undefined) return rfc1035(explicit, fallback);
61 if (existing !== undefined) return existing;
62 return rfc1035(
63 yield* createPhysicalName({
64 id,
65 maxLength,
66 lowercase: true,
67 }),
68 fallback,
69 );
70 });
71
72export const parseName = (
73 name: string,
74 collection: string,
75 defaultLocation: string,
76) => {
77 const parts = name.split("/").filter((part) => part.length > 0);
78 const collectionAt = parts.lastIndexOf(collection);
79 const locationsAt = parts.lastIndexOf("locations");
80 const projectsAt = parts.lastIndexOf("projects");
81 return {
82 project:
83 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
84 location:
85 locationsAt >= 0 && parts[locationsAt + 1]
86 ? parts[locationsAt + 1]!
87 : defaultLocation,
88 id:
89 collectionAt >= 0 && parts[collectionAt + 1]
90 ? parts[collectionAt + 1]!
91 : lastSegment(name),
92 parent:
93 collectionAt > 0
94 ? parts.slice(0, collectionAt).join("/")
95 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
96 };
97};
98
99export const expandNamed = (
100 value: string,
101 project: string,
102 location: string,
103 collection: string,
104) => {
105 if (value.includes("/")) return value.replace(/\/+$/, "");
106 return `projects/${project}/locations/${location}/${collection}/${value}`;
107};
108
109export const serviceAccountName = (value: string, project: string) => {
110 if (value.startsWith("projects/")) return value.replace(/\/+$/, "");
111 if (value.includes("@")) {
112 return `projects/${project}/serviceAccounts/${value}`;
113 }
114 return `projects/${project}/serviceAccounts/${value}@${project}.iam.gserviceaccount.com`;
115};
116
117export const userLabels = (
118 labels: Record<string, string | undefined> | null | undefined,
119): Record<string, string> => stripInternalLabels(tagRecord(labels));
120
121export const stringMap = (
122 value: Record<string, string | undefined> | null | undefined,
123): Record<string, string> =>
124 Object.fromEntries(
125 Object.entries(value ?? {}).filter(
126 (entry): entry is [string, string] => typeof entry[1] === "string",
127 ),
128 );
129
130export const hasAlchemyLabelMap = (
131 labels: Record<string, string | undefined> | null | undefined,
132) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
133
134export const canonical = (value: unknown): unknown => {
135 if (value === undefined || value === null) return undefined;
136 if (typeof value === "boolean" || typeof value === "number") return value;
137 if (typeof value === "string") {
138 return value.length === 0 ? undefined : value;
139 }
140 if (Array.isArray(value)) {
141 const items = value.map(canonical).filter((item) => item !== undefined);
142 return items.length === 0 ? undefined : items;
143 }
144 if (typeof value === "object") {
145 const entries = Object.entries(value as Record<string, unknown>)
146 .map(([key, item]) => [key, canonical(item)] as const)
147 .filter(([, item]) => item !== undefined)
148 .sort(([left], [right]) => left.localeCompare(right));
149 if (entries.length === 0) return undefined;
150 return Object.fromEntries(entries);
151 }
152 return undefined;
153};
154
155export const fingerprint = (value: unknown): string =>
156 JSON.stringify(canonical(value) ?? null);
157
158export const fieldMask = (fields: Array<string | false | undefined>) =>
159 fields
160 .filter((field): field is string => typeof field === "string")
161 .join(",");
162
163export const sameText = (left: string | undefined, right: string | undefined) =>
164 (left ?? "") === (right ?? "");
165
166export const replaceOnIdentity = (input: {
167 previousId: string | undefined;
168 nextId: string | undefined;
169 previousLocation: string;
170 nextLocation: string;
171 extra?: boolean;
172}) => {
173 const replace =
174 (input.extra ?? false) ||
175 (input.previousId !== undefined &&
176 input.nextId !== undefined &&
177 input.nextId !== input.previousId) ||
178 input.previousLocation !== input.nextLocation;
179 if (!replace) return undefined;
180 const samePhysical =
181 input.previousLocation === input.nextLocation &&
182 input.previousId !== undefined &&
183 input.nextId === input.previousId;
184 return {
185 action: "replace" as const,
186 deleteFirst: samePhysical,
187 };
188};
189
190/**
191 * Wait for an Infrastructure Manager operation; previews and deployment
192 * groups run Terraform and can take several minutes. ALREADY_EXISTS
193 * (code 6) counts as success (create race); with `notFoundOk`, so does
194 * NOT_FOUND (code 5, delete race).
195 */
196export const waitForOperation = (
197 operation: config.Operation,
198 options?: { notFoundOk?: boolean },
199) =>
200 waitForGcpOperation(
201 operation,
202 (name) => config.getProjectsLocationsOperations({ name }),
203 { budget: "20 minutes" },
204 ).pipe(
205 Effect.catchIf(
206 (error) =>
207 error._tag === "GCP.OperationFailed" &&
208 (error.code === 6 ||
209 (options?.notFoundOk === true && error.code === 5)),
210 () => Effect.succeed(operation),
211 ),
212 );
213
214export const waitUntilExists = <A, E, R>(
215 get: Effect.Effect<A | undefined, E, R>,
216 name: string,
217): Effect.Effect<NonNullable<A>, E | ResourceNotResolved, R> =>
218 get.pipe(
219 Effect.filterOrFail(
220 (value): value is NonNullable<A> => value != null,
221 () => new ResourceNotResolved({ name }),
222 ),
223 Effect.retry({
224 while: (error) => error instanceof ResourceNotResolved,
225 times: 8,
226 schedule: Schedule.spaced("1 second"),
227 }),
228 );
229
230export const waitUntilGone = <A, E, R>(
231 get: Effect.Effect<A | undefined, E, R>,
232 name: string,
233): Effect.Effect<void, E | ResourceStillExists, R> =>
234 get.pipe(
235 Effect.filterOrFail(
236 (value) => value === undefined,
237 () => new ResourceStillExists({ name }),
238 ),
239 Effect.retry({
240 while: (error) => error instanceof ResourceStillExists,
241 times: 10,
242 schedule: Schedule.spaced("2 seconds"),
243 }),
244 Effect.asVoid,
245 );
246
247export const listAtLocation = <A, E, R>(
248 project: string,
249 region: string,
250 list: (parent: string) => Effect.Effect<A[], E, R>,
251): Effect.Effect<A[], E, R> =>
252 // Prefer the all-locations wildcard; fall back to the default region
253 // (whose error, if any, propagates).
254 list(`projects/${project}/locations/-`).pipe(
255 Effect.catch(() => list(`projects/${project}/locations/${region}`)),
256 );
257
258export const listLabeledPages = <Page, A, E, R>(
259 pages: Stream.Stream<Page, E, R>,
260 items: (page: Page) => readonly A[] | undefined,
261 labelsOf: (item: A) => Record<string, string | undefined> | null | undefined,
262): Effect.Effect<A[], E, R> =>
263 pages.pipe(
264 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
265 Stream.filter((item) => hasAlchemyLabelMap(labelsOf(item))),
266 Stream.runCollect,
267 Effect.map((chunk) => Array.from(chunk)),
268 );