1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitRegionOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 alchemyLabelKeys,
14 createInternalLabels,
15 hasAlchemyLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18
19export type RegionNotificationEndpointGrpcSettings = {
20 /**
21 * gRPCLB DNS name of the notification service
22 * (e.g. `example.googleapis.com` or `health.example.com:443`).
23 */
24 endpoint: string;
25 /**
26 * Seconds spent retrying notifications until a successful response.
27 * Default 30, max 1200.
28 */
29 retryDurationSec?: number;
30 /**
31 * Optional value written to the gRPC `name` field.
32 */
33 payloadName?: string;
34 /**
35 * Optional authority header sent with notifications.
36 */
37 authority?: string;
38 /**
39 * How often to resend a full update of unhealthy backends (600–3600s).
40 * Regional endpoints only.
41 */
42 resendInterval?: compute.Duration;
43};
44
45export type RegionNotificationEndpointProps = {
46 /**
47 * Endpoint name (RFC1035, 1-63 characters). If omitted, a unique name
48 * is generated from the stack, stage, and logical id. Changing it
49 * replaces the resource.
50 */
51 notificationEndpointName?: string;
52 /**
53 * Region the endpoint lives in (e.g. `us-central1`). Immutable —
54 * changing it replaces the resource. `US-CENTRAL1` is accepted and
55 * normalized to `us-central1`.
56 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
57 */
58 region?: string;
59 /**
60 * Optional description. Compute NotificationEndpoint has no labels
61 * field and no update API, so Alchemy ownership is stored in a
62 * `[alchemy …]` prefix and any description change replaces the
63 * endpoint.
64 */
65 description?: string;
66 /**
67 * gRPC notification settings. Immutable — changing any field replaces
68 * the endpoint.
69 */
70 grpcSettings: RegionNotificationEndpointGrpcSettings;
71};
72
73export type RegionNotificationEndpoint = Resource<
74 "GCP.Compute.RegionNotificationEndpoint",
75 RegionNotificationEndpointProps,
76 {
77 /** NotificationEndpoint name. */
78 notificationEndpointName: string;
79 /** Project id. */
80 project: string;
81 /** Region short name (`us-central1`). */
82 region: string;
83 /** User description with the Alchemy ownership prefix stripped. */
84 description: string | undefined;
85 /** gRPC endpoint DNS name. */
86 grpcEndpoint: string | undefined;
87 /** Retry duration in seconds. */
88 retryDurationSec: number | undefined;
89 /** Optional gRPC payload name. */
90 payloadName: string | undefined;
91 /** Optional authority header. */
92 authority: string | undefined;
93 /** Full-update resend interval, if set. */
94 resendInterval: compute.Duration | undefined;
95 /** Server-defined URL. */
96 selfLink: string | undefined;
97 /** Server-assigned numeric id. */
98 notificationEndpointId: string | undefined;
99 /** RFC3339 creation timestamp. */
100 creationTimestamp: string | undefined;
101 /** Resource kind. */
102 kind: string | undefined;
103 },
104 never,
105 Providers
106>;
107
108/**
109 * A regional Compute Engine notification endpoint.
110 *
111 * Notification endpoints receive gRPC callbacks when a health-check
112 * service detects backend status changes. This resource maps to the
113 * `regionNotificationEndpoints` collection. There is no update API and
114 * no labels field — Alchemy stamps ownership into the description so
115 * nuke can find leaked endpoints. Changing any user-facing field
116 * replaces the resource.
117 *
118 * ### Creating a Notification Endpoint
119 * **Example:** Generated name
120 * ```typescript
121 * const endpoint = yield* GCP.Compute.RegionNotificationEndpoint("Health", {
122 * region: "us-central1",
123 * grpcSettings: { endpoint: "health.example.com:443" },
124 * });
125 * ```
126 *
127 * **Example:** Named endpoint with retry settings
128 * ```typescript
129 * const endpoint = yield* GCP.Compute.RegionNotificationEndpoint("Health", {
130 * notificationEndpointName: "app-health",
131 * region: "us-central1",
132 * description: "regional health callbacks",
133 * grpcSettings: {
134 * endpoint: "health.example.com:443",
135 * retryDurationSec: 30,
136 * },
137 * });
138 * ```
139 *
140 * @resource
141 * @category Compute
142 */
143export const RegionNotificationEndpoint = Resource<RegionNotificationEndpoint>(
144 "GCP.Compute.RegionNotificationEndpoint",
145);
146
147export class RegionNotificationEndpointNotResolved extends Data.TaggedError(
148 "GCP.Compute.RegionNotificationEndpointNotResolved",
149)<{
150 notificationEndpointName: string;
151 region: string;
152}> {}
153
154const lastSegment = (value: string | undefined) => {
155 if (value === undefined || value.length === 0) return "";
156 const trimmed = value.replace(/\/+$/, "");
157 const parts = trimmed.split("/");
158 return parts[parts.length - 1] || trimmed;
159};
160
161const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
162 lastSegment(region ?? defaultRegion).toLowerCase();
163
164const toName = (id: string, name: string | undefined, existing?: string) =>
165 Effect.gen(function* () {
166 if (name !== undefined) return name;
167 if (existing !== undefined) return existing;
168 const generated = yield* createPhysicalName({
169 id,
170 maxLength: 63,
171 lowercase: true,
172 });
173 return /^[a-z]/.test(generated) ? generated : `n${generated}`.slice(0, 63);
174 });
175
176const encodeDescription = (
177 labels: Record<string, string>,
178 description: string | undefined,
179): string => {
180 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
181 return description ? `${marker}\n${description}` : marker;
182};
183
184const parseDescription = (
185 description: string | undefined,
186): {
187 labels: Record<string, string>;
188 description: string | undefined;
189} => {
190 if (!description?.startsWith("[alchemy ")) {
191 return { labels: {}, description };
192 }
193 const end = description.indexOf("]");
194 if (end < 0) return { labels: {}, description };
195 const labels: Record<string, string> = {};
196 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
197 const eq = part.indexOf("=");
198 if (eq > 0) {
199 labels[part.slice(0, eq)] = part.slice(eq + 1);
200 }
201 }
202 const rest = description.slice(end + 1).replace(/^\n/, "");
203 return { labels, description: rest.length > 0 ? rest : undefined };
204};
205
206const hasOwnershipMarker = (description: string | undefined) =>
207 Object.keys(parseDescription(description).labels).some((key) =>
208 key.startsWith("alchemy-"),
209 );
210
211const sameInterval = (
212 left: compute.Duration | undefined,
213 right: compute.Duration | undefined,
214) =>
215 (left?.seconds ?? "") === (right?.seconds ?? "") &&
216 (left?.nanos ?? 0) === (right?.nanos ?? 0);
217
218const toAttrs = (endpoint: compute.NotificationEndpoint, project: string) => {
219 const parsed = parseDescription(endpoint.description);
220 return {
221 notificationEndpointName: endpoint.name ?? endpoint.id ?? "",
222 project,
223 region: lastSegment(endpoint.region).toLowerCase(),
224 description: parsed.description,
225 grpcEndpoint: endpoint.grpcSettings?.endpoint,
226 retryDurationSec: endpoint.grpcSettings?.retryDurationSec,
227 payloadName: endpoint.grpcSettings?.payloadName,
228 authority: endpoint.grpcSettings?.authority,
229 resendInterval: endpoint.grpcSettings?.resendInterval,
230 selfLink: endpoint.selfLink,
231 notificationEndpointId: endpoint.id,
232 creationTimestamp: endpoint.creationTimestamp,
233 kind: endpoint.kind,
234 };
235};
236
237const getByName = (
238 project: string,
239 region: string,
240 notificationEndpoint: string,
241) =>
242 compute
243 .getRegionNotificationEndpoints({
244 project,
245 region,
246 notificationEndpoint,
247 })
248 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
249
250const immutableChanged = (
251 news: RegionNotificationEndpointProps,
252 olds: RegionNotificationEndpointProps | undefined,
253 output: RegionNotificationEndpoint["Attributes"] | undefined,
254) => {
255 const previousDescription = olds?.description ?? output?.description ?? "";
256 if ((news.description ?? "") !== previousDescription) return true;
257 const previousEndpoint =
258 olds?.grpcSettings.endpoint ?? output?.grpcEndpoint ?? "";
259 if (news.grpcSettings.endpoint !== previousEndpoint) return true;
260 const previousRetry =
261 olds?.grpcSettings.retryDurationSec ?? output?.retryDurationSec;
262 if (
263 news.grpcSettings.retryDurationSec !== undefined &&
264 news.grpcSettings.retryDurationSec !== previousRetry
265 ) {
266 return true;
267 }
268 const previousPayload =
269 olds?.grpcSettings.payloadName ?? output?.payloadName ?? "";
270 if ((news.grpcSettings.payloadName ?? "") !== previousPayload) return true;
271 const previousAuthority =
272 olds?.grpcSettings.authority ?? output?.authority ?? "";
273 if ((news.grpcSettings.authority ?? "") !== previousAuthority) return true;
274 if (
275 news.grpcSettings.resendInterval !== undefined &&
276 !sameInterval(
277 news.grpcSettings.resendInterval,
278 olds?.grpcSettings.resendInterval ?? output?.resendInterval,
279 )
280 ) {
281 return true;
282 }
283 return false;
284};
285
286export const RegionNotificationEndpointProvider = () =>
287 Provider.succeed(RegionNotificationEndpoint, {
288 stables: [
289 "notificationEndpointName",
290 "project",
291 "region",
292 "notificationEndpointId",
293 "selfLink",
294 "creationTimestamp",
295 ],
296
297 diff: Effect.fn(function* ({ news, olds, output }) {
298 if (!isResolved(news)) return undefined;
299 const env = yield* GcpEnvironment.current;
300 const previousName =
301 olds?.notificationEndpointName ?? output?.notificationEndpointName;
302 const nextName = news.notificationEndpointName ?? previousName;
303 const previousRegion = normalizeRegion(
304 olds?.region ?? output?.region,
305 env.region,
306 );
307 const nextRegion = normalizeRegion(
308 news.region ?? output?.region,
309 env.region,
310 );
311 const regionChanged = previousRegion !== nextRegion;
312 const nameChanged =
313 previousName !== undefined &&
314 nextName !== undefined &&
315 previousName !== nextName;
316 if (
317 nameChanged ||
318 regionChanged ||
319 immutableChanged(news, olds, output)
320 ) {
321 return {
322 action: "replace" as const,
323 deleteFirst: !regionChanged,
324 };
325 }
326 return undefined;
327 }),
328
329 read: Effect.fn(function* ({ id, olds, output }) {
330 const env = yield* GcpEnvironment.current;
331 const notificationEndpointName = yield* toName(
332 id,
333 olds?.notificationEndpointName,
334 output?.notificationEndpointName,
335 );
336 const region = normalizeRegion(
337 olds?.region ?? output?.region,
338 env.region,
339 );
340 const existing = yield* getByName(
341 env.project,
342 region,
343 notificationEndpointName,
344 );
345 if (existing === undefined) return undefined;
346 const attrs = toAttrs(existing, env.project);
347 const { labels } = parseDescription(existing.description);
348 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
349 }),
350
351 list: () =>
352 Effect.gen(function* () {
353 const env = yield* GcpEnvironment.current;
354 const pages = yield* compute.aggregatedListRegionNotificationEndpoints
355 .pages({
356 project: env.project,
357 returnPartialSuccess: true,
358 maxResults: 500,
359 })
360 .pipe(Stream.runCollect);
361 return Array.from(pages).flatMap((page) =>
362 Object.values(page.items ?? {}).flatMap((scoped) =>
363 (scoped?.resources ?? [])
364 .filter((endpoint) => hasOwnershipMarker(endpoint.description))
365 .map((endpoint) => toAttrs(endpoint, env.project)),
366 ),
367 );
368 }),
369
370 reconcile: Effect.fn(function* ({ id, news, output }) {
371 const env = yield* GcpEnvironment.current;
372 const notificationEndpointName = yield* toName(
373 id,
374 news.notificationEndpointName,
375 output?.notificationEndpointName,
376 );
377 const region = normalizeRegion(news.region ?? output?.region, env.region);
378 const ownership = yield* createInternalLabels(id);
379 const desiredDescription = encodeDescription(ownership, news.description);
380
381 let current = yield* getByName(
382 env.project,
383 region,
384 notificationEndpointName,
385 );
386
387 if (current === undefined) {
388 const grpcSettings: compute.NotificationEndpointGrpcSettings = {
389 endpoint: news.grpcSettings.endpoint,
390 };
391 if (news.grpcSettings.retryDurationSec !== undefined) {
392 grpcSettings.retryDurationSec = news.grpcSettings.retryDurationSec;
393 }
394 if (news.grpcSettings.payloadName !== undefined) {
395 grpcSettings.payloadName = news.grpcSettings.payloadName;
396 }
397 if (news.grpcSettings.authority !== undefined) {
398 grpcSettings.authority = news.grpcSettings.authority;
399 }
400 if (news.grpcSettings.resendInterval !== undefined) {
401 grpcSettings.resendInterval = news.grpcSettings.resendInterval;
402 }
403 yield* compute
404 .insertRegionNotificationEndpoints({
405 project: env.project,
406 region,
407 body: {
408 name: notificationEndpointName,
409 description: desiredDescription,
410 grpcSettings,
411 },
412 })
413 .pipe(
414 Effect.flatMap((operation) =>
415 waitRegionOperation(env.project, region, operation, {
416 ignore: ["RESOURCE_ALREADY_EXISTS"],
417 }),
418 ),
419 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
420 );
421 current = yield* getByName(
422 env.project,
423 region,
424 notificationEndpointName,
425 );
426 }
427
428 if (current === undefined) {
429 return yield* new RegionNotificationEndpointNotResolved({
430 notificationEndpointName,
431 region,
432 });
433 }
434
435 return toAttrs(current, env.project);
436 }),
437
438 delete: Effect.fn(function* ({ output }) {
439 const env = yield* GcpEnvironment.current;
440 const region = normalizeRegion(output.region, env.region);
441 const operation = yield* compute
442 .deleteRegionNotificationEndpoints({
443 project: env.project,
444 region,
445 notificationEndpoint: output.notificationEndpointName,
446 })
447 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
448 if (operation !== undefined) {
449 yield* waitRegionOperation(env.project, region, operation).pipe(
450 Effect.catchTag("NotFound", () => Effect.void),
451 );
452 }
453 }),
454 });