1import * as iap from "@distilled.cloud/gcp/iap_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 {
8 alchemyLabelKeys,
9 createInternalLabels,
10 hasAlchemyLabels,
11} from "../Labels.ts";
12
13export const MAX_DEST_GROUP_ID_LENGTH = 63;
14export const MIN_DEST_GROUP_ID_LENGTH = 4;
15export const MAX_DISPLAY_NAME_LENGTH = 255;
16export const OWNER_ZONE = "alc.invalid";
17
18const DIGIT_TO_LETTER = "abcdefghij";
19
20const emptyList = <A>() => Effect.succeed([] as A[]);
21
22export const lastSegment = (value: string) => {
23 const trimmed = value.replace(/\/+$/, "");
24 const parts = trimmed.split("/");
25 return parts[parts.length - 1] || trimmed;
26};
27
28export const parentOf = (name: string) => {
29 const parts = name.split("/").filter((part) => part.length > 0);
30 return parts.slice(0, -2).join("/");
31};
32
33export const normalizeLocation = (
34 location: string | undefined,
35 defaultLocation: string,
36) => lastSegment(location ?? defaultLocation).toLowerCase();
37
38export const sameText = (left: string | undefined, right: string | undefined) =>
39 (left ?? "") === (right ?? "");
40
41export const sameStringList = (
42 left: readonly string[] | undefined,
43 right: readonly string[] | undefined,
44) => {
45 const a = [...(left ?? [])].map((value) => value.toLowerCase()).sort();
46 const b = [...(right ?? [])].map((value) => value.toLowerCase()).sort();
47 return JSON.stringify(a) === JSON.stringify(b);
48};
49
50export const uniqueStrings = (values: readonly string[] | undefined) => {
51 const seen = new Set<string>();
52 const next: string[] = [];
53 for (const value of values ?? []) {
54 const trimmed = value.trim();
55 if (trimmed.length === 0) continue;
56 const key = trimmed.toLowerCase();
57 if (seen.has(key)) continue;
58 seen.add(key);
59 next.push(trimmed);
60 }
61 return next;
62};
63
64export const updateMaskOf = (...fields: Array<string | undefined>) =>
65 fields.filter((field): field is string => field !== undefined).join(",");
66
67export const projectParent = (project: string) => `projects/${project}`;
68
69export const destGroupParent = (project: string, location: string) =>
70 `projects/${project}/iap_tunnel/locations/${location}`;
71
72export const destGroupNameOf = (
73 project: string,
74 location: string,
75 destGroupId: string,
76) => {
77 if (destGroupId.includes("/destGroups/")) {
78 return destGroupId.replace(/\/+$/, "");
79 }
80 return `${destGroupParent(project, location)}/destGroups/${lastSegment(destGroupId)}`;
81};
82
83export const parseDestGroupName = (
84 name: string,
85 fallbackProject: string,
86 fallbackLocation: string,
87) => {
88 const parts = name.split("/").filter((part) => part.length > 0);
89 const destAt = parts.lastIndexOf("destGroups");
90 const locationsAt = parts.lastIndexOf("locations");
91 const projectsAt = parts.lastIndexOf("projects");
92 return {
93 project:
94 projectsAt >= 0 && parts[projectsAt + 1]
95 ? parts[projectsAt + 1]!
96 : fallbackProject,
97 location:
98 locationsAt >= 0 && parts[locationsAt + 1]
99 ? parts[locationsAt + 1]!
100 : fallbackLocation,
101 destGroupId:
102 destAt >= 0 && parts[destAt + 1] ? parts[destAt + 1]! : lastSegment(name),
103 };
104};
105
106export const parseClientName = (name: string, fallbackProject: string) => {
107 const parts = name.split("/").filter((part) => part.length > 0);
108 const clientsAt = parts.lastIndexOf("identityAwareProxyClients");
109 const brandsAt = parts.lastIndexOf("brands");
110 const projectsAt = parts.lastIndexOf("projects");
111 const project =
112 projectsAt >= 0 && parts[projectsAt + 1]
113 ? parts[projectsAt + 1]!
114 : fallbackProject;
115 const brandId =
116 brandsAt >= 0 && parts[brandsAt + 1] ? parts[brandsAt + 1]! : "";
117 return {
118 project,
119 brandId,
120 brand: brandId.length > 0 ? `projects/${project}/brands/${brandId}` : "",
121 identityAwareProxyClientId:
122 clientsAt >= 0 && parts[clientsAt + 1]
123 ? parts[clientsAt + 1]!
124 : lastSegment(name),
125 };
126};
127
128export const brandNameOf = (project: string, brand: string) => {
129 const trimmed = brand.replace(/\/+$/, "");
130 if (trimmed.includes("/brands/")) return trimmed;
131 if (trimmed.startsWith("projects/")) return trimmed;
132 return `projects/${project}/brands/${lastSegment(trimmed)}`;
133};
134
135const lettersOnly = (value: string) =>
136 value
137 .toLowerCase()
138 .replace(/[0-9]/g, (digit) => DIGIT_TO_LETTER[Number(digit)] ?? "a")
139 .replace(/[^a-z-]/g, "-")
140 .replace(/-+/g, "-")
141 .replace(/^-|-$/g, "");
142
143export const toDestGroupId = (
144 id: string,
145 explicit: string | undefined,
146 existing: string | undefined,
147) =>
148 Effect.gen(function* () {
149 if (explicit !== undefined && explicit.length > 0) {
150 return lastSegment(explicit).slice(0, MAX_DEST_GROUP_ID_LENGTH);
151 }
152 if (existing !== undefined && existing.length > 0) {
153 return lastSegment(existing).slice(0, MAX_DEST_GROUP_ID_LENGTH);
154 }
155 const generated = yield* createPhysicalName({
156 id,
157 maxLength: MAX_DEST_GROUP_ID_LENGTH,
158 lowercase: true,
159 });
160 let next = lettersOnly(generated);
161 if (!/^[a-z]/.test(next)) next = `d${next}`;
162 next = next.slice(0, MAX_DEST_GROUP_ID_LENGTH).replace(/-$/g, "");
163 if (next.length < MIN_DEST_GROUP_ID_LENGTH) {
164 next = `${next}dest`.slice(0, MAX_DEST_GROUP_ID_LENGTH);
165 }
166 return next;
167 });
168
169const markerOf = (stack: string, stage: string, id: string) =>
170 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
171
172const fitMarker = (labels: Record<string, string>, maxLength: number) => {
173 let stack = labels[alchemyLabelKeys.stack] ?? "x";
174 let stage = labels[alchemyLabelKeys.stage] ?? "x";
175 let id = labels[alchemyLabelKeys.id] ?? "x";
176 let marker = markerOf(stack, stage, id);
177 while (
178 marker.length > maxLength &&
179 (stack.length > 1 || stage.length > 1 || id.length > 1)
180 ) {
181 if (stack.length >= stage.length && stack.length >= id.length) {
182 stack = stack.slice(0, -1);
183 } else if (stage.length >= id.length) {
184 stage = stage.slice(0, -1);
185 } else {
186 id = id.slice(0, -1);
187 }
188 marker = markerOf(stack, stage, id);
189 }
190 return marker.slice(0, maxLength);
191};
192
193export const encodeOwnershipLine = (
194 labels: Record<string, string>,
195 text: string | undefined,
196 maxLength = MAX_DISPLAY_NAME_LENGTH,
197): string => {
198 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
199 if (!trimmed) return fitMarker(labels, maxLength);
200 const minMarker = 24;
201 const reserved = Math.min(
202 trimmed.length + 1,
203 Math.max(0, maxLength - minMarker),
204 );
205 const marker = fitMarker(labels, maxLength - reserved);
206 return `${marker} ${trimmed}`.slice(0, maxLength);
207};
208
209export const parseOwnership = (
210 text: string | undefined,
211): {
212 labels: Record<string, string>;
213 text: string | undefined;
214} => {
215 if (!text?.startsWith("[alchemy ")) {
216 return { labels: {}, text };
217 }
218 const end = text.indexOf("]");
219 if (end < 0) return { labels: {}, text };
220 const labels: Record<string, string> = {};
221 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
222 const eq = part.indexOf("=");
223 if (eq > 0) {
224 labels[part.slice(0, eq)] = part.slice(eq + 1);
225 }
226 }
227 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
228 return { labels, text: rest.length > 0 ? rest : undefined };
229};
230
231export const hasOwnershipMarker = (text: string | undefined) =>
232 Object.keys(parseOwnership(text).labels).some((key) =>
233 key.startsWith("alchemy-"),
234 );
235
236const prefixMatch = (expected: string, observed: string) =>
237 expected === observed ||
238 expected.startsWith(observed) ||
239 observed.startsWith(expected);
240
241export const ownedByAlchemy = (id: string, text: string | undefined) =>
242 Effect.gen(function* () {
243 const expected = yield* createInternalLabels(id);
244 const { labels } = parseOwnership(text);
245 if (!hasOwnershipMarker(text)) return false;
246 const exact = yield* hasAlchemyLabels(id, labels);
247 if (exact) return true;
248 return (
249 prefixMatch(
250 expected[alchemyLabelKeys.stack] ?? "",
251 labels[alchemyLabelKeys.stack] ?? "",
252 ) &&
253 prefixMatch(
254 expected[alchemyLabelKeys.stage] ?? "",
255 labels[alchemyLabelKeys.stage] ?? "",
256 ) &&
257 prefixMatch(
258 expected[alchemyLabelKeys.id] ?? "",
259 labels[alchemyLabelKeys.id] ?? "",
260 )
261 );
262 });
263
264export const ownershipLabels = (id: string) => createInternalLabels(id);
265
266export const ownershipFqdn = (labels: Record<string, string>) => {
267 const raw = (labels[alchemyLabelKeys.id] ?? "x")
268 .toLowerCase()
269 .replace(/_/g, "-")
270 .replace(/[^a-z0-9-]/g, "")
271 .replace(/-+/g, "-")
272 .replace(/^-|-$/g, "")
273 .slice(0, 40);
274 return `alc-${raw.length > 0 ? raw : "x"}.${OWNER_ZONE}`;
275};
276
277export const isOwnershipFqdn = (fqdn: string) =>
278 fqdn.toLowerCase().endsWith(`.${OWNER_ZONE}`);
279
280export const userFqdns = (fqdns: readonly string[] | undefined) =>
281 uniqueStrings(fqdns).filter((fqdn) => !isOwnershipFqdn(fqdn));
282
283export const hasOwnershipFqdn = (fqdns: readonly string[] | undefined) =>
284 (fqdns ?? []).some((fqdn) => isOwnershipFqdn(fqdn));
285
286export const ownedDestGroup = (
287 id: string,
288 fqdns: readonly string[] | undefined,
289) =>
290 Effect.gen(function* () {
291 if (!hasOwnershipFqdn(fqdns)) return false;
292 const expected = ownershipFqdn(yield* createInternalLabels(id));
293 return (fqdns ?? []).some((fqdn) => {
294 const lower = fqdn.toLowerCase();
295 return lower === expected || prefixMatch(expected, lower);
296 });
297 });
298
299export const desiredFqdns = (
300 labels: Record<string, string>,
301 fqdns: readonly string[] | undefined,
302) => uniqueStrings([...userFqdns(fqdns), ownershipFqdn(labels)]);
303
304export const toGeneratedDisplayName = (
305 id: string,
306 requested: string | undefined,
307 existing: string | undefined,
308) =>
309 Effect.gen(function* () {
310 if (requested !== undefined) return requested;
311 if (existing !== undefined) return existing;
312 const generated = yield* createPhysicalName({
313 id,
314 maxLength: 80,
315 lowercase: true,
316 });
317 return generated;
318 });
319
320export const replaceOnIdentity = (input: {
321 previousId?: string;
322 nextId?: string;
323 previousParent?: string;
324 nextParent?: string;
325 extra?: boolean;
326}) => {
327 if (input.extra === true) {
328 return { action: "replace" as const, deleteFirst: false };
329 }
330 if (
331 input.previousId !== undefined &&
332 input.nextId !== undefined &&
333 input.previousId !== input.nextId
334 ) {
335 return { action: "replace" as const, deleteFirst: false };
336 }
337 if (
338 input.previousParent !== undefined &&
339 input.nextParent !== undefined &&
340 input.previousParent !== input.nextParent
341 ) {
342 return { action: "replace" as const, deleteFirst: false };
343 }
344 return undefined;
345};
346
347export const catchMissing = <A, E extends { readonly _tag: string }, R>(
348 effect: Effect.Effect<A, E, R>,
349) =>
350 effect.pipe(
351 Effect.catchIf(
352 (error): error is E & { readonly _tag: "NotFound" } =>
353 error._tag === "NotFound",
354 () => Effect.succeed(undefined),
355 ),
356 );
357
358const collectPages = <Page, Item, E extends { readonly _tag: string }, R>(
359 pages: Stream.Stream<Page, E, R>,
360 items: (page: Page) => readonly Item[] | null | undefined,
361) =>
362 pages.pipe(
363 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
364 Stream.runCollect,
365 Effect.map((chunk) => Array.from(chunk)),
366 Effect.catchIf(
367 (error): error is E & { readonly _tag: "NotFound" } =>
368 error._tag === "NotFound",
369 () => emptyList<Item>(),
370 ),
371 );
372
373export const getDestGroup = (name: string) =>
374 name.length === 0
375 ? Effect.succeed(undefined)
376 : catchMissing(iap.getProjectsIap_tunnelLocationsDestGroups({ name }));
377
378export const getClient = (name: string) =>
379 name.length === 0
380 ? Effect.succeed(undefined)
381 : catchMissing(iap.getProjectsBrandsIdentityAwareProxyClients({ name }));
382
383export const listDestGroups = (project: string) =>
384 collectPages(
385 iap.listProjectsIap_tunnelLocationsDestGroups.pages({
386 parent: destGroupParent(project, "-"),
387 pageSize: 1000,
388 }),
389 (page) => page.tunnelDestGroups,
390 );
391
392export const listOwnedDestGroups = (project: string) =>
393 listDestGroups(project).pipe(
394 Effect.map((groups) =>
395 groups.filter((group) => hasOwnershipFqdn(group.fqdns)),
396 ),
397 );
398
399export const listBrands = (project: string) =>
400 iap.listProjectsBrands({ parent: projectParent(project) }).pipe(
401 Effect.map((page) => page.brands ?? []),
402 Effect.catchTag("NotFound", () => emptyList<iap.Brand>()),
403 );
404
405export const listClients = (brand: string) =>
406 brand.length === 0
407 ? emptyList<iap.IdentityAwareProxyClient>()
408 : collectPages(
409 iap.listProjectsBrandsIdentityAwareProxyClients.pages({
410 parent: brand,
411 pageSize: 100,
412 }),
413 (page) => page.identityAwareProxyClients,
414 );
415
416export const listOwnedClients = (project: string) =>
417 Effect.gen(function* () {
418 const brands = yield* listBrands(project);
419 const pages = yield* Effect.forEach(
420 brands,
421 (brand) =>
422 brand.name
423 ? listClients(brand.name)
424 : emptyList<iap.IdentityAwareProxyClient>(),
425 { concurrency: 4 },
426 );
427 return pages
428 .flat()
429 .filter((client) => hasOwnershipMarker(client.displayName));
430 });
431
432export const findOwnedClient = (
433 project: string,
434 id: string,
435 brand: string | undefined,
436 displayName: string | undefined,
437) =>
438 Effect.gen(function* () {
439 const clients =
440 brand !== undefined && brand.length > 0
441 ? yield* listClients(brand)
442 : yield* listOwnedClients(project);
443 for (const client of clients) {
444 if (!(yield* ownedByAlchemy(id, client.displayName))) continue;
445 const parsed = parseOwnership(client.displayName);
446 if (
447 displayName !== undefined &&
448 parsed.text !== undefined &&
449 parsed.text !== displayName
450 ) {
451 continue;
452 }
453 return client;
454 }
455 return undefined;
456 });
457
458export class DeleteNotConfirmed extends Data.TaggedError(
459 "GCP.IAP.DeleteNotConfirmed",
460)<{}> {}
461
462/** Poll until the resource is gone; fails if it is still readable after ~60s. */
463export const waitUntilGone = <A, E, R>(
464 get: Effect.Effect<A | undefined, E, R>,
465) =>
466 get.pipe(
467 Effect.repeat({
468 schedule: Schedule.spaced("2 seconds"),
469 until: (value) => value === undefined,
470 times: 30,
471 }),
472 Effect.flatMap((value): Effect.Effect<void, DeleteNotConfirmed> =>
473 value === undefined ? Effect.void : Effect.fail(new DeleteNotConfirmed()),
474 ),
475 );