1import * as monitoring from "@distilled.cloud/gcp/monitoring_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { tagRecord } from "../../Tags.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 createInternalLabels,
14 diffLabels,
15 hasAlchemyLabels,
16 stripInternalLabels,
17 toLabels,
18} from "../Labels.ts";
19import type { Providers } from "../Providers.ts";
20
21const MAX_DISPLAY_NAME_LENGTH = 512;
22
23export type NotificationChannelProps = {
24 /**
25 * Channel type. Must match a `NotificationChannelDescriptor.type`
26 * (`email`, `webhook_tokenauth`, `pubsub`, `slack`, …). Immutable —
27 * changing it replaces the channel.
28 */
29 type: string;
30 /**
31 * Human-readable display name. If omitted, a unique name is generated
32 * from the stack, stage, and logical id. Limited to 512 Unicode
33 * characters.
34 */
35 displayName?: string;
36 /**
37 * Human-readable description. Limited to 1024 Unicode characters.
38 */
39 description?: string;
40 /**
41 * Type-specific configuration (for example `email_address` for `email`,
42 * `url` for `webhook_tokenauth`). Not used for Alchemy ownership —
43 * see `userLabels`.
44 */
45 labels?: Record<string, string>;
46 /**
47 * User labels. Alchemy ownership labels are merged in automatically.
48 */
49 userLabels?: Record<string, string>;
50 /**
51 * Whether notifications are forwarded to this channel.
52 * @default true
53 */
54 enabled?: boolean;
55};
56
57export type NotificationChannel = Resource<
58 "GCP.Monitoring.NotificationChannel",
59 NotificationChannelProps,
60 {
61 /** Full resource name `projects/{project}/notificationChannels/{channel}`. */
62 name: string;
63 /** Server-assigned channel id (last path segment). */
64 notificationChannelId: string;
65 /** Project id. */
66 project: string;
67 /** Channel type (`email`, `webhook_tokenauth`, …). */
68 type: string;
69 /** Human-readable display name. */
70 displayName: string | undefined;
71 /** Human-readable description. */
72 description: string | undefined;
73 /** Type-specific configuration labels. */
74 labels: Record<string, string>;
75 /** User labels (Alchemy ownership labels stripped). */
76 userLabels: Record<string, string>;
77 /** Whether notifications are forwarded to this channel. */
78 enabled: boolean;
79 /** Verification status (`UNVERIFIED`, `VERIFIED`, or unset). */
80 verificationStatus: string | undefined;
81 },
82 never,
83 Providers
84>;
85
86/**
87 * A Cloud Monitoring notification channel — email, webhook, Pub/Sub, or
88 * another supported delivery type.
89 *
90 * Channel ids are assigned by the API. Alchemy stamps ownership into
91 * `userLabels` so `list` / `pnpm nuke:gcp` can find them. Changing `type`
92 * replaces the channel. Display name, description, labels, user labels,
93 * and enabled update in place.
94 *
95 * ### Creating a Channel
96 * **Example:** Email channel
97 * ```typescript
98 * const channel = yield* GCP.Monitoring.NotificationChannel("Alerts", {
99 * type: "email",
100 * labels: { email_address: "alerts@example.com" },
101 * });
102 * ```
103 *
104 * **Example:** Webhook channel with user labels
105 * ```typescript
106 * const channel = yield* GCP.Monitoring.NotificationChannel("Hooks", {
107 * type: "webhook_tokenauth",
108 * displayName: "incident webhook",
109 * labels: { url: "https://example.com/hooks/alerts" },
110 * userLabels: { env: "prod" },
111 * });
112 * ```
113 *
114 * ### Updating a Channel
115 * **Example:** Disable delivery without deleting the channel
116 * ```typescript
117 * const channel = yield* GCP.Monitoring.NotificationChannel("Alerts", {
118 * type: "email",
119 * labels: { email_address: "alerts@example.com" },
120 * enabled: false,
121 * });
122 * ```
123 *
124 * @resource
125 * @category Monitoring
126 */
127export const NotificationChannel = Resource<NotificationChannel>(
128 "GCP.Monitoring.NotificationChannel",
129);
130
131export class NotificationChannelNotResolved extends Data.TaggedError(
132 "GCP.Monitoring.NotificationChannelNotResolved",
133)<{
134 name: string;
135}> {}
136
137const lastSegment = (value: string) => {
138 const trimmed = value.replace(/\/+$/, "");
139 const parts = trimmed.split("/");
140 return parts[parts.length - 1] || trimmed;
141};
142
143const parentOf = (project: string) => `projects/${project}`;
144
145const userFacingLabels = (
146 labels: Record<string, string | undefined> | null | undefined,
147): Record<string, string> => stripInternalLabels(tagRecord(labels));
148
149const channelLabels = (
150 labels: Record<string, string | undefined> | null | undefined,
151): Record<string, string> => tagRecord(labels);
152
153const recordsEqual = (
154 left: Record<string, string>,
155 right: Record<string, string>,
156) => {
157 const leftKeys = Object.keys(left).sort();
158 const rightKeys = Object.keys(right).sort();
159 return (
160 leftKeys.length === rightKeys.length &&
161 leftKeys.every(
162 (key, index) => key === rightKeys[index] && left[key] === right[key],
163 )
164 );
165};
166
167const toDisplayName = (
168 id: string,
169 displayName: string | undefined,
170 existing?: string,
171) =>
172 Effect.gen(function* () {
173 return (
174 displayName ??
175 existing ??
176 (yield* createPhysicalName({
177 id,
178 maxLength: MAX_DISPLAY_NAME_LENGTH,
179 lowercase: true,
180 }))
181 );
182 });
183
184const toAttrs = (channel: monitoring.NotificationChannel, project: string) => {
185 const name = channel.name ?? "";
186 return {
187 name,
188 notificationChannelId: lastSegment(name),
189 project,
190 type: channel.type ?? "",
191 displayName: channel.displayName,
192 description: channel.description,
193 labels: channelLabels(channel.labels),
194 userLabels: userFacingLabels(channel.userLabels),
195 enabled: channel.enabled !== false,
196 verificationStatus: channel.verificationStatus,
197 };
198};
199
200const getByName = (name: string) =>
201 monitoring
202 .getProjectsNotificationChannels({ name })
203 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
204
205const listOwned = (project: string) =>
206 monitoring.listProjectsNotificationChannels
207 .pages({
208 name: parentOf(project),
209 pageSize: 1000,
210 })
211 .pipe(
212 Stream.flatMap((page) =>
213 Stream.fromIterable(page.notificationChannels ?? []),
214 ),
215 Stream.filter((channel) =>
216 Object.keys(channel.userLabels ?? {}).some((key) =>
217 key.startsWith("alchemy-"),
218 ),
219 ),
220 Stream.map((channel) => toAttrs(channel, project)),
221 Stream.runCollect,
222 Effect.map((chunk) => Array.from(chunk)),
223 Effect.catchTag("NotFound", () => Effect.succeed([])),
224 );
225
226const findOwned = (project: string, id: string) =>
227 Effect.gen(function* () {
228 const owned = yield* monitoring.listProjectsNotificationChannels
229 .pages({
230 name: parentOf(project),
231 pageSize: 1000,
232 })
233 .pipe(
234 Stream.flatMap((page) =>
235 Stream.fromIterable(page.notificationChannels ?? []),
236 ),
237 Stream.runCollect,
238 Effect.map((chunk) => Array.from(chunk)),
239 Effect.catchTag("NotFound", () => Effect.succeed([])),
240 );
241 for (const channel of owned) {
242 if (yield* hasAlchemyLabels(id, tagRecord(channel.userLabels))) {
243 return channel;
244 }
245 }
246 return undefined;
247 });
248
249const observe = (project: string, id: string, name: string | undefined) =>
250 Effect.gen(function* () {
251 if (name !== undefined) {
252 const existing = yield* getByName(name);
253 if (existing !== undefined) return existing;
254 }
255 return yield* findOwned(project, id);
256 });
257
258export const NotificationChannelProvider = () =>
259 Provider.succeed(NotificationChannel, {
260 stables: ["name", "notificationChannelId", "project"],
261
262 diff: Effect.fn(function* ({ news, olds, output }) {
263 if (!isResolved(news)) return undefined;
264 const previous = olds?.type ?? output?.type;
265 if (previous !== undefined && news.type !== previous) {
266 return { action: "replace" as const, deleteFirst: true };
267 }
268 return undefined;
269 }),
270
271 read: Effect.fn(function* ({ id, output }) {
272 const env = yield* GcpEnvironment.current;
273 const existing = yield* observe(env.project, id, output?.name);
274 if (existing === undefined) return undefined;
275 const attrs = toAttrs(existing, env.project);
276 return (yield* hasAlchemyLabels(id, tagRecord(existing.userLabels)))
277 ? attrs
278 : Unowned(attrs);
279 }),
280
281 list: () =>
282 Effect.gen(function* () {
283 const env = yield* GcpEnvironment.current;
284 return yield* listOwned(env.project);
285 }),
286
287 reconcile: Effect.fn(function* ({ id, news, output }) {
288 const env = yield* GcpEnvironment.current;
289 const displayName = yield* toDisplayName(
290 id,
291 news.displayName,
292 output?.displayName,
293 );
294 const desiredUserLabels = {
295 ...toLabels(news.userLabels),
296 ...(yield* createInternalLabels(id)),
297 };
298 const desiredLabels = news.labels ?? {};
299 const desiredEnabled = news.enabled !== false;
300 const desiredDescription = news.description ?? "";
301
302 let current = yield* observe(env.project, id, output?.name);
303 if (current !== undefined && (current.type ?? "") !== news.type) {
304 current = undefined;
305 }
306
307 if (current === undefined) {
308 const created = yield* monitoring
309 .createProjectsNotificationChannels({
310 name: parentOf(env.project),
311 body: {
312 type: news.type,
313 displayName,
314 description: news.description,
315 labels: desiredLabels,
316 userLabels: desiredUserLabels,
317 enabled: desiredEnabled,
318 },
319 })
320 .pipe(Effect.catchTag("Conflict", () => findOwned(env.project, id)));
321 current = created ?? undefined;
322 }
323
324 if (current === undefined) {
325 return yield* new NotificationChannelNotResolved({
326 name: output?.name ?? displayName,
327 });
328 }
329
330 const name = current.name ?? output?.name ?? "";
331 const observedUserLabels = tagRecord(current.userLabels);
332 const { upsert, removed } = diffLabels(
333 observedUserLabels,
334 desiredUserLabels,
335 );
336 const userLabelsChanged = upsert.length > 0 || removed.length > 0;
337 const labelsChanged = !recordsEqual(
338 channelLabels(current.labels),
339 desiredLabels,
340 );
341 const displayNameChanged = (current.displayName ?? "") !== displayName;
342 const descriptionChanged =
343 (current.description ?? "") !== desiredDescription;
344 const enabledChanged = (current.enabled !== false) !== desiredEnabled;
345
346 const updateMask = [
347 displayNameChanged ? "display_name" : undefined,
348 descriptionChanged ? "description" : undefined,
349 labelsChanged ? "labels" : undefined,
350 userLabelsChanged ? "user_labels" : undefined,
351 enabledChanged ? "enabled" : undefined,
352 ].filter((field): field is string => field !== undefined);
353
354 if (updateMask.length > 0) {
355 current = yield* monitoring.patchProjectsNotificationChannels({
356 name,
357 updateMask: updateMask.join(","),
358 body: {
359 name,
360 type: news.type,
361 displayName,
362 description: news.description ?? "",
363 labels: desiredLabels,
364 userLabels: desiredUserLabels,
365 enabled: desiredEnabled,
366 },
367 });
368 }
369
370 return toAttrs(current, env.project);
371 }),
372
373 delete: Effect.fn(function* ({ output }) {
374 yield* monitoring
375 .deleteProjectsNotificationChannels({
376 name: output.name,
377 force: true,
378 })
379 .pipe(Effect.catchTag("NotFound", () => Effect.void));
380 }),
381 });