1import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
2import type * as dlp from "@distilled.cloud/gcp/dlp_v2";
3import * as Data from "effect/Data";
4import * as Config from "effect/Config";
5import * as Effect from "effect/Effect";
6import * as Option from "effect/Option";
7import * as Schedule from "effect/Schedule";
8import * as Stream from "effect/Stream";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 alchemyLabelKeys,
13 createInternalLabels,
14 hasAlchemyLabels,
15} from "../Labels.ts";
16
17/** Optional deploy-time setting from the ConfigProvider (env by default). */
18const optionalSetting = (key: string) =>
19 Effect.gen(function* () {
20 const value = yield* Config.option(Config.String(key));
21 return Option.getOrUndefined(value)?.trim() || undefined;
22 });
23
24export const MAX_ID_LENGTH = 100;
25export const MAX_DESCRIPTION_LENGTH = 256;
26export const MAX_DISPLAY_NAME_LENGTH = 100;
27export const MAX_CONTENT_POLICY_DISPLAY_NAME_LENGTH = 63;
28export const DEFAULT_LOCATION = "global";
29export const DEFAULT_REGIONAL_LOCATION = "us";
30/**
31 * Locations swept by `list`, alongside the stack region. `us-central1` stays
32 * so resources created under the former hard-coded default remain reachable.
33 */
34export const LIST_LOCATIONS = ["us-central1", "global", "us"] as const;
35
36export class DlpNotResolved extends Data.TaggedError("GCP.DLP.NotResolved")<{
37 name: string;
38}> {}
39
40export class StoredInfoTypeNotReady extends Data.TaggedError(
41 "GCP.DLP.StoredInfoTypeNotReady",
42)<{
43 name: string;
44 state: string | undefined;
45}> {}
46
47export class StoredInfoTypeFailed extends Data.TaggedError(
48 "GCP.DLP.StoredInfoTypeFailed",
49)<{
50 name: string;
51 state: string;
52 message: string;
53}> {}
54
55const storedInfoTypeFailure = (
56 name: string,
57 version: dlp.GooglePrivacyDlpV2StoredInfoTypeVersion | undefined,
58) =>
59 version?.state === "FAILED" || version?.state === "INVALID"
60 ? new StoredInfoTypeFailed({
61 name,
62 state: version.state,
63 message:
64 (version.errors ?? [])
65 .map((error) => error.details?.message ?? "")
66 .filter((message) => message.length > 0)
67 .join("; ") || `stored info type version is ${version.state}`,
68 })
69 : undefined;
70
71/**
72 * Poll a stored info type until its current version is `READY` and no
73 * pending versions remain (create and update both build a new version
74 * asynchronously). Fails on a `FAILED`/`INVALID` version.
75 */
76export const waitForStoredInfoTypeReady = <
77 E extends { readonly _tag: string },
78 R,
79>(
80 name: string,
81 get: Effect.Effect<dlp.GooglePrivacyDlpV2StoredInfoType | undefined, E, R>,
82) =>
83 get.pipe(
84 Effect.flatMap(
85 (
86 stored,
87 ): Effect.Effect<
88 dlp.GooglePrivacyDlpV2StoredInfoType,
89 StoredInfoTypeNotReady | StoredInfoTypeFailed
90 > => {
91 if (stored === undefined) {
92 return Effect.fail(
93 new StoredInfoTypeNotReady({ name, state: undefined }),
94 );
95 }
96 const pending = stored.pendingVersions ?? [];
97 const failed = storedInfoTypeFailure(
98 name,
99 pending[pending.length - 1] ?? stored.currentVersion,
100 );
101 if (failed !== undefined) return Effect.fail(failed);
102 if (stored.currentVersion?.state !== "READY" || pending.length > 0) {
103 return Effect.fail(
104 new StoredInfoTypeNotReady({
105 name,
106 state: pending[0]?.state ?? stored.currentVersion?.state,
107 }),
108 );
109 }
110 return Effect.succeed(stored);
111 },
112 ),
113 Effect.retry({
114 while: (error) => error._tag === "GCP.DLP.StoredInfoTypeNotReady",
115 times: 120,
116 schedule: Schedule.spaced("5 seconds"),
117 }),
118 );
119
120export class OrganizationNotResolved extends Data.TaggedError(
121 "GCP.DLP.OrganizationNotResolved",
122)<{
123 project: string;
124}> {}
125
126export const lastSegment = (value: string) => {
127 const trimmed = value.replace(/\/+$/, "");
128 const parts = trimmed.split("/");
129 return parts[parts.length - 1] || trimmed;
130};
131
132export const parseName = (name: string, collection: string) => {
133 const parts = name.split("/").filter((part) => part.length > 0);
134 const collectionAt = parts.lastIndexOf(collection);
135 const locationsAt = parts.lastIndexOf("locations");
136 const projectsAt = parts.lastIndexOf("projects");
137 const orgsAt = parts.lastIndexOf("organizations");
138 return {
139 project:
140 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
141 organization: orgsAt >= 0 && parts[orgsAt + 1] ? parts[orgsAt + 1]! : "",
142 location:
143 locationsAt >= 0 && parts[locationsAt + 1]
144 ? parts[locationsAt + 1]!
145 : DEFAULT_LOCATION,
146 id:
147 collectionAt >= 0 && parts[collectionAt + 1]
148 ? parts[collectionAt + 1]!
149 : lastSegment(name),
150 parent:
151 collectionAt > 0
152 ? parts.slice(0, collectionAt).join("/")
153 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
154 };
155};
156
157export const locationOf = (name: string, fallback = DEFAULT_LOCATION) => {
158 const parts = name.split("/");
159 const index = parts.indexOf("locations");
160 return index >= 0 ? (parts[index + 1] ?? fallback) : fallback;
161};
162
163export const organizationOf = (name: string) => {
164 const parts = name.split("/");
165 const index = parts.indexOf("organizations");
166 return index >= 0 ? (parts[index + 1] ?? "") : "";
167};
168
169export const projectOf = (name: string) => {
170 const parts = name.split("/");
171 const index = parts.indexOf("projects");
172 return index >= 0 ? (parts[index + 1] ?? "") : "";
173};
174
175export const organizationParent = (value: string) =>
176 value.startsWith("organizations/")
177 ? value
178 : `organizations/${lastSegment(value)}`;
179
180export const organizationIdOf = (value: string) => lastSegment(value);
181
182export const locationParent = (project: string, location: string) =>
183 `projects/${project}/locations/${location}`;
184
185export const organizationLocationParent = (
186 organization: string,
187 location: string,
188) => `${organizationParent(organization)}/locations/${location}`;
189
190export const locationParentsOf = (organization: string, region: string) =>
191 Array.from(new Set<string>([...LIST_LOCATIONS, region])).map((location) =>
192 organizationLocationParent(organization, location),
193 );
194
195export const organizationLocationParentsOf = locationParentsOf;
196
197export const normalizeLocation = (
198 location: string | undefined,
199 fallback = DEFAULT_LOCATION,
200) => lastSegment(location ?? fallback).toLowerCase();
201
202export const jsonEqual = (left: unknown, right: unknown) =>
203 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
204
205export const sameText = (left: string | undefined, right: string | undefined) =>
206 (left ?? "") === (right ?? "");
207
208export const updateMaskOf = (...fields: Array<string | undefined>) =>
209 fields.filter((field): field is string => field !== undefined).join(",");
210
211export const toResourceId = (
212 id: string,
213 requested: string | undefined,
214 existing: string | undefined,
215 prefix = "d",
216) =>
217 Effect.gen(function* () {
218 if (requested !== undefined) return requested;
219 if (existing !== undefined) return existing;
220 const generated = yield* createPhysicalName({
221 id,
222 maxLength: MAX_ID_LENGTH,
223 lowercase: true,
224 });
225 const next = /^[a-zA-Z]/.test(generated)
226 ? generated
227 : `${prefix}${generated}`;
228 return next.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, MAX_ID_LENGTH);
229 });
230
231export const toPhysicalId = (
232 id: string,
233 explicit: string | undefined,
234 existing: string | undefined,
235) => toResourceId(id, explicit, existing);
236
237const markerOf = (stack: string, stage: string, id: string) =>
238 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
239
240const fitMarker = (labels: Record<string, string>, maxLength: number) => {
241 let stack = labels[alchemyLabelKeys.stack] ?? "x";
242 let stage = labels[alchemyLabelKeys.stage] ?? "x";
243 let id = labels[alchemyLabelKeys.id] ?? "x";
244 let marker = markerOf(stack, stage, id);
245 while (
246 marker.length > maxLength &&
247 (stack.length > 1 || stage.length > 1 || id.length > 1)
248 ) {
249 if (stack.length >= stage.length && stack.length >= id.length) {
250 stack = stack.slice(0, -1);
251 } else if (stage.length >= id.length) {
252 stage = stage.slice(0, -1);
253 } else {
254 id = id.slice(0, -1);
255 }
256 marker = markerOf(stack, stage, id);
257 }
258 return marker.slice(0, maxLength);
259};
260
261export const encodeOwnership = (
262 labels: Record<string, string>,
263 text: string | undefined,
264 maxLength = MAX_DESCRIPTION_LENGTH,
265): string => {
266 const marker = fitMarker(labels, maxLength);
267 const trimmed = text?.trim();
268 if (!trimmed) return marker.slice(0, maxLength);
269 return `${marker}\n${trimmed}`.slice(0, maxLength);
270};
271
272export const encodeOwnershipLine = (
273 labels: Record<string, string>,
274 text: string | undefined,
275 maxLength = MAX_DISPLAY_NAME_LENGTH,
276): string => {
277 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
278 const reserved =
279 trimmed && trimmed.length > 0
280 ? Math.min(trimmed.length + 1, Math.max(0, maxLength - 24))
281 : 0;
282 const marker = fitMarker(labels, Math.max(24, maxLength - reserved));
283 if (!trimmed) return marker.slice(0, maxLength);
284 return `${marker} ${trimmed}`.slice(0, maxLength);
285};
286
287export const encodeDescription = encodeOwnership;
288export const encodeDisplayName = encodeOwnershipLine;
289
290export const parseOwnership = (
291 text: string | undefined,
292): {
293 labels: Record<string, string>;
294 text: string | undefined;
295} => {
296 if (!text?.startsWith("[alchemy ")) {
297 return { labels: {}, text };
298 }
299 const end = text.indexOf("]");
300 if (end < 0) return { labels: {}, text };
301 const labels: Record<string, string> = {};
302 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
303 const eq = part.indexOf("=");
304 if (eq > 0) {
305 labels[part.slice(0, eq)] = part.slice(eq + 1);
306 }
307 }
308 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
309 return { labels, text: rest.length > 0 ? rest : undefined };
310};
311
312export const hasOwnershipMarker = (text: string | undefined) =>
313 Object.keys(parseOwnership(text).labels).some((key) =>
314 key.startsWith("alchemy-"),
315 );
316
317const prefixMatch = (expected: string, observed: string) =>
318 expected === observed ||
319 expected.startsWith(observed) ||
320 observed.startsWith(expected);
321
322export const ownedByAlchemy = (id: string, text: string | undefined) =>
323 Effect.gen(function* () {
324 const expected = yield* createInternalLabels(id);
325 const { labels } = parseOwnership(text);
326 if (!hasOwnershipMarker(text)) return false;
327 const exact = yield* hasAlchemyLabels(id, labels);
328 if (exact) return true;
329 return (
330 prefixMatch(
331 expected[alchemyLabelKeys.stack] ?? "",
332 labels[alchemyLabelKeys.stack] ?? "",
333 ) &&
334 prefixMatch(
335 expected[alchemyLabelKeys.stage] ?? "",
336 labels[alchemyLabelKeys.stage] ?? "",
337 ) &&
338 prefixMatch(
339 expected[alchemyLabelKeys.id] ?? "",
340 labels[alchemyLabelKeys.id] ?? "",
341 )
342 );
343 });
344
345export const hybridLabels = (labels: Record<string, string>) => {
346 const startLetter = (value: string) =>
347 /^[a-z]/.test(value) ? value : `x${value}`.slice(0, 63);
348 return {
349 [alchemyLabelKeys.stack]: startLetter(
350 labels[alchemyLabelKeys.stack] ?? "x",
351 ),
352 [alchemyLabelKeys.stage]: startLetter(
353 labels[alchemyLabelKeys.stage] ?? "x",
354 ),
355 [alchemyLabelKeys.id]: startLetter(labels[alchemyLabelKeys.id] ?? "x"),
356 };
357};
358
359export const hasHybridOwnership = (
360 labels: Record<string, string | undefined> | null | undefined,
361) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
362
363export const collectPages = <Page, A, E, R>(
364 pages: Stream.Stream<Page, E, R>,
365 items: (page: Page) => readonly A[] | undefined,
366) =>
367 pages.pipe(
368 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
369 Stream.runCollect,
370 Effect.map((chunk) => Array.from(chunk)),
371 );
372
373export const canonical = (value: unknown): unknown => {
374 if (value === undefined || value === null) return undefined;
375 if (typeof value === "boolean" || typeof value === "number") return value;
376 if (typeof value === "string") {
377 return value.length === 0 ? undefined : value;
378 }
379 if (Array.isArray(value)) {
380 const items = value.map(canonical).filter((item) => item !== undefined);
381 return items.length === 0 ? undefined : items;
382 }
383 if (typeof value === "object") {
384 const entries = Object.entries(value as Record<string, unknown>)
385 .map(([key, item]) => [key, canonical(item)] as const)
386 .filter(([, item]) => item !== undefined)
387 .sort(([left], [right]) => left.localeCompare(right));
388 if (entries.length === 0) return undefined;
389 return Object.fromEntries(entries);
390 }
391 return undefined;
392};
393
394export const fingerprint = (value: unknown): string =>
395 JSON.stringify(canonical(value) ?? null);
396
397const parentOf = (name: string) =>
398 name.startsWith("projects/")
399 ? resourcemanager.getProjects({ name }).pipe(
400 Effect.map((resource) => resource.parent),
401 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
402 )
403 : name.startsWith("folders/")
404 ? resourcemanager.getFolders({ name }).pipe(
405 Effect.map((folder) => folder.parent),
406 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
407 )
408 : Effect.succeed(undefined);
409
410export const tryResolveOrganization = () =>
411 Effect.gen(function* () {
412 const fromConfig = yield* optionalSetting("GOOGLE_ORGANIZATION_ID");
413 if (fromConfig) return organizationParent(fromConfig);
414 const env = yield* GcpEnvironment.current;
415 let current: string | undefined = `projects/${env.project}`;
416 for (let i = 0; i < 8; i++) {
417 if (current === undefined) return undefined;
418 if (current.startsWith("organizations/")) return current;
419 current = yield* parentOf(current);
420 }
421 return undefined;
422 });
423
424export const resolveOrganization = (
425 explicit: string | undefined,
426 existing: string | undefined,
427) =>
428 Effect.gen(function* () {
429 if (explicit !== undefined) return organizationParent(explicit);
430 if (existing !== undefined) return organizationParent(existing);
431 const resolved = yield* tryResolveOrganization();
432 if (resolved === undefined) {
433 const env = yield* GcpEnvironment.current;
434 return yield* new OrganizationNotResolved({ project: env.project });
435 }
436 return resolved;
437 });
438
439export const replaceOnIdentity = (changed: boolean) =>
440 changed
441 ? ({ action: "replace" as const, deleteFirst: false } as const)
442 : undefined;
443
444export const replaceOn = (
445 previous: string | undefined,
446 next: string | undefined,
447) =>
448 replaceOnIdentity(
449 previous !== undefined && next !== undefined && previous !== next,
450 );