1import * as gkehub from "@distilled.cloud/gcp/gkehub_v2";
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 { waitForOperation as waitForGcpOperation } from "../Operation.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import { tagRecord } from "../../Tags.ts";
9import { stripInternalLabels } from "../Labels.ts";
10
11export const DEFAULT_LOCATION = "global";
12export const MAX_NAME_LENGTH = 63;
13
14export class ResourceNotResolved extends Data.TaggedError(
15 "GCP.GKEHub.ResourceNotResolved",
16)<{
17 name: string;
18}> {}
19
20export class ResourceStillExists extends Data.TaggedError(
21 "GCP.GKEHub.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 = "gkehub"): 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 = `g${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 | undefined) =>
46 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
47
48export const toPhysicalId = (
49 id: string,
50 explicit: string | undefined,
51 existing: string | undefined,
52 fallback = "gkehub",
53) =>
54 Effect.gen(function* () {
55 if (explicit !== undefined) return rfc1035(explicit, fallback);
56 if (existing !== undefined) return existing;
57 return rfc1035(
58 yield* createPhysicalName({
59 id,
60 maxLength: MAX_NAME_LENGTH,
61 lowercase: true,
62 }),
63 fallback,
64 );
65 });
66
67export const parseName = (name: string, collection: string) => {
68 const parts = name.split("/").filter((part) => part.length > 0);
69 const collectionAt = parts.lastIndexOf(collection);
70 const locationsAt = parts.lastIndexOf("locations");
71 const projectsAt = parts.lastIndexOf("projects");
72 const membershipsAt = parts.lastIndexOf("memberships");
73 return {
74 project:
75 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
76 location:
77 locationsAt >= 0 && parts[locationsAt + 1]
78 ? parts[locationsAt + 1]!
79 : DEFAULT_LOCATION,
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 membershipId:
89 membershipsAt >= 0 && parts[membershipsAt + 1]
90 ? parts[membershipsAt + 1]!
91 : "",
92 };
93};
94
95export const membershipName = (
96 value: string,
97 project: string,
98 location: string,
99) => {
100 if (value.includes("/")) return value.replace(/\/+$/, "");
101 return `projects/${project}/locations/${location}/memberships/${value}`;
102};
103
104export const userLabels = (
105 labels: Record<string, string | undefined> | null | undefined,
106): Record<string, string> => stripInternalLabels(tagRecord(labels));
107
108export const hasAlchemyLabelMap = (
109 labels: Record<string, string | undefined> | null | undefined,
110) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
111
112export const canonical = (value: unknown): unknown => {
113 if (value === undefined || value === null) return undefined;
114 if (typeof value === "boolean" || typeof value === "number") return value;
115 if (typeof value === "string") {
116 return value.length === 0 ? undefined : value;
117 }
118 if (Array.isArray(value)) {
119 const items = value.map(canonical).filter((item) => item !== undefined);
120 return items.length === 0 ? undefined : items;
121 }
122 if (typeof value === "object") {
123 const entries = Object.entries(value as Record<string, unknown>)
124 .map(([key, item]) => [key, canonical(item)] as const)
125 .filter(([, item]) => item !== undefined)
126 .sort(([left], [right]) => left.localeCompare(right));
127 if (entries.length === 0) return undefined;
128 return Object.fromEntries(entries);
129 }
130 return undefined;
131};
132
133export const fingerprint = (value: unknown): string =>
134 JSON.stringify(canonical(value) ?? null);
135
136export const fieldMask = (fields: Array<string | false | undefined>) =>
137 fields
138 .filter((field): field is string => typeof field === "string")
139 .join(",");
140
141export const replaceOnIdentity = (input: {
142 previousId: string | undefined;
143 nextId: string | undefined;
144 previousLocation: string;
145 nextLocation: string;
146 extra?: boolean;
147 previousParent?: string;
148 nextParent?: string;
149}) => {
150 const parentChanged =
151 (input.previousParent ?? "") !== "" &&
152 (input.nextParent ?? "") !== "" &&
153 (input.previousParent ?? "") !== (input.nextParent ?? "");
154 const replace =
155 (input.extra ?? false) ||
156 parentChanged ||
157 (input.previousId !== undefined &&
158 input.nextId !== undefined &&
159 input.nextId !== input.previousId) ||
160 input.previousLocation !== input.nextLocation;
161 if (!replace) return undefined;
162 const samePhysical =
163 input.previousLocation === input.nextLocation &&
164 !parentChanged &&
165 input.previousId !== undefined &&
166 input.nextId === input.previousId;
167 return {
168 action: "replace" as const,
169 deleteFirst: samePhysical,
170 };
171};
172
173/**
174 * Wait for a GKE Hub long-running operation. Feature changes reconcile
175 * fleet config across member clusters; a few minutes is typical.
176 */
177export const waitForOperation = (operation: gkehub.Operation) =>
178 waitForGcpOperation(
179 operation,
180 (name) => gkehub.getProjectsLocationsOperations({ name }),
181 { budget: "10 minutes" },
182 );
183
184export const waitUntilExists = <A, E, R>(
185 get: Effect.Effect<A | undefined, E, R>,
186 name: string,
187): Effect.Effect<NonNullable<A>, E | ResourceNotResolved, R> =>
188 get.pipe(
189 Effect.filterOrFail(
190 (value): value is NonNullable<A> => value != null,
191 () => new ResourceNotResolved({ name }),
192 ),
193 Effect.retry({
194 while: (error) => error instanceof ResourceNotResolved,
195 times: 8,
196 schedule: Schedule.spaced("1 second"),
197 }),
198 );
199
200export const waitUntilGone = <A, E, R>(
201 get: Effect.Effect<A | undefined, E, R>,
202 name: string,
203): Effect.Effect<void, E | ResourceStillExists, R> =>
204 get.pipe(
205 Effect.filterOrFail(
206 (value) => value === undefined,
207 () => new ResourceStillExists({ name }),
208 ),
209 Effect.retry({
210 while: (error) => error instanceof ResourceStillExists,
211 times: 10,
212 schedule: Schedule.spaced("2 seconds"),
213 }),
214 Effect.asVoid,
215 );
216
217export const listLabeledPages = <Page, A, E, R>(
218 pages: Stream.Stream<Page, E, R>,
219 items: (page: Page) => readonly A[] | undefined,
220 labelsOf: (item: A) => Record<string, string | undefined> | null | undefined,
221): Effect.Effect<A[], E, R> =>
222 pages.pipe(
223 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
224 Stream.filter((item) => hasAlchemyLabelMap(labelsOf(item))),
225 Stream.runCollect,
226 Effect.map((chunk) => Array.from(chunk)),
227 );