1import * as beyondcorp from "@distilled.cloud/gcp/beyondcorp_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 {
9 alchemyLabelKeys,
10 createInternalLabels,
11 hasAlchemyLabels,
12 stripInternalLabels,
13} from "../Labels.ts";
14import {
15 waitForOperation as waitForLongRunning,
16 type LongRunningOperation,
17} from "../Operation.ts";
18
19export const DEFAULT_GLOBAL = "global";
20export const MAX_NAME_LENGTH = 63;
21export const MAX_DISPLAY_NAME_LENGTH = 64;
22export const DEFAULT_CONNECTION_TYPE = "TCP_PROXY";
23export const DEFAULT_GATEWAY_TYPE = "TCP_PROXY";
24export const DEFAULT_HOST_TYPE = "GCP_REGIONAL_MIG";
25export const DEFAULT_GATEWAY_KIND = "GCP_REGIONAL_MIG";
26
27export class ResourceNotResolved extends Data.TaggedError(
28 "GCP.BeyondCorp.ResourceNotResolved",
29)<{
30 name: string;
31}> {}
32
33export class ResourceStillExists extends Data.TaggedError(
34 "GCP.BeyondCorp.ResourceStillExists",
35)<{
36 name: string;
37}> {}
38
39export class ResourceFailed extends Data.TaggedError(
40 "GCP.BeyondCorp.ResourceFailed",
41)<{
42 name: string;
43 state: string | undefined;
44}> {}
45
46export class ResourceNotReady extends Data.TaggedError(
47 "GCP.BeyondCorp.ResourceNotReady",
48)<{
49 name: string;
50 state: string | undefined;
51}> {}
52
53export const lastSegment = (value: string) => {
54 const trimmed = value.replace(/\/+$/, "");
55 const parts = trimmed.split("/");
56 return parts[parts.length - 1] || trimmed;
57};
58
59export const rfc1035 = (name: string, fallback = "beyondcorp"): string => {
60 let next = name
61 .toLowerCase()
62 .replace(/[^a-z0-9-]/g, "-")
63 .replace(/-+/g, "-")
64 .replace(/^-+|-+$/g, "");
65 if (!/^[a-z]/.test(next)) next = `${fallback[0] ?? "b"}${next}`;
66 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
67 if (next.length === 0) next = fallback;
68 if (next.length < 4)
69 next = `${next}${fallback}xxxx`.slice(0, MAX_NAME_LENGTH);
70 if (!/[a-z0-9]$/.test(next)) {
71 next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
72 }
73 return next.slice(0, MAX_NAME_LENGTH);
74};
75
76export const normalizeLocation = (
77 location: string | undefined,
78 fallback: string,
79) => lastSegment(location ?? fallback).toLowerCase();
80
81export const parentOf = (project: string, location: string) =>
82 `projects/${project}/locations/${location}`;
83
84export const parseName = (
85 name: string,
86 collection: string,
87 fallbackLocation: string,
88) => {
89 const parts = name.split("/").filter((part) => part.length > 0);
90 const collectionAt = parts.lastIndexOf(collection);
91 const locationsAt = parts.lastIndexOf("locations");
92 const projectsAt = parts.lastIndexOf("projects");
93 return {
94 project:
95 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
96 location:
97 locationsAt >= 0 && parts[locationsAt + 1]
98 ? parts[locationsAt + 1]!
99 : fallbackLocation,
100 id:
101 collectionAt >= 0 && parts[collectionAt + 1]
102 ? parts[collectionAt + 1]!
103 : lastSegment(name),
104 parent:
105 collectionAt > 0
106 ? parts.slice(0, collectionAt).join("/")
107 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
108 };
109};
110
111export const expandName = (
112 value: string,
113 project: string,
114 location: string,
115 collection: string,
116) => {
117 const next = value.replace(/\/+$/, "");
118 if (next.includes("/")) return next;
119 return `projects/${project}/locations/${location}/${collection}/${next}`;
120};
121
122export const toPhysicalId = (
123 id: string,
124 explicit: string | undefined,
125 existing: string | undefined,
126 fallback = "beyondcorp",
127) =>
128 Effect.gen(function* () {
129 if (explicit !== undefined) return rfc1035(explicit, fallback);
130 if (existing !== undefined) return existing;
131 return rfc1035(
132 yield* createPhysicalName({
133 id,
134 maxLength: MAX_NAME_LENGTH,
135 lowercase: true,
136 }),
137 fallback,
138 );
139 });
140
141export const userLabels = (
142 labels: Record<string, string | undefined> | null | undefined,
143): Record<string, string> => stripInternalLabels(tagRecord(labels));
144
145export const hasAlchemyLabelMap = (
146 labels: Record<string, string | undefined> | null | undefined,
147) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
148
149const markerOf = (stack: string, stage: string, id: string) =>
150 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
151
152const compactMarkerOf = (stack: string, stage: string, id: string) =>
153 `[alc ${stack} ${stage} ${id}]`;
154
155const shrinkMarker = (
156 labels: Record<string, string>,
157 maxLength: number,
158 build: (stack: string, stage: string, id: string) => string,
159) => {
160 let stack = labels[alchemyLabelKeys.stack] ?? "x";
161 let stage = labels[alchemyLabelKeys.stage] ?? "x";
162 let id = labels[alchemyLabelKeys.id] ?? "x";
163 let marker = build(stack, stage, id);
164 while (
165 marker.length > maxLength &&
166 (stack.length > 1 || stage.length > 1 || id.length > 1)
167 ) {
168 if (stack.length >= stage.length && stack.length >= id.length) {
169 stack = stack.slice(0, -1);
170 } else if (stage.length >= id.length) {
171 stage = stage.slice(0, -1);
172 } else {
173 id = id.slice(0, -1);
174 }
175 marker = build(stack, stage, id);
176 }
177 return marker.slice(0, maxLength);
178};
179
180export const encodeOwnershipLine = (
181 labels: Record<string, string>,
182 text: string | undefined,
183 maxLength = MAX_DISPLAY_NAME_LENGTH,
184): string => {
185 const marker =
186 maxLength < 54
187 ? shrinkMarker(labels, maxLength, compactMarkerOf)
188 : shrinkMarker(labels, maxLength, markerOf);
189 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
190 if (!trimmed) return marker;
191 return `${marker} ${trimmed}`.slice(0, maxLength);
192};
193
194export const parseOwnership = (
195 text: string | undefined,
196): {
197 labels: Record<string, string>;
198 text: string | undefined;
199} => {
200 if (text?.startsWith("[alc ")) {
201 const end = text.indexOf("]");
202 if (end < 0) return { labels: {}, text };
203 const parts = text.slice("[alc ".length, end).trim().split(/\s+/);
204 const labels: Record<string, string> = {};
205 if (parts[0]) labels[alchemyLabelKeys.stack] = parts[0]!;
206 if (parts[1]) labels[alchemyLabelKeys.stage] = parts[1]!;
207 if (parts[2]) labels[alchemyLabelKeys.id] = parts[2]!;
208 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
209 return { labels, text: rest.length > 0 ? rest : undefined };
210 }
211 if (!text?.startsWith("[alchemy ")) {
212 return { labels: {}, text };
213 }
214 const end = text.indexOf("]");
215 if (end < 0) return { labels: {}, text };
216 const labels: Record<string, string> = {};
217 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
218 const eq = part.indexOf("=");
219 if (eq > 0) {
220 labels[part.slice(0, eq)] = part.slice(eq + 1);
221 }
222 }
223 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
224 return { labels, text: rest.length > 0 ? rest : undefined };
225};
226
227export const hasOwnershipMarker = (text: string | undefined) =>
228 Object.keys(parseOwnership(text).labels).some((key) =>
229 key.startsWith("alchemy-"),
230 );
231
232const prefixMatch = (expected: string, observed: string) =>
233 expected === observed ||
234 expected.startsWith(observed) ||
235 observed.startsWith(expected);
236
237export const ownedByAlchemy = (id: string, text: string | undefined) =>
238 Effect.gen(function* () {
239 const expected = yield* createInternalLabels(id);
240 const { labels } = parseOwnership(text);
241 if (!hasOwnershipMarker(text)) return false;
242 const exact = yield* hasAlchemyLabels(id, labels);
243 if (exact) return true;
244 return (
245 prefixMatch(
246 expected[alchemyLabelKeys.stack] ?? "",
247 labels[alchemyLabelKeys.stack] ?? "",
248 ) &&
249 prefixMatch(
250 expected[alchemyLabelKeys.stage] ?? "",
251 labels[alchemyLabelKeys.stage] ?? "",
252 ) &&
253 prefixMatch(
254 expected[alchemyLabelKeys.id] ?? "",
255 labels[alchemyLabelKeys.id] ?? "",
256 )
257 );
258 });
259
260export const canonical = (value: unknown): unknown => {
261 if (value === undefined || value === null) return undefined;
262 if (typeof value === "boolean" || typeof value === "number") return value;
263 if (typeof value === "string") {
264 return value.length === 0 ? undefined : value;
265 }
266 if (Array.isArray(value)) {
267 const items = value.map(canonical).filter((item) => item !== undefined);
268 return items.length === 0 ? undefined : items;
269 }
270 if (typeof value === "object") {
271 const entries = Object.entries(value as Record<string, unknown>)
272 .map(([key, item]) => [key, canonical(item)] as const)
273 .filter(([, item]) => item !== undefined)
274 .sort(([left], [right]) => left.localeCompare(right));
275 if (entries.length === 0) return undefined;
276 return Object.fromEntries(entries);
277 }
278 return undefined;
279};
280
281export const fingerprint = (value: unknown): string =>
282 JSON.stringify(canonical(value) ?? null);
283
284export const sameText = (left: string | undefined, right: string | undefined) =>
285 (left ?? "") === (right ?? "");
286
287export const sameJson = (left: unknown, right: unknown) =>
288 fingerprint(left) === fingerprint(right);
289
290export const sameStringList = (
291 left: readonly string[] | undefined,
292 right: readonly string[] | undefined,
293) =>
294 JSON.stringify([...(left ?? [])].map(lastSegment).sort()) ===
295 JSON.stringify([...(right ?? [])].map(lastSegment).sort());
296
297export const fieldMask = (fields: Array<string | false | undefined>) =>
298 fields
299 .filter((field): field is string => typeof field === "string")
300 .join(",");
301
302export const replaceOnIdentity = (input: {
303 previousId: string | undefined;
304 nextId: string | undefined;
305 previousLocation: string;
306 nextLocation: string;
307 extra?: boolean;
308 previousParent?: string;
309 nextParent?: string;
310}) => {
311 const parentChanged =
312 (input.previousParent ?? "") !== "" &&
313 (input.nextParent ?? "") !== "" &&
314 lastSegment(input.previousParent ?? "") !==
315 lastSegment(input.nextParent ?? "");
316 const replace =
317 (input.extra ?? false) ||
318 parentChanged ||
319 (input.previousId !== undefined &&
320 input.nextId !== undefined &&
321 input.nextId !== input.previousId) ||
322 input.previousLocation !== input.nextLocation;
323 if (!replace) return undefined;
324 const samePhysical =
325 input.previousLocation === input.nextLocation &&
326 !parentChanged &&
327 input.previousId !== undefined &&
328 input.nextId === input.previousId;
329 return {
330 action: "replace" as const,
331 deleteFirst: samePhysical,
332 };
333};
334
335const READY_STATES = new Set(["CREATED", "RUNNING"]);
336const FAILED_STATES = new Set(["ERROR"]);
337
338export const isReadyState = (state: string | undefined) => {
339 const value = (state ?? "").toUpperCase();
340 return value.length === 0 || READY_STATES.has(value);
341};
342
343export const isFailedState = (state: string | undefined) =>
344 FAILED_STATES.has((state ?? "").toUpperCase());
345
346export const waitUntilExists = <A, E extends { readonly _tag: string }, R>(
347 get: Effect.Effect<A, E, R>,
348 name: string,
349): Effect.Effect<Exclude<A, undefined>, E | ResourceNotResolved, R> =>
350 get.pipe(
351 Effect.filterOrFail(
352 (value): value is Exclude<A, undefined> => value !== undefined,
353 () => new ResourceNotResolved({ name }),
354 ),
355 Effect.retry({
356 while: (error) => error._tag === "GCP.BeyondCorp.ResourceNotResolved",
357 times: 8,
358 schedule: Schedule.spaced("1 second"),
359 }),
360 );
361
362export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
363 get: Effect.Effect<A, E, R>,
364 name: string,
365): Effect.Effect<void, E | ResourceStillExists, R> =>
366 get.pipe(
367 Effect.filterOrFail(
368 (value) => value === undefined,
369 () => new ResourceStillExists({ name }),
370 ),
371 Effect.asVoid,
372 Effect.retry({
373 while: (error) => error._tag === "GCP.BeyondCorp.ResourceStillExists",
374 times: 10,
375 schedule: Schedule.spaced("2 seconds"),
376 }),
377 );
378
379export const waitUntilReady = <A, E extends { readonly _tag: string }, R>(
380 get: Effect.Effect<A, E, R>,
381 name: string,
382 stateOf: (value: Exclude<A, undefined>) => string | undefined,
383): Effect.Effect<
384 Exclude<A, undefined>,
385 E | ResourceNotResolved | ResourceFailed | ResourceNotReady,
386 R
387> =>
388 get.pipe(
389 Effect.filterOrFail(
390 (value): value is Exclude<A, undefined> => value !== undefined,
391 () => new ResourceNotResolved({ name }),
392 ),
393 Effect.filterOrFail(
394 (value) => !isFailedState(stateOf(value)),
395 (value) => new ResourceFailed({ name, state: stateOf(value) }),
396 ),
397 Effect.filterOrFail(
398 (value) => {
399 const state = stateOf(value);
400 return isReadyState(state) || (state ?? "").toUpperCase() === "DOWN";
401 },
402 (value) => new ResourceNotReady({ name, state: stateOf(value) }),
403 ),
404 Effect.retry({
405 while: (error) =>
406 error._tag === "GCP.BeyondCorp.ResourceNotReady" ||
407 error._tag === "GCP.BeyondCorp.ResourceNotResolved",
408 times: 8,
409 schedule: Schedule.spaced("3 seconds"),
410 }),
411 );
412
413export const collectPages = <Page, Item, E, R>(
414 stream: Stream.Stream<Page, E, R>,
415 pick: (page: Page) => readonly Item[] | undefined,
416) =>
417 stream.pipe(
418 Stream.flatMap((page) => Stream.fromIterable(pick(page) ?? [])),
419 Stream.runFold(
420 (): Item[] => [],
421 (acc, item) => {
422 acc.push(item);
423 return acc;
424 },
425 ),
426 );
427
428/** Every location at once: the API accepts the `locations/-` wildcard. */
429export const listAtLocation = <A, E, R>(
430 project: string,
431 list: (parent: string) => Effect.Effect<A[], E, R>,
432) => list(`projects/${project}/locations/-`);
433
434/** App connector and security gateway provisioning takes several minutes. */
435const OPERATION_BUDGET = "15 minutes";
436
437/**
438 * Wait for a long-running operation. ALREADY_EXISTS (code 6) means a
439 * concurrent create won the race; reconcile observes the resource next.
440 */
441export const waitForOperation = (operation: LongRunningOperation) =>
442 waitForLongRunning(
443 operation,
444 (name) => beyondcorp.getProjectsLocationsOperations({ name }),
445 {
446 budget: OPERATION_BUDGET,
447 },
448 ).pipe(
449 Effect.catchIf(
450 (error) => error._tag === "GCP.OperationFailed" && error.code === 6,
451 () => Effect.succeed(operation),
452 ),
453 );
454
455/**
456 * Wait for a delete operation. A vanished operation or NOT_FOUND (code 5)
457 * means the resource is already gone.
458 */
459export const waitForDeleteOperation = (operation: LongRunningOperation) =>
460 waitForOperation(operation).pipe(
461 Effect.catchIf(
462 (error) =>
463 error._tag === "NotFound" ||
464 (error._tag === "GCP.OperationFailed" && error.code === 5),
465 () => Effect.succeed(operation),
466 ),
467 );