1import * as cloudsupport from "@distilled.cloud/gcp/cloudsupport_v2";
2import * as pubsub from "@distilled.cloud/gcp/pubsub_v1";
3import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 createInternalLabels,
13 hasAlchemyLabels,
14 sanitizeLabelValue,
15} from "../Labels.ts";
16
17export const COLLECTION = "supportEventSubscriptions";
18export const MAX_TOPIC_ID_LENGTH = 255;
19export const PUBLISHER_ROLE = "roles/pubsub.publisher";
20export const CLOUD_SUPPORT_EVENTS_MEMBER =
21 "serviceAccount:cloud-support-apievents@system.gserviceaccount.com";
22
23export class OrganizationRequired extends Data.TaggedError(
24 "GCP.CloudSupport.OrganizationRequired",
25)<{
26 project: string;
27}> {}
28
29export const lastSegment = (value: string) => {
30 const trimmed = value.replace(/\/+$/, "");
31 const parts = trimmed.split("/");
32 return parts[parts.length - 1] || trimmed;
33};
34
35export const organizationParent = (value: string) =>
36 value.startsWith("organizations/")
37 ? value
38 : `organizations/${lastSegment(value)}`;
39
40export const parentOfName = (name: string) => {
41 const parts = name.split("/").filter((part) => part.length > 0);
42 const index = parts.lastIndexOf(COLLECTION);
43 if (index <= 0) return "";
44 return parts.slice(0, index).join("/");
45};
46
47export const subscriptionIdOf = (name: string) => lastSegment(name);
48
49export const toSubscriptionName = (parent: string, value: string) => {
50 if (value.length === 0) return "";
51 if (value.includes(`/${COLLECTION}/`)) return value;
52 if (parent.length === 0) return value;
53 return `${parent}/${COLLECTION}/${lastSegment(value)}`;
54};
55
56export const sameText = (left: string | undefined, right: string | undefined) =>
57 (left ?? "") === (right ?? "");
58
59export const isDeleted = (
60 subscription: cloudsupport.SupportEventSubscription | undefined,
61) => subscription?.state === "DELETED";
62
63export const expandTopic = (value: string, project: string) => {
64 const trimmed = value.replace(/\/+$/, "");
65 if (trimmed.includes("/topics/")) return trimmed;
66 return `projects/${project}/topics/${lastSegment(trimmed)}`;
67};
68
69export const sameTopic = (
70 left: string | undefined,
71 right: string | undefined,
72) => {
73 if (left === undefined || right === undefined) return left === right;
74 return left === right || lastSegment(left) === lastSegment(right);
75};
76
77export const rfc1035 = (
78 name: string,
79 fallback = "topic",
80 maxLength = MAX_TOPIC_ID_LENGTH,
81): string => {
82 let next = name
83 .toLowerCase()
84 .replace(/[^a-z0-9-]/g, "-")
85 .replace(/-+/g, "-")
86 .replace(/^-+/, "")
87 .replace(/-+$/, "");
88 if (!/^[a-z]/.test(next)) next = `t${next}`;
89 next = next.slice(0, maxLength).replace(/-+$/, "");
90 if (next.length < 3) {
91 next = `${next}${fallback}`.slice(0, maxLength);
92 }
93 if (!/[a-z0-9]$/.test(next)) {
94 next = `${next.slice(0, maxLength - 1)}0`;
95 }
96 return next.slice(0, maxLength);
97};
98
99export const toPhysicalId = (
100 id: string,
101 explicit: string | undefined,
102 existing: string | undefined,
103) =>
104 Effect.gen(function* () {
105 if (explicit !== undefined && explicit.length > 0) {
106 return rfc1035(explicit);
107 }
108 if (existing !== undefined && existing.length > 0) {
109 return lastSegment(existing);
110 }
111 return rfc1035(
112 yield* createPhysicalName({
113 id,
114 maxLength: MAX_TOPIC_ID_LENGTH,
115 lowercase: true,
116 }),
117 );
118 });
119
120const emptyList = <A>() => Effect.succeed([] as A[]);
121
122const parentOfResource = (name: string) =>
123 name.startsWith("projects/")
124 ? resourcemanager.getProjects({ name }).pipe(
125 Effect.map((resource) => resource.parent),
126 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
127 )
128 : name.startsWith("folders/")
129 ? resourcemanager.getFolders({ name }).pipe(
130 Effect.map((folder) => folder.parent),
131 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
132 )
133 : Effect.succeed(undefined);
134
135export const tryResolveOrganization = () =>
136 Effect.gen(function* () {
137 const env = yield* GcpEnvironment.current;
138 let current: string | undefined = `projects/${env.project}`;
139 for (let i = 0; i < 8; i++) {
140 if (current === undefined) return undefined;
141 if (current.startsWith("organizations/")) return current;
142 current = yield* parentOfResource(current);
143 }
144 return undefined;
145 });
146
147export const resolveOrganization = (
148 explicit: string | undefined,
149 existing: string | undefined,
150) =>
151 Effect.gen(function* () {
152 if (explicit !== undefined && explicit.length > 0) {
153 return organizationParent(explicit);
154 }
155 if (existing !== undefined && existing.length > 0) {
156 return organizationParent(existing);
157 }
158 const resolved = yield* tryResolveOrganization();
159 if (resolved === undefined) {
160 const env = yield* GcpEnvironment.current;
161 return yield* new OrganizationRequired({ project: env.project });
162 }
163 return resolved;
164 });
165
166export const listOrganizationParents = () =>
167 Effect.gen(function* () {
168 const resolved = yield* tryResolveOrganization();
169 return resolved === undefined ? [] : [resolved];
170 });
171
172export const getByName = (name: string) =>
173 name.length === 0
174 ? Effect.succeed(undefined)
175 : cloudsupport.getOrganizationsSupportEventSubscriptions({ name }).pipe(
176 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
177 Effect.map((subscription) =>
178 isDeleted(subscription) ? undefined : subscription,
179 ),
180 );
181
182export const getDeletedByName = (name: string) =>
183 name.length === 0
184 ? Effect.succeed(undefined)
185 : cloudsupport
186 .getOrganizationsSupportEventSubscriptions({ name })
187 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
188
189export const getTopic = (name: string) =>
190 name.length === 0
191 ? Effect.succeed(undefined)
192 : pubsub
193 .getProjectsTopics({ topic: name })
194 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
195
196export const grantCloudSupportPublisher = (topic: string) =>
197 Effect.gen(function* () {
198 if (topic.length === 0) return;
199 const policy = yield* pubsub.getIamPolicyProjectsTopics({
200 resource: topic,
201 });
202 const bindings = (policy.bindings ?? []).map((binding) => ({
203 ...binding,
204 members: [...(binding.members ?? [])],
205 }));
206 const publisher = bindings.find(
207 (binding) => binding.role === PUBLISHER_ROLE,
208 );
209 if (publisher?.members?.includes(CLOUD_SUPPORT_EVENTS_MEMBER)) return;
210 if (publisher) {
211 publisher.members = [
212 ...(publisher.members ?? []),
213 CLOUD_SUPPORT_EVENTS_MEMBER,
214 ];
215 } else {
216 bindings.push({
217 role: PUBLISHER_ROLE,
218 members: [CLOUD_SUPPORT_EVENTS_MEMBER],
219 });
220 }
221 yield* pubsub.setIamPolicyProjectsTopics({
222 resource: topic,
223 body: { policy: { ...policy, bindings } },
224 });
225 }).pipe(
226 Effect.retry({
227 while: (error) => error._tag === "Conflict",
228 times: 5,
229 schedule: Schedule.spaced("2 seconds"),
230 }),
231 );
232
233export const ensureTopic = (name: string, labels: Record<string, string>) =>
234 Effect.gen(function* () {
235 let current = yield* getTopic(name);
236 if (current === undefined) {
237 const created = yield* pubsub
238 .createProjectsTopics({
239 name,
240 body: { labels },
241 })
242 .pipe(Effect.catchTag("Conflict", () => getTopic(name)));
243 current = created ?? undefined;
244 }
245 if (current === undefined) return undefined;
246 const observed = tagRecord(current.labels);
247 const desired = { ...observed, ...labels };
248 const changed = Object.entries(desired).some(
249 ([key, value]) => observed[key] !== value,
250 );
251 if (changed) {
252 current = yield* pubsub
253 .patchProjectsTopics({
254 name,
255 body: {
256 topic: { name, labels: desired },
257 updateMask: "labels",
258 },
259 })
260 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(current)));
261 }
262 yield* grantCloudSupportPublisher(name);
263 return current;
264 });
265
266export const deleteManagedTopic = (name: string | undefined) => {
267 if (name === undefined || name.length === 0) return Effect.void;
268 return pubsub
269 .deleteProjectsTopics({ topic: name })
270 .pipe(Effect.catchTag("NotFound", () => Effect.void));
271};
272
273export const ownershipLabels = (id: string) =>
274 Effect.gen(function* () {
275 const internal = yield* createInternalLabels(id);
276 return {
277 ...internal,
278 "alchemy-cloudsupport": sanitizeLabelValue("subscription"),
279 };
280 });
281
282export const hasOwnershipLabels = (
283 labels: Record<string, string | undefined> | null | undefined,
284) =>
285 Object.keys(labels ?? {}).some(
286 (key) =>
287 key.startsWith("alchemy-cloudsupport") || key.startsWith("alchemy-"),
288 );
289
290export const ownedByTopicLabels = (
291 id: string,
292 labels: Record<string, string | undefined> | null | undefined,
293) =>
294 Effect.gen(function* () {
295 if (!hasOwnershipLabels(labels)) return false;
296 return yield* hasAlchemyLabels(id, tagRecord(labels));
297 });
298
299export const listSubscriptions = (
300 parent: string,
301 options?: { showDeleted?: boolean; filter?: string },
302) =>
303 parent.length === 0
304 ? emptyList<cloudsupport.SupportEventSubscription>()
305 : cloudsupport.listOrganizationsSupportEventSubscriptions
306 .pages({
307 parent,
308 pageSize: 200,
309 showDeleted: options?.showDeleted,
310 filter: options?.filter,
311 })
312 .pipe(
313 Stream.flatMap((page) =>
314 Stream.fromIterable(page.supportEventSubscriptions ?? []),
315 ),
316 Stream.runCollect,
317 Effect.map((chunk) => Array.from(chunk)),
318 Effect.catchTag("NotFound", () =>
319 emptyList<cloudsupport.SupportEventSubscription>(),
320 ),
321 );
322
323export const listOwnedSubscriptions = (parent: string) =>
324 Effect.gen(function* () {
325 const subscriptions = yield* listSubscriptions(parent);
326 const owned: cloudsupport.SupportEventSubscription[] = [];
327 for (const subscription of subscriptions) {
328 if (isDeleted(subscription)) continue;
329 const topic = subscription.pubSubTopic;
330 if (topic === undefined || topic.length === 0) continue;
331 const observed = yield* getTopic(topic);
332 if (hasOwnershipLabels(observed?.labels)) {
333 owned.push(subscription);
334 }
335 }
336 return owned;
337 });
338
339export const findOwnedSubscription = (
340 parent: string,
341 id: string,
342 name: string | undefined,
343 topicName: string | undefined,
344) =>
345 Effect.gen(function* () {
346 const existing = yield* getByName(name ?? "");
347 if (existing !== undefined) return existing;
348 const deleted = yield* getDeletedByName(name ?? "");
349 const listed = yield* listSubscriptions(parent, {
350 showDeleted: true,
351 filter:
352 topicName !== undefined && topicName.length > 0
353 ? `pub_sub_topic="${topicName}"`
354 : undefined,
355 });
356 const candidates = [...(deleted !== undefined ? [deleted] : []), ...listed];
357 const seen = new Set<string>();
358 for (const subscription of candidates) {
359 const key = subscription.name ?? "";
360 if (key.length > 0) {
361 if (seen.has(key)) continue;
362 seen.add(key);
363 }
364 if (name !== undefined && name.length > 0 && subscription.name === name) {
365 return subscription;
366 }
367 if (
368 topicName !== undefined &&
369 sameTopic(subscription.pubSubTopic, topicName)
370 ) {
371 const topic = yield* getTopic(subscription.pubSubTopic ?? "");
372 if (yield* ownedByTopicLabels(id, topic?.labels)) {
373 return subscription;
374 }
375 }
376 }
377 const all = yield* listSubscriptions(parent, { showDeleted: true });
378 for (const subscription of all) {
379 const topic = yield* getTopic(subscription.pubSubTopic ?? "");
380 if (yield* ownedByTopicLabels(id, topic?.labels)) {
381 return subscription;
382 }
383 }
384 return undefined;
385 });
386
387export const undeleteSubscription = (name: string) =>
388 cloudsupport
389 .undeleteOrganizationsSupportEventSubscriptions({ name, body: {} })
390 .pipe(
391 // Already live or already gone: observe instead.
392 Effect.catchTag(["NotFound", "Conflict"], () => getDeletedByName(name)),
393 );