1import * as assuredworkloads from "@distilled.cloud/gcp/assuredworkloads_v1";
2import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import { stripInternalLabels } from "../Labels.ts";
12
13export const MAX_DISPLAY_NAME_LENGTH = 30;
14export const MIN_DISPLAY_NAME_LENGTH = 4;
15
16export type ApiWorkload =
17 assuredworkloads.GoogleCloudAssuredworkloadsV1Workload;
18export type ApiOperation = assuredworkloads.GoogleLongrunningOperation;
19
20export class AssuredworkloadsNotResolved extends Data.TaggedError(
21 "GCP.AssuredWorkloads.NotResolved",
22)<{
23 name: string;
24}> {}
25
26export class AssuredworkloadsStillExists extends Data.TaggedError(
27 "GCP.AssuredWorkloads.StillExists",
28)<{
29 name: string;
30}> {}
31
32export class OrganizationNotResolved extends Data.TaggedError(
33 "GCP.AssuredWorkloads.OrganizationNotResolved",
34)<{
35 project: string;
36}> {}
37
38export const lastSegment = (value: string) => {
39 const trimmed = value.replace(/\/+$/, "");
40 const parts = trimmed.split("/");
41 return parts[parts.length - 1] || trimmed;
42};
43
44export const organizationParent = (value: string) =>
45 value.startsWith("organizations/")
46 ? value
47 : `organizations/${lastSegment(value)}`;
48
49export const organizationIdOf = (value: string) => lastSegment(value);
50
51export const normalizeLocation = (location: string) =>
52 lastSegment(location).toLowerCase();
53
54export const locationParent = (organization: string, location: string) =>
55 `${organizationParent(organization)}/locations/${normalizeLocation(location)}`;
56
57export const parseName = (name: string, defaultLocation: string) => {
58 const parts = name.split("/").filter((part) => part.length > 0);
59 const workloadsAt = parts.lastIndexOf("workloads");
60 const locationsAt = parts.lastIndexOf("locations");
61 const orgsAt = parts.lastIndexOf("organizations");
62 return {
63 organization: orgsAt >= 0 && parts[orgsAt + 1] ? parts[orgsAt + 1]! : "",
64 location:
65 locationsAt >= 0 && parts[locationsAt + 1]
66 ? parts[locationsAt + 1]!
67 : defaultLocation,
68 id:
69 workloadsAt >= 0 && parts[workloadsAt + 1]
70 ? parts[workloadsAt + 1]!
71 : lastSegment(name),
72 parent:
73 workloadsAt > 0
74 ? parts.slice(0, workloadsAt).join("/")
75 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
76 };
77};
78
79export const sanitizeDisplayName = (name: string): string => {
80 let next = name
81 .replace(/[^A-Za-z0-9 -]/g, " ")
82 .replace(/\s+/g, " ")
83 .trim();
84 if (next.length === 0) next = "Work";
85 if (next.length > MAX_DISPLAY_NAME_LENGTH) {
86 next = next.slice(0, MAX_DISPLAY_NAME_LENGTH).trim();
87 }
88 if (next.length < MIN_DISPLAY_NAME_LENGTH) {
89 next = `${next} Load`.slice(0, MAX_DISPLAY_NAME_LENGTH);
90 }
91 return next;
92};
93
94export const toDisplayName = (
95 id: string,
96 requested: string | undefined,
97 existing: string | undefined,
98) =>
99 Effect.gen(function* () {
100 if (requested !== undefined) return requested;
101 if (existing !== undefined) return existing;
102 return sanitizeDisplayName(
103 yield* createPhysicalName({
104 id,
105 maxLength: MAX_DISPLAY_NAME_LENGTH,
106 lowercase: true,
107 }),
108 );
109 });
110
111export const userLabels = (
112 labels: Record<string, string | undefined> | null | undefined,
113): Record<string, string> => stripInternalLabels(tagRecord(labels));
114
115export const hasAlchemyLabelMap = (
116 labels: Record<string, string | undefined> | null | undefined,
117) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
118
119export const sameText = (left: string | undefined, right: string | undefined) =>
120 (left ?? "") === (right ?? "");
121
122export const sameBool = (
123 left: boolean | undefined,
124 right: boolean | undefined,
125) => (left ?? true) === (right ?? true);
126
127export const canonical = (value: unknown): unknown => {
128 if (value === undefined || value === null) return undefined;
129 if (typeof value === "boolean" || typeof value === "number") return value;
130 if (typeof value === "string") {
131 return value.length === 0 ? undefined : value;
132 }
133 if (Array.isArray(value)) {
134 const items = value.map(canonical).filter((item) => item !== undefined);
135 return items.length === 0 ? undefined : items;
136 }
137 if (typeof value === "object") {
138 const entries = Object.entries(value as Record<string, unknown>)
139 .map(([key, item]) => [key, canonical(item)] as const)
140 .filter(([, item]) => item !== undefined)
141 .sort(([left], [right]) => left.localeCompare(right));
142 if (entries.length === 0) return undefined;
143 return Object.fromEntries(entries);
144 }
145 return undefined;
146};
147
148export const fingerprint = (value: unknown): string =>
149 JSON.stringify(canonical(value) ?? null);
150
151export const fieldMask = (fields: Array<string | false | undefined>) =>
152 fields
153 .filter((field): field is string => typeof field === "string")
154 .join(",");
155
156export const replaceOnIdentity = (changed: boolean) =>
157 changed
158 ? ({ action: "replace" as const, deleteFirst: false } as const)
159 : undefined;
160
161export const collectPages = <Page, A, E, R>(
162 pages: Stream.Stream<Page, E, R>,
163 items: (page: Page) => readonly A[] | undefined,
164) =>
165 pages.pipe(
166 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
167 Stream.runCollect,
168 Effect.map((chunk) => Array.from(chunk)),
169 );
170
171export const listWorkloads = (organization: string, location: string) =>
172 collectPages(
173 assuredworkloads.listOrganizationsLocationsWorkloads.pages({
174 parent: locationParent(organization, location),
175 pageSize: 100,
176 }),
177 (page) => page.workloads,
178 ).pipe(
179 Effect.catchTag("NotFound", () => Effect.succeed([] as ApiWorkload[])),
180 );
181
182export const listOwnedWorkloads = (organization: string, region: string) =>
183 Effect.gen(function* () {
184 const wildcard = yield* listWorkloads(organization, "-");
185 const regional = yield* listWorkloads(organization, region);
186 const byName = new Map<string, ApiWorkload>();
187 for (const workload of [...wildcard, ...regional]) {
188 if (workload.name) byName.set(workload.name, workload);
189 }
190 return Array.from(byName.values()).filter((workload) =>
191 hasAlchemyLabelMap(workload.labels),
192 );
193 });
194
195export const resourceNameFromOperation = (
196 operation: ApiOperation,
197): string | undefined => {
198 const response = operation.response;
199 if (
200 response &&
201 typeof response.name === "string" &&
202 response.name.includes("/workloads/")
203 ) {
204 return response.name;
205 }
206 const metadata = operation.metadata;
207 if (metadata && typeof metadata.target === "string") {
208 return metadata.target;
209 }
210 if (
211 metadata &&
212 typeof metadata.name === "string" &&
213 metadata.name.includes("/workloads/")
214 ) {
215 return metadata.name;
216 }
217 return undefined;
218};
219
220/** Workload creation settles within a few minutes. */
221export const waitForOperation = (operation: ApiOperation) =>
222 waitForGcpOperation(
223 operation,
224 (name) => assuredworkloads.getOrganizationsLocationsOperations({ name }),
225 { budget: "10 minutes" },
226 );
227
228export const waitUntilExists = <A, E extends { readonly _tag: string }, R>(
229 get: Effect.Effect<A | undefined, E, R>,
230 name: string,
231) =>
232 get.pipe(
233 Effect.filterOrFail(
234 (value): value is NonNullable<A> => value !== undefined,
235 () => new AssuredworkloadsNotResolved({ name }),
236 ),
237 Effect.retry({
238 while: (error) => error._tag === "GCP.AssuredWorkloads.NotResolved",
239 times: 8,
240 schedule: Schedule.spaced("2 seconds"),
241 }),
242 );
243
244export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
245 get: Effect.Effect<A | undefined, E, R>,
246 name: string,
247) =>
248 get.pipe(
249 Effect.filterOrFail(
250 (value) => value === undefined,
251 () => new AssuredworkloadsStillExists({ name }),
252 ),
253 Effect.retry({
254 while: (error) => error._tag === "GCP.AssuredWorkloads.StillExists",
255 times: 10,
256 schedule: Schedule.spaced("2 seconds"),
257 }),
258 Effect.asVoid,
259 );
260
261const ancestryParent = (name: string) =>
262 name.startsWith("projects/")
263 ? resourcemanager.getProjects({ name }).pipe(
264 Effect.map((resource) => resource.parent),
265 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
266 )
267 : name.startsWith("folders/")
268 ? resourcemanager.getFolders({ name }).pipe(
269 Effect.map((folder) => folder.parent),
270 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
271 )
272 : Effect.succeed(undefined);
273
274export const tryResolveOrganization = () =>
275 Effect.gen(function* () {
276 const env = yield* GcpEnvironment.current;
277 let current: string | undefined = `projects/${env.project}`;
278 for (let i = 0; i < 8; i++) {
279 if (current === undefined) return undefined;
280 if (current.startsWith("organizations/")) return current;
281 current = yield* ancestryParent(current);
282 }
283 return undefined;
284 });
285
286export const resolveOrganization = (
287 explicit: string | undefined,
288 existing: string | undefined,
289) =>
290 Effect.gen(function* () {
291 if (explicit !== undefined) return organizationParent(explicit);
292 if (existing !== undefined) return organizationParent(existing);
293 const resolved = yield* tryResolveOrganization();
294 if (resolved === undefined) {
295 const env = yield* GcpEnvironment.current;
296 return yield* new OrganizationNotResolved({ project: env.project });
297 }
298 return resolved;
299 });
300
301export const deleteChildResources = (workload: ApiWorkload) =>
302 Effect.gen(function* () {
303 const resources = workload.resources ?? [];
304 const projects = resources.filter(
305 (resource) =>
306 resource.resourceType === "CONSUMER_PROJECT" ||
307 resource.resourceType === "ENCRYPTION_KEYS_PROJECT",
308 );
309 const folders = resources.filter(
310 (resource) => resource.resourceType === "CONSUMER_FOLDER",
311 );
312
313 yield* Effect.forEach(
314 projects,
315 (resource) => {
316 const id = resource.resourceId;
317 if (id === undefined || id.length === 0) return Effect.void;
318 return resourcemanager
319 .deleteProjects({ name: `projects/${id}` })
320 .pipe(Effect.catchTag("NotFound", () => Effect.void));
321 },
322 { concurrency: 4 },
323 );
324
325 yield* Effect.forEach(
326 folders,
327 (resource) => {
328 const id = resource.resourceId;
329 if (id === undefined || id.length === 0) return Effect.void;
330 return resourcemanager
331 .deleteFolders({ name: `folders/${id}` })
332 .pipe(Effect.catchTag("NotFound", () => Effect.void));
333 },
334 { concurrency: 4 },
335 );
336 });