1import * as osconfig from "@distilled.cloud/gcp/osconfig_v2";
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 { waitForOperation as waitForGcpOperation } from "../Operation.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import { stripInternalLabels } from "../Labels.ts";
12
13export const MAX_ID_LENGTH = 63;
14export const DEFAULT_ACTION = "UPSERT";
15export const DEFAULT_STATE = "STOPPED";
16
17export type OrchestratedResource =
18 osconfig.GoogleCloudOsconfigV2__OrchestratedResource;
19export type OrchestrationScope =
20 osconfig.GoogleCloudOsconfigV2__OrchestrationScope;
21export type PolicyOrchestratorBody =
22 osconfig.GoogleCloudOsconfigV2__PolicyOrchestrator;
23
24export class OsconfigNotResolved extends Data.TaggedError(
25 "GCP.OSConfig.NotResolved",
26)<{
27 name: string;
28}> {}
29
30export class OsconfigStillExists extends Data.TaggedError(
31 "GCP.OSConfig.StillExists",
32)<{
33 name: string;
34}> {}
35
36export class FolderNotResolved extends Data.TaggedError(
37 "GCP.OSConfig.FolderNotResolved",
38)<{
39 project: string;
40}> {}
41
42export class OrganizationNotResolved extends Data.TaggedError(
43 "GCP.OSConfig.OrganizationNotResolved",
44)<{
45 project: string;
46}> {}
47
48export const lastSegment = (value: string) => {
49 const trimmed = value.replace(/\/+$/, "");
50 const parts = trimmed.split("/");
51 return parts[parts.length - 1] || trimmed;
52};
53
54export const rfc1035 = (name: string, fallback = "orch"): string => {
55 let next = name
56 .toLowerCase()
57 .replace(/[^a-z0-9-]/g, "-")
58 .replace(/-+/g, "-")
59 .replace(/^-+|-+$/g, "");
60 if (!/^[a-z]/.test(next)) next = `${fallback[0] ?? "o"}${next}`;
61 next = next.slice(0, MAX_ID_LENGTH).replace(/-+$/g, "");
62 if (next.length === 0) next = fallback;
63 if (!/[a-z0-9]$/.test(next)) {
64 next = `${next.slice(0, MAX_ID_LENGTH - 1)}0`;
65 }
66 return next.slice(0, MAX_ID_LENGTH);
67};
68
69export const toPhysicalId = (
70 id: string,
71 explicit: string | undefined,
72 existing: string | undefined,
73 fallback = "orch",
74) =>
75 Effect.gen(function* () {
76 if (explicit !== undefined) return rfc1035(explicit, fallback);
77 if (existing !== undefined) return existing;
78 return rfc1035(
79 yield* createPhysicalName({
80 id,
81 maxLength: MAX_ID_LENGTH,
82 lowercase: true,
83 }),
84 fallback,
85 );
86 });
87
88export const folderParent = (value: string) =>
89 value.startsWith("folders/") ? value : `folders/${lastSegment(value)}`;
90
91export const organizationParent = (value: string) =>
92 value.startsWith("organizations/")
93 ? value
94 : `organizations/${lastSegment(value)}`;
95
96export const projectParent = (value: string) =>
97 value.startsWith("projects/") ? value : `projects/${lastSegment(value)}`;
98
99export const globalParent = (scopeParent: string) =>
100 `${scopeParent.replace(/\/+$/, "")}/locations/global`;
101
102export const resourceName = (parent: string, policyOrchestratorId: string) =>
103 `${parent}/policyOrchestrators/${policyOrchestratorId}`;
104
105export const parseName = (name: string) => {
106 const parts = name.split("/").filter((part) => part.length > 0);
107 const collectionAt = parts.lastIndexOf("policyOrchestrators");
108 const locationsAt = parts.lastIndexOf("locations");
109 const projectsAt = parts.lastIndexOf("projects");
110 const orgsAt = parts.lastIndexOf("organizations");
111 const foldersAt = parts.lastIndexOf("folders");
112 return {
113 project:
114 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
115 organization: orgsAt >= 0 && parts[orgsAt + 1] ? parts[orgsAt + 1]! : "",
116 folder: foldersAt >= 0 && parts[foldersAt + 1] ? parts[foldersAt + 1]! : "",
117 location:
118 locationsAt >= 0 && parts[locationsAt + 1]
119 ? parts[locationsAt + 1]!
120 : "global",
121 id:
122 collectionAt >= 0 && parts[collectionAt + 1]
123 ? parts[collectionAt + 1]!
124 : lastSegment(name),
125 parent:
126 collectionAt > 0
127 ? parts.slice(0, collectionAt).join("/")
128 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
129 };
130};
131
132export const userLabels = (
133 labels: Record<string, string | undefined> | null | undefined,
134): Record<string, string> => stripInternalLabels(tagRecord(labels));
135
136export const hasAlchemyLabelMap = (
137 labels: Record<string, string | undefined> | null | undefined,
138) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
139
140export const canonical = (value: unknown): unknown => {
141 if (value === undefined || value === null) return undefined;
142 if (typeof value === "boolean" || typeof value === "number") return value;
143 if (typeof value === "string") {
144 return value.length === 0 ? undefined : value;
145 }
146 if (Array.isArray(value)) {
147 const items = value.map(canonical).filter((item) => item !== undefined);
148 return items.length === 0 ? undefined : items;
149 }
150 if (typeof value === "object") {
151 const entries = Object.entries(value as Record<string, unknown>)
152 .map(([key, item]) => [key, canonical(item)] as const)
153 .filter(([, item]) => item !== undefined)
154 .sort(([left], [right]) => left.localeCompare(right));
155 if (entries.length === 0) return undefined;
156 return Object.fromEntries(entries);
157 }
158 return undefined;
159};
160
161export const fingerprint = (value: unknown): string =>
162 JSON.stringify(canonical(value) ?? null);
163
164export const fieldMask = (fields: Array<string | false | undefined>) =>
165 fields
166 .filter((field): field is string => typeof field === "string")
167 .join(",");
168
169const OUTPUT_ONLY_ASSIGNMENT = new Set([
170 "rolloutState",
171 "baseline",
172 "deleted",
173 "etag",
174 "uid",
175 "revisionCreateTime",
176 "reconciling",
177 "name",
178 "revisionId",
179]);
180
181export const assignmentPayload = (
182 assignment: osconfig.OSPolicyAssignment | undefined,
183): osconfig.OSPolicyAssignment | undefined => {
184 if (assignment === undefined) return undefined;
185 const next: Record<string, unknown> = {};
186 for (const [key, value] of Object.entries(assignment)) {
187 if (!OUTPUT_ONLY_ASSIGNMENT.has(key) && value !== undefined) {
188 next[key] = value;
189 }
190 }
191 return next as osconfig.OSPolicyAssignment;
192};
193
194export const orchestratedPayload = (
195 resource: OrchestratedResource | undefined,
196): OrchestratedResource | undefined => {
197 if (resource === undefined) return undefined;
198 return {
199 id: resource.id,
200 osPolicyAssignmentV1Payload: assignmentPayload(
201 resource.osPolicyAssignmentV1Payload,
202 ),
203 };
204};
205
206export const defaultOrchestratedResource = (
207 resourceId: string,
208): OrchestratedResource => ({
209 id: resourceId,
210 osPolicyAssignmentV1Payload: {
211 instanceFilter: {
212 inventories: [{ osShortName: "debian" }],
213 },
214 osPolicies: [
215 {
216 id: "alchemy-noop",
217 mode: "VALIDATION",
218 resourceGroups: [
219 {
220 resources: [
221 {
222 id: "noop-exec",
223 exec: {
224 validate: {
225 interpreter: "SHELL",
226 script: "exit 100",
227 },
228 },
229 },
230 ],
231 },
232 ],
233 },
234 ],
235 rollout: {
236 disruptionBudget: { percent: 100 },
237 minWaitDuration: "0s",
238 },
239 },
240});
241
242const getOperation = (name: string) =>
243 name.startsWith("folders/")
244 ? osconfig.getFoldersLocationsOperations({ name })
245 : name.startsWith("organizations/")
246 ? osconfig.getOrganizationsLocationsOperations({ name })
247 : osconfig.getProjectsLocationsOperations({ name });
248
249/**
250 * Wait for an OS Config policy-orchestrator operation. An operation that
251 * finished with `ALREADY_EXISTS` (6) is a lost create race; `notFoundOk`
252 * also accepts `NOT_FOUND` (5).
253 */
254export const waitForOperation = (
255 operation: osconfig.Operation,
256 options?: { notFoundOk?: boolean },
257) =>
258 waitForGcpOperation(
259 operation,
260 (name) =>
261 getOperation(name).pipe(
262 Effect.catchTag("NotFound", (error) =>
263 options?.notFoundOk === true
264 ? Effect.succeed<osconfig.Operation>({ name, done: true })
265 : Effect.fail(error),
266 ),
267 ),
268 { budget: "10 minutes" },
269 ).pipe(
270 Effect.catchIf(
271 (error) =>
272 error._tag === "GCP.OperationFailed" &&
273 (error.code === 6 ||
274 (options?.notFoundOk === true && error.code === 5)),
275 () => Effect.void,
276 ),
277 Effect.asVoid,
278 );
279
280export const waitUntilExists = <A, E, R>(
281 get: Effect.Effect<A | undefined, E, R>,
282 name: string,
283): Effect.Effect<NonNullable<A>, E | OsconfigNotResolved, R> =>
284 get.pipe(
285 Effect.filterOrFail(
286 (value): value is NonNullable<A> => value != null,
287 () => new OsconfigNotResolved({ name }),
288 ),
289 Effect.retry({
290 while: (error) => error instanceof OsconfigNotResolved,
291 times: 8,
292 schedule: Schedule.spaced("1 second"),
293 }),
294 );
295
296export const waitUntilGone = <A, E, R>(
297 get: Effect.Effect<A | undefined, E, R>,
298 name: string,
299): Effect.Effect<void, E | OsconfigStillExists, R> =>
300 get.pipe(
301 Effect.filterOrFail(
302 (value) => value === undefined,
303 () => new OsconfigStillExists({ name }),
304 ),
305 Effect.retry({
306 while: (error) => error instanceof OsconfigStillExists,
307 times: 10,
308 schedule: Schedule.spaced("3 seconds"),
309 }),
310 Effect.asVoid,
311 );
312
313export const collectOrchestrators = <E extends { readonly _tag: string }>(
314 pages: Stream.Stream<
315 osconfig.GoogleCloudOsconfigV2__ListPolicyOrchestratorsResponse,
316 | osconfig.NotFound
317 | osconfig.Forbidden
318 | osconfig.ServiceDisabled
319 | osconfig.GcpOpError,
320 osconfig.GcpOpContext
321 >,
322) =>
323 pages.pipe(
324 Stream.flatMap((page) =>
325 Stream.fromIterable(page.policyOrchestrators ?? []),
326 ),
327 Stream.runCollect,
328 Effect.map((chunk) => Array.from(chunk)),
329 Effect.catchTag(["NotFound", "Forbidden", "ServiceDisabled"], () =>
330 Effect.succeed([] as PolicyOrchestratorBody[]),
331 ),
332 );
333
334const ancestryParent = (name: string) =>
335 name.startsWith("projects/")
336 ? resourcemanager.getProjects({ name }).pipe(
337 Effect.map((resource) => resource.parent),
338 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
339 )
340 : name.startsWith("folders/")
341 ? resourcemanager.getFolders({ name }).pipe(
342 Effect.map((folder) => folder.parent),
343 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
344 )
345 : Effect.succeed(undefined);
346
347export const tryResolveFolder = () =>
348 Effect.gen(function* () {
349 const env = yield* GcpEnvironment.current;
350 let current: string | undefined = `projects/${env.project}`;
351 for (let i = 0; i < 8; i++) {
352 if (current === undefined) return undefined;
353 if (current.startsWith("folders/")) return current;
354 if (current.startsWith("organizations/")) return undefined;
355 current = yield* ancestryParent(current);
356 }
357 return undefined;
358 });
359
360export const resolveFolder = (
361 explicit: string | undefined,
362 existing: string | undefined,
363) =>
364 Effect.gen(function* () {
365 if (explicit !== undefined) return folderParent(explicit);
366 if (existing !== undefined) return folderParent(existing);
367 const resolved = yield* tryResolveFolder();
368 if (resolved === undefined) {
369 const env = yield* GcpEnvironment.current;
370 return yield* new FolderNotResolved({ project: env.project });
371 }
372 return resolved;
373 });
374
375export const tryResolveOrganization = () =>
376 Effect.gen(function* () {
377 const env = yield* GcpEnvironment.current;
378 let current: string | undefined = `projects/${env.project}`;
379 for (let i = 0; i < 8; i++) {
380 if (current === undefined) return undefined;
381 if (current.startsWith("organizations/")) return current;
382 current = yield* ancestryParent(current);
383 }
384 return undefined;
385 });
386
387export const resolveOrganization = (
388 explicit: string | undefined,
389 existing: string | undefined,
390) =>
391 Effect.gen(function* () {
392 if (explicit !== undefined) return organizationParent(explicit);
393 if (existing !== undefined) return organizationParent(existing);
394 const resolved = yield* tryResolveOrganization();
395 if (resolved === undefined) {
396 const env = yield* GcpEnvironment.current;
397 return yield* new OrganizationNotResolved({ project: env.project });
398 }
399 return resolved;
400 });
401
402export const replaceOnIdentity = (input: {
403 previousId: string | undefined;
404 nextId: string | undefined;
405 previousParent: string;
406 nextParent: string;
407}) => {
408 const idChanged =
409 input.previousId !== undefined &&
410 input.nextId !== undefined &&
411 input.nextId !== input.previousId;
412 const parentChanged =
413 input.previousParent !== "" &&
414 input.nextParent !== "" &&
415 input.previousParent !== input.nextParent;
416 if (!idChanged && !parentChanged) return undefined;
417 return {
418 action: "replace" as const,
419 deleteFirst:
420 !parentChanged &&
421 input.previousId !== undefined &&
422 input.nextId === input.previousId,
423 };
424};