1import * as cloudasset from "@distilled.cloud/gcp/cloudasset_v1";
2import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
3import * as pubsub from "@distilled.cloud/gcp/pubsub_v1";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import { alchemyLabelKeys, hasAlchemyLabels } from "../Labels.ts";
9
10export const MAX_ID_LENGTH = 63;
11export const MAX_SAVED_QUERY_DESCRIPTION = 255;
12export const NO_OP_EXPRESSION = "true";
13export const PUBLISHER_ROLE = "roles/pubsub.publisher";
14
15export const lastSegment = (value: string) => {
16 const trimmed = value.replace(/\/+$/, "");
17 const parts = trimmed.split("/");
18 return parts[parts.length - 1] || trimmed;
19};
20
21export const parentOf = (name: string, collection: string) => {
22 const parts = name.split("/").filter((part) => part.length > 0);
23 const at = parts.lastIndexOf(collection);
24 return at > 0 ? parts.slice(0, at).join("/") : parts.slice(0, -1).join("/");
25};
26
27export const projectParent = (project: string) => `projects/${project}`;
28
29/** Cloud Asset get/delete names require a numeric project, folder, or org. */
30export const scopeParent = (project: string, explicit?: string) =>
31 Effect.gen(function* () {
32 if (explicit !== undefined && explicit.length > 0) {
33 if (!explicit.startsWith("projects/"))
34 return explicit.replace(/\/+$/, "");
35 const id = lastSegment(explicit);
36 if (/^\d+$/.test(id)) return `projects/${id}`;
37 const number = yield* projectNumberOf(id);
38 return `projects/${number}`;
39 }
40 const number = yield* projectNumberOf(project);
41 return `projects/${number}`;
42 });
43
44export const rfc1035 = (name: string, maxLength = MAX_ID_LENGTH): string => {
45 let next = name
46 .toLowerCase()
47 .replace(/[^a-z0-9-]/g, "-")
48 .replace(/-+/g, "-")
49 .replace(/^-+|-+$/g, "");
50 if (!/^[a-z]/.test(next)) next = `a${next}`;
51 next = next.slice(0, maxLength).replace(/-+$/g, "");
52 if (next.length < 4) next = `${next}feed`.slice(0, maxLength);
53 if (next.length === 0) return "feed";
54 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, maxLength - 1)}0`;
55 return next.slice(0, maxLength);
56};
57
58export const toPhysicalId = (
59 id: string,
60 explicit: string | undefined,
61 existing: string | undefined,
62 maxLength = MAX_ID_LENGTH,
63) =>
64 Effect.gen(function* () {
65 if (explicit !== undefined) return rfc1035(explicit, maxLength);
66 if (existing !== undefined) return existing;
67 return rfc1035(
68 yield* createPhysicalName({ id, maxLength, lowercase: true }),
69 maxLength,
70 );
71 });
72
73export const sameText = (left: string | undefined, right: string | undefined) =>
74 (left ?? "") === (right ?? "");
75
76export const sortedStrings = (values: readonly string[] | undefined) =>
77 [...(values ?? [])].slice().sort();
78
79export const sameStringList = (
80 left: readonly string[] | undefined,
81 right: readonly string[] | undefined,
82) =>
83 JSON.stringify(sortedStrings(left)) === JSON.stringify(sortedStrings(right));
84
85const canonical = (value: unknown): unknown => {
86 if (value === undefined || value === null) return undefined;
87 if (typeof value === "boolean" || typeof value === "number") return value;
88 if (typeof value === "string") return value.length === 0 ? undefined : value;
89 if (Array.isArray(value)) {
90 const items = value.map(canonical).filter((item) => item !== undefined);
91 return items.length === 0 ? undefined : items;
92 }
93 if (typeof value === "object") {
94 const entries = Object.entries(value as Record<string, unknown>)
95 .map(([key, item]) => [key, canonical(item)] as const)
96 .filter(([, item]) => item !== undefined)
97 .sort(([left], [right]) => left.localeCompare(right));
98 if (entries.length === 0) return undefined;
99 return Object.fromEntries(entries);
100 }
101 return undefined;
102};
103
104export const sameJson = (left: unknown, right: unknown) =>
105 JSON.stringify(canonical(left) ?? null) ===
106 JSON.stringify(canonical(right) ?? null);
107
108export const updateMaskOf = (...fields: Array<string | undefined>) =>
109 fields.filter((field): field is string => field !== undefined).join(",");
110
111export const encodeDescription = (
112 labels: Record<string, string>,
113 description: string | undefined,
114 maxLength?: number,
115): string => {
116 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
117 const combined =
118 description && description.length > 0
119 ? `${marker}\n${description}`
120 : marker;
121 return maxLength !== undefined ? combined.slice(0, maxLength) : combined;
122};
123
124export const parseDescription = (
125 description: string | undefined,
126): {
127 labels: Record<string, string>;
128 description: string | undefined;
129} => {
130 if (!description?.startsWith("[alchemy ")) {
131 return { labels: {}, description };
132 }
133 const end = description.indexOf("]");
134 if (end < 0) return { labels: {}, description };
135 const labels: Record<string, string> = {};
136 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
137 const eq = part.indexOf("=");
138 if (eq > 0) {
139 labels[part.slice(0, eq)] = part.slice(eq + 1);
140 }
141 }
142 const rest = description.slice(end + 1).replace(/^\n/, "");
143 return { labels, description: rest.length > 0 ? rest : undefined };
144};
145
146export const hasOwnershipMarker = (description: string | undefined) =>
147 Object.keys(parseDescription(description).labels).some((key) =>
148 key.startsWith("alchemy-"),
149 );
150
151export const ownedByAlchemy = (id: string, description: string | undefined) =>
152 Effect.gen(function* () {
153 const { labels } = parseDescription(description);
154 return yield* hasAlchemyLabels(id, labels);
155 });
156
157export const replaceOn = (
158 previous: string | undefined,
159 next: string | undefined,
160) =>
161 previous !== undefined && next !== undefined && previous !== next
162 ? ({ action: "replace" as const, deleteFirst: false } as const)
163 : undefined;
164
165export const collectPages = <Page, A, E, R>(
166 pages: Stream.Stream<Page, E, R>,
167 items: (page: Page) => readonly A[] | undefined,
168) =>
169 pages.pipe(
170 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
171 Stream.runCollect,
172 Effect.map((chunk) => Array.from(chunk)),
173 );
174
175export const topicResource = (topic: string, project: string) =>
176 topic.includes("/") ? topic : `projects/${project}/topics/${topic}`;
177
178export const projectNumberOf = (project: string) =>
179 resourcemanager.getProjects({ name: `projects/${project}` }).pipe(
180 Effect.map((resource) => {
181 const number = lastSegment(resource.name ?? "");
182 return /^\d+$/.test(number) ? number : project;
183 }),
184 );
185
186export const grantCloudAssetPublisher = (project: string, topic: string) =>
187 Effect.gen(function* () {
188 const resource = topicResource(topic, project);
189 const projectNumber = yield* projectNumberOf(project);
190 const member = `serviceAccount:service-${projectNumber}@gcp-sa-cloudasset.iam.gserviceaccount.com`;
191 const policy = yield* pubsub.getIamPolicyProjectsTopics({ resource });
192 const bindings = (policy.bindings ?? []).map((binding) => ({
193 ...binding,
194 members: [...(binding.members ?? [])],
195 }));
196 const publisher = bindings.find(
197 (binding) => binding.role === PUBLISHER_ROLE,
198 );
199 if (publisher?.members?.includes(member)) return;
200 if (publisher) {
201 publisher.members = [...(publisher.members ?? []), member];
202 } else {
203 bindings.push({ role: PUBLISHER_ROLE, members: [member] });
204 }
205 yield* pubsub.setIamPolicyProjectsTopics({
206 resource,
207 body: { policy: { ...policy, bindings } },
208 });
209 }).pipe(
210 Effect.retry({
211 while: (error) => error._tag === "Conflict",
212 times: 5,
213 schedule: Schedule.spaced("2 seconds"),
214 }),
215 );
216
217/** `feeds.list` is unpaginated: it returns every feed under the parent. */
218export const listFeeds = (parent: string) =>
219 cloudasset.listFeeds({ parent }).pipe(Effect.map((page) => page.feeds ?? []));
220
221export const listSavedQueries = (parent: string) =>
222 collectPages(
223 cloudasset.listSavedQueries.pages({ parent, pageSize: 100 }),
224 (page) => page.savedQueries,
225 );