1import * as cloudsupport from "@distilled.cloud/gcp/cloudsupport_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 COLLECTION,
12 deleteManagedTopic,
13 ensureTopic,
14 expandTopic,
15 findOwnedSubscription,
16 getByName,
17 getDeletedByName,
18 getTopic,
19 isDeleted,
20 lastSegment,
21 listOwnedSubscriptions,
22 listOrganizationParents,
23 organizationParent,
24 ownedByTopicLabels,
25 ownershipLabels,
26 parentOfName,
27 resolveOrganization,
28 sameTopic,
29 subscriptionIdOf,
30 toPhysicalId,
31 toSubscriptionName,
32 undeleteSubscription,
33} from "./internal.ts";
34
35export type SupportEventSubscriptionProps = {
36 /**
37 * Parent organization (`organizations/{organization}` or the numeric
38 * id). Defaults to `GOOGLE_ORGANIZATION_ID` or the project's Resource
39 * Manager ancestor. Immutable — changing it replaces the subscription.
40 */
41 organization?: string;
42 /**
43 * Resource name
44 * `organizations/{organization}/supportEventSubscriptions/{subscription}`
45 * or the `{subscription}` id. Server-assigned on create. Immutable —
46 * changing it replaces the subscription.
47 */
48 subscriptionId?: string;
49 /**
50 * Pub/Sub topic that receives Cloud Support events
51 * (`projects/{project}/topics/{topic}` or a topic id). Created with
52 * Alchemy ownership labels when omitted. Updating this value patches
53 * the subscription in place. The topic must live in the same GCP
54 * organization as `organization`.
55 */
56 pubSubTopic?: string;
57};
58
59export type SupportEventSubscription = Resource<
60 "GCP.CloudSupport.SupportEventSubscription",
61 SupportEventSubscriptionProps,
62 {
63 /** Full resource name `organizations/{organization}/supportEventSubscriptions/{subscription}`. */
64 name: string;
65 /** Server-assigned subscription id (last path segment). */
66 subscriptionId: string;
67 /** Parent resource `organizations/{organization}`. */
68 parent: string;
69 /** Organization resource name. */
70 organization: string;
71 /** Organization id (last path segment). */
72 organizationId: string;
73 /** Project id used when the subscription was reconciled. */
74 project: string;
75 /** Pub/Sub topic that receives events. */
76 pubSubTopic: string | undefined;
77 /** Server-reported state (`WORKING`, `FAILING`, `DELETED`). */
78 state: string | undefined;
79 /** Reason the subscription is failing, if `state` is `FAILING`. */
80 failureReason: string | undefined;
81 /** RFC3339 creation timestamp. */
82 createTime: string | undefined;
83 /** RFC3339 last-update timestamp. */
84 updateTime: string | undefined;
85 /** RFC3339 soft-delete timestamp. */
86 deleteTime: string | undefined;
87 /** RFC3339 purge timestamp. */
88 purgeTime: string | undefined;
89 /** Whether Alchemy created the Pub/Sub topic. */
90 managedTopic: boolean;
91 },
92 never,
93 Providers
94>;
95
96/**
97 * A Cloud Support event subscription that publishes case events to a
98 * Pub/Sub topic.
99 *
100 * Support event subscriptions have no labels field, so Alchemy stamps
101 * ownership onto the destination Pub/Sub topic for `list` / nuke and
102 * grants `roles/pubsub.publisher` to
103 * `cloud-support-apievents@system.gserviceaccount.com`. Organization and
104 * subscription id are identity — changing either replaces the
105 * subscription. The Pub/Sub topic updates in place. Creating a
106 * subscription requires the Cloud Support API and Cloud Customer Care on
107 * the organization.
108 *
109 * ### Creating a Support Event Subscription
110 * **Example:** Generated topic
111 * ```typescript
112 * const sub = yield* GCP.CloudSupport.SupportEventSubscription("Events", {
113 * organization: "organizations/123",
114 * });
115 * ```
116 *
117 * **Example:** Existing Pub/Sub topic
118 * ```typescript
119 * const topic = yield* GCP.PubSub.Topic("SupportEvents", {});
120 * const sub = yield* GCP.CloudSupport.SupportEventSubscription("Events", {
121 * organization: "organizations/123",
122 * pubSubTopic: topic.name,
123 * });
124 * ```
125 *
126 * ### Updating a Support Event Subscription
127 * **Example:** Point at a new topic
128 * ```typescript
129 * // Same logical id as before; only the topic changes.
130 * const sub = yield* GCP.CloudSupport.SupportEventSubscription("Events", {
131 * organization: "organizations/123",
132 * pubSubTopic: otherTopic.name,
133 * });
134 * ```
135 *
136 * @resource
137 * @category CloudSupport
138 */
139export const SupportEventSubscription = Resource<SupportEventSubscription>(
140 "GCP.CloudSupport.SupportEventSubscription",
141);
142
143export class SupportEventSubscriptionNotResolved extends Data.TaggedError(
144 "GCP.CloudSupport.SupportEventSubscriptionNotResolved",
145)<{
146 name: string;
147}> {}
148
149const lookupName = (
150 parent: string,
151 subscriptionId: string | undefined,
152 existingName: string | undefined,
153) => {
154 if (existingName !== undefined && existingName.length > 0) {
155 return existingName;
156 }
157 if (subscriptionId !== undefined && subscriptionId.length > 0) {
158 return toSubscriptionName(parent, subscriptionId);
159 }
160 return "";
161};
162
163const toAttrs = (
164 subscription: cloudsupport.SupportEventSubscription,
165 project: string,
166 managedTopic: boolean,
167) => {
168 const name = subscription.name ?? "";
169 const parent = parentOfName(name);
170 const organization = parent.startsWith("organizations/")
171 ? parent
172 : parent.length > 0
173 ? organizationParent(parent)
174 : "";
175 return {
176 name,
177 subscriptionId: subscriptionIdOf(name),
178 parent: parent || organization,
179 organization,
180 organizationId: lastSegment(organization),
181 project,
182 pubSubTopic: subscription.pubSubTopic,
183 state: subscription.state,
184 failureReason: subscription.failureReason,
185 createTime: subscription.createTime,
186 updateTime: subscription.updateTime,
187 deleteTime: subscription.deleteTime,
188 purgeTime: subscription.purgeTime,
189 managedTopic,
190 };
191};
192
193export const SupportEventSubscriptionProvider = () =>
194 Provider.succeed(SupportEventSubscription, {
195 stables: [
196 "name",
197 "subscriptionId",
198 "parent",
199 "organization",
200 "organizationId",
201 "project",
202 "createTime",
203 ],
204
205 diff: Effect.fn(function* ({ news, olds, output }) {
206 if (!isResolved(news)) return undefined;
207 const previousOrg = olds?.organization ?? output?.organization;
208 if (
209 news.organization !== undefined &&
210 previousOrg !== undefined &&
211 organizationParent(news.organization) !==
212 organizationParent(previousOrg)
213 ) {
214 return { action: "replace" as const, deleteFirst: false };
215 }
216 const previousId = olds?.subscriptionId ?? output?.subscriptionId;
217 if (
218 previousId !== undefined &&
219 news.subscriptionId !== undefined &&
220 toSubscriptionName("", news.subscriptionId) !==
221 toSubscriptionName("", previousId) &&
222 news.subscriptionId !== output?.subscriptionId &&
223 news.subscriptionId !== output?.name
224 ) {
225 return { action: "replace" as const, deleteFirst: false };
226 }
227 return undefined;
228 }),
229
230 read: Effect.fn(function* ({ id, olds, output }) {
231 const env = yield* GcpEnvironment.current;
232 const parent = yield* resolveOrganization(
233 olds?.organization ?? output?.organization,
234 output?.parent,
235 );
236 const name = lookupName(
237 parent,
238 olds?.subscriptionId ?? output?.subscriptionId,
239 output?.name,
240 );
241 let existing = yield* getByName(name);
242 if (existing === undefined) {
243 existing = yield* findOwnedSubscription(
244 parent,
245 id,
246 name,
247 olds?.pubSubTopic ?? output?.pubSubTopic,
248 );
249 }
250 if (existing === undefined || isDeleted(existing)) return undefined;
251 const topic = yield* getTopic(existing.pubSubTopic ?? "");
252 const attrs = toAttrs(
253 existing,
254 env.project,
255 output?.managedTopic === true,
256 );
257 const owned =
258 (yield* ownedByTopicLabels(id, topic?.labels)) ||
259 output?.name === attrs.name;
260 return owned ? attrs : Unowned(attrs);
261 }),
262
263 list: () =>
264 Effect.gen(function* () {
265 const env = yield* GcpEnvironment.current;
266 const parents = yield* listOrganizationParents();
267 const pages = yield* Effect.forEach(parents, listOwnedSubscriptions, {
268 concurrency: 2,
269 });
270 return pages
271 .flat()
272 .map((subscription) => toAttrs(subscription, env.project, false));
273 }),
274
275 reconcile: Effect.fn(function* ({ id, news, output }) {
276 const env = yield* GcpEnvironment.current;
277 const parent = yield* resolveOrganization(
278 news.organization ?? output?.organization,
279 output?.parent,
280 );
281 const labels = yield* ownershipLabels(id);
282 const managedTopic = news.pubSubTopic === undefined;
283 const topicName = news.pubSubTopic
284 ? expandTopic(news.pubSubTopic, env.project)
285 : expandTopic(
286 output?.pubSubTopic ??
287 (yield* toPhysicalId(id, undefined, undefined)),
288 env.project,
289 );
290
291 yield* ensureTopic(topicName, labels);
292
293 const lookup = lookupName(
294 parent,
295 news.subscriptionId ?? output?.subscriptionId,
296 output?.name,
297 );
298 let current = yield* findOwnedSubscription(parent, id, lookup, topicName);
299
300 if (isDeleted(current) && current?.name) {
301 current = (yield* undeleteSubscription(current.name)) ?? current;
302 if (isDeleted(current) && current.name) {
303 current = (yield* getByName(current.name)) ?? current;
304 }
305 }
306
307 if (current === undefined || isDeleted(current)) {
308 const created = yield* cloudsupport
309 .createOrganizationsSupportEventSubscriptions({
310 parent,
311 body: {
312 pubSubTopic: topicName,
313 },
314 })
315 .pipe(
316 Effect.catchTag("Conflict", () =>
317 findOwnedSubscription(parent, id, lookup, topicName),
318 ),
319 );
320 current = created ?? undefined;
321 if (current?.name) {
322 current = (yield* getDeletedByName(current.name)) ?? current;
323 }
324 }
325
326 if (current === undefined || current.name === undefined) {
327 return yield* new SupportEventSubscriptionNotResolved({
328 name: lookup || `${parent}/${COLLECTION}`,
329 });
330 }
331
332 if (isDeleted(current)) {
333 current =
334 (yield* undeleteSubscription(current.name)) ??
335 (yield* getByName(current.name)) ??
336 current;
337 }
338
339 if (current === undefined || current.name === undefined) {
340 return yield* new SupportEventSubscriptionNotResolved({
341 name: lookup || `${parent}/${COLLECTION}`,
342 });
343 }
344
345 const name = current.name;
346 if (!sameTopic(current.pubSubTopic, topicName)) {
347 current =
348 yield* cloudsupport.patchOrganizationsSupportEventSubscriptions({
349 name,
350 updateMask: "pub_sub_topic",
351 body: {
352 pubSubTopic: topicName,
353 },
354 });
355 }
356
357 const fresh = (yield* getDeletedByName(name)) ?? current;
358 return toAttrs(fresh, env.project, managedTopic);
359 }),
360
361 delete: Effect.fn(function* ({ output }) {
362 const name =
363 output.name || toSubscriptionName(output.parent, output.subscriptionId);
364 if (name.length > 0) {
365 yield* cloudsupport
366 .deleteOrganizationsSupportEventSubscriptions({ name })
367 .pipe(Effect.catchTag("NotFound", () => Effect.void));
368 }
369 if (output.managedTopic === true) {
370 yield* deleteManagedTopic(output.pubSubTopic);
371 }
372 }),
373 });