1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitGlobalOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { tagRecord } from "../../Tags.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 createInternalLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21
22const DEFAULT_PROTOCOL = "HTTP";
23const DEFAULT_SCHEME = "EXTERNAL";
24const DEFAULT_TIMEOUT_SEC = 30;
25
26export type BackendServiceBackend = {
27 /** Fully-qualified instance group or NEG URL. */
28 group: string;
29 /** Optional backend description. */
30 description?: string;
31 /** Balancing mode (`UTILIZATION`, `RATE`, `CONNECTION`, …). */
32 balancingMode?: string;
33 /** Capacity scaler in `[0.0, 1.0]`. */
34 capacityScaler?: number;
35 /** Max requests per second (RATE mode). */
36 maxRate?: number;
37 /** Max RPS per instance. */
38 maxRatePerInstance?: number;
39 /** Target utilization in `[0.0, 1.0]`. */
40 maxUtilization?: number;
41 /** Whether this backend is a failover group. */
42 failover?: boolean;
43};
44
45export type BackendServiceLogConfigProps = {
46 /**
47 * Export load-balancer logs to Cloud Logging.
48 * @default false
49 */
50 enable?: boolean;
51 /**
52 * Sampling rate in `[0, 1]` when logging is enabled.
53 * @default 1.0
54 */
55 sampleRate?: number;
56};
57
58export type BackendServiceProps = {
59 /**
60 * Name of the backend service. If omitted, a unique RFC1035 name is
61 * generated from the stack, stage, and logical id. Must be 1-63
62 * characters and match `[a-z]([-a-z0-9]*[a-z0-9])?`. Immutable —
63 * changing it replaces the resource.
64 */
65 name?: string;
66 /**
67 * User-facing description. Alchemy ownership is stamped into the stored
68 * description (`[alchemy alchemy-stack=… alchemy-stage=… alchemy-id=…]`);
69 * global backend services have no labels API.
70 */
71 description?: string;
72 /**
73 * Protocol used to talk to backends (`HTTP`, `HTTPS`, `HTTP2`, `TCP`,
74 * `SSL`, `UDP`, `GRPC`, `H2C`).
75 * @default "HTTP"
76 */
77 protocol?: string;
78 /**
79 * Load balancer type. Immutable — changing it replaces the resource.
80 * @default "EXTERNAL"
81 */
82 loadBalancingScheme?: string;
83 /**
84 * Backend timeout in seconds.
85 * @default 30
86 */
87 timeoutSec?: number;
88 /**
89 * Named port on instance-group backends. Ignored for NEGs and for
90 * passthrough Network Load Balancers.
91 */
92 portName?: string;
93 /**
94 * Enable Cloud CDN (global external Application / classic HTTP(S) LB).
95 * @default false
96 */
97 enableCDN?: boolean;
98 /**
99 * Session affinity (`NONE`, `CLIENT_IP`, `GENERATED_COOKIE`, …).
100 * @default "NONE"
101 */
102 sessionAffinity?: string;
103 /**
104 * Cookie lifetime in seconds for `GENERATED_COOKIE` / `HTTP_COOKIE`
105 * affinity. `0` is a session cookie.
106 */
107 affinityCookieTtlSec?: number;
108 /**
109 * Fully-qualified health-check URLs. At most one is typically allowed.
110 * Required for instance-group / zonal NEG backends; forbidden for
111 * internet and serverless NEGs.
112 */
113 healthChecks?: string[];
114 /**
115 * Backends (instance groups or NEGs) that serve this service.
116 */
117 backends?: BackendServiceBackend[];
118 /**
119 * Connection-draining timeout in seconds. GCP defaults this to `300`
120 * when omitted.
121 */
122 connectionDrainingTimeoutSec?: number;
123 /**
124 * Compress text responses (`AUTOMATIC` or `DISABLED`).
125 */
126 compressionMode?: string;
127 /**
128 * Headers the load balancer adds to proxied requests.
129 */
130 customRequestHeaders?: string[];
131 /**
132 * Headers the load balancer adds to proxied responses.
133 */
134 customResponseHeaders?: string[];
135 /**
136 * Cloud Logging options for traffic served by this backend service.
137 */
138 logConfig?: BackendServiceLogConfigProps;
139 /**
140 * User labels. Encoded into the description marker alongside Alchemy
141 * ownership labels (the API has no `labels` field).
142 */
143 labels?: Record<string, string>;
144};
145
146export type BackendService = Resource<
147 "GCP.Compute.BackendService",
148 BackendServiceProps,
149 {
150 /** RFC1035 resource name. */
151 name: string;
152 /** Project id. */
153 project: string;
154 /** Server-assigned numeric id. */
155 backendServiceId: string | undefined;
156 /** Compute self-link. */
157 selfLink: string | undefined;
158 /** RFC3339 creation timestamp. */
159 creationTimestamp: string | undefined;
160 /** Optimistic-locking fingerprint. */
161 fingerprint: string | undefined;
162 /** User-facing description (ownership marker stripped). */
163 description: string | undefined;
164 /** Protocol used to talk to backends. */
165 protocol: string | undefined;
166 /** Load balancer type. */
167 loadBalancingScheme: string | undefined;
168 /** Backend timeout in seconds. */
169 timeoutSec: number | undefined;
170 /** Named port on instance-group backends. */
171 portName: string | undefined;
172 /** Whether Cloud CDN is enabled. */
173 enableCDN: boolean;
174 /** Session affinity mode. */
175 sessionAffinity: string | undefined;
176 /** Cookie TTL in seconds, if set. */
177 affinityCookieTtlSec: number | undefined;
178 /** Fully-qualified health-check URLs. */
179 healthChecks: string[];
180 /** Attached backends. */
181 backends: BackendServiceBackend[];
182 /** Connection-draining timeout in seconds. */
183 connectionDrainingTimeoutSec: number | undefined;
184 /** Response compression mode. */
185 compressionMode: string | undefined;
186 /** Custom request headers. */
187 customRequestHeaders: string[];
188 /** Custom response headers. */
189 customResponseHeaders: string[];
190 /** Logging configuration. */
191 logConfig: BackendServiceLogConfigProps | undefined;
192 /** User labels (Alchemy ownership labels stripped). */
193 labels: Record<string, string>;
194 /** Resource kind (`compute#backendService`). */
195 kind: string | undefined;
196 },
197 never,
198 Providers
199>;
200
201/**
202 * A global Compute Engine backend service. Backend services define how
203 * Google Cloud load balancers distribute traffic — protocol, timeout,
204 * session affinity, health checks, and the backends themselves.
205 *
206 * Compute Engine backend services have no labels field, so Alchemy stamps
207 * ownership into the description (`[alchemy alchemy-stack=… alchemy-stage=…
208 * alchemy-id=…]`) so `list` / `pnpm nuke:gcp` can find them.
209 *
210 * ### Creating a Backend Service
211 * **Example:** Generated name
212 * ```typescript
213 * const backend = yield* GCP.Compute.BackendService("web", {
214 * protocol: "HTTP",
215 * });
216 * ```
217 *
218 * **Example:** Explicit name, timeout, and labels
219 * ```typescript
220 * const backend = yield* GCP.Compute.BackendService("web", {
221 * name: "web-backend",
222 * protocol: "HTTP",
223 * loadBalancingScheme: "EXTERNAL",
224 * timeoutSec: 30,
225 * enableCDN: true,
226 * labels: { env: "prod" },
227 * });
228 * ```
229 *
230 * ### Updating a Backend Service
231 * **Example:** Raise the timeout and enable CDN
232 * ```typescript
233 * const backend = yield* GCP.Compute.BackendService("web", {
234 * name: "web-backend",
235 * timeoutSec: 60,
236 * enableCDN: true,
237 * });
238 * ```
239 *
240 * @resource
241 * @category Compute
242 */
243export const BackendService = Resource<BackendService>(
244 "GCP.Compute.BackendService",
245);
246
247export class BackendServiceNotResolved extends Data.TaggedError(
248 "GCP.Compute.BackendServiceNotResolved",
249)<{
250 name: string;
251}> {}
252
253const rfc1035 = (name: string): string => {
254 let next = name
255 .toLowerCase()
256 .replace(/[^a-z0-9-]/g, "-")
257 .replace(/-+/g, "-")
258 .replace(/^-+/, "")
259 .replace(/-+$/, "");
260 if (!/^[a-z]/.test(next)) {
261 next = `b${next}`;
262 }
263 next = next.slice(0, 63).replace(/-+$/, "");
264 return next.length > 0 ? next : "backend";
265};
266
267const toName = (id: string, name: string | undefined, existing?: string) =>
268 Effect.gen(function* () {
269 if (name !== undefined) return name;
270 if (existing !== undefined) return existing;
271 return rfc1035(
272 yield* createPhysicalName({
273 id,
274 maxLength: 63,
275 lowercase: true,
276 }),
277 );
278 });
279
280const encodeDescription = (
281 user: string | undefined,
282 labels: Record<string, string>,
283): string => {
284 const packed = Object.entries(labels)
285 .sort(([a], [b]) => a.localeCompare(b))
286 .map(([key, value]) => `${key}=${value}`)
287 .join(" ");
288 const marker = `[alchemy ${packed}]`;
289 return user ? `${marker}\n${user}` : marker;
290};
291
292const parseDescription = (
293 description: string | undefined,
294): { labels: Record<string, string>; user: string | undefined } => {
295 if (!description?.startsWith("[alchemy ")) {
296 return { labels: {}, user: description };
297 }
298 const end = description.indexOf("]");
299 if (end < 0) return { labels: {}, user: description };
300 const labels: Record<string, string> = {};
301 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
302 const eq = part.indexOf("=");
303 if (eq > 0) {
304 labels[part.slice(0, eq)] = part.slice(eq + 1);
305 }
306 }
307 const rest = description.slice(end + 1).replace(/^\n/, "");
308 return { labels, user: rest.length > 0 ? rest : undefined };
309};
310
311const hasOwnershipMarker = (description: string | undefined): boolean =>
312 (description ?? "").startsWith("[alchemy ");
313
314const userLabels = (
315 labels: Record<string, string | undefined> | null | undefined,
316): Record<string, string> => stripInternalLabels(tagRecord(labels));
317
318const toBackend = (backend: compute.Backend): BackendServiceBackend => ({
319 group: backend.group ?? "",
320 description: backend.description,
321 balancingMode: backend.balancingMode,
322 capacityScaler: backend.capacityScaler,
323 maxRate: backend.maxRate,
324 maxRatePerInstance: backend.maxRatePerInstance,
325 maxUtilization: backend.maxUtilization,
326 failover: backend.failover,
327});
328
329const toLogConfig = (
330 logConfig: compute.BackendServiceLogConfig | undefined,
331): BackendServiceLogConfigProps | undefined => {
332 if (logConfig === undefined) return undefined;
333 return {
334 enable: logConfig.enable,
335 sampleRate: logConfig.sampleRate,
336 };
337};
338
339const toAttrs = (service: compute.BackendService, project: string) => {
340 const decoded = parseDescription(service.description);
341 return {
342 name: service.name ?? "",
343 project,
344 backendServiceId: service.id,
345 selfLink: service.selfLink,
346 creationTimestamp: service.creationTimestamp,
347 fingerprint: service.fingerprint,
348 description: decoded.user,
349 protocol: service.protocol,
350 loadBalancingScheme: service.loadBalancingScheme,
351 timeoutSec: service.timeoutSec,
352 portName: service.portName,
353 enableCDN: service.enableCDN === true,
354 sessionAffinity: service.sessionAffinity,
355 affinityCookieTtlSec: service.affinityCookieTtlSec,
356 healthChecks: [...(service.healthChecks ?? [])],
357 backends: (service.backends ?? []).map(toBackend),
358 connectionDrainingTimeoutSec:
359 service.connectionDraining?.drainingTimeoutSec,
360 compressionMode: service.compressionMode,
361 customRequestHeaders: [...(service.customRequestHeaders ?? [])],
362 customResponseHeaders: [...(service.customResponseHeaders ?? [])],
363 logConfig: toLogConfig(service.logConfig),
364 labels: userLabels(decoded.labels),
365 kind: service.kind,
366 };
367};
368
369const sameStringList = (
370 left: readonly string[] | undefined,
371 right: readonly string[] | undefined,
372) =>
373 JSON.stringify([...(left ?? [])].sort()) ===
374 JSON.stringify([...(right ?? [])].sort());
375
376const backendKey = (backend: BackendServiceBackend) =>
377 JSON.stringify({
378 group: backend.group,
379 description: backend.description ?? "",
380 balancingMode: backend.balancingMode ?? "",
381 capacityScaler: backend.capacityScaler ?? null,
382 maxRate: backend.maxRate ?? null,
383 maxRatePerInstance: backend.maxRatePerInstance ?? null,
384 maxUtilization: backend.maxUtilization ?? null,
385 failover: backend.failover ?? false,
386 });
387
388const sameBackends = (
389 left: readonly BackendServiceBackend[] | undefined,
390 right: readonly BackendServiceBackend[] | undefined,
391) =>
392 JSON.stringify([...(left ?? [])].map(backendKey).sort()) ===
393 JSON.stringify([...(right ?? [])].map(backendKey).sort());
394
395const sameLogConfig = (
396 left: BackendServiceLogConfigProps | undefined,
397 right: compute.BackendServiceLogConfig | undefined,
398) =>
399 (left?.enable ?? false) === (right?.enable ?? false) &&
400 (left?.sampleRate === undefined || left.sampleRate === right?.sampleRate);
401
402const getByName = (project: string, name: string) =>
403 compute
404 .getBackendServices({ project, backendService: name })
405 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
406
407const awaitResource = (project: string, name: string) =>
408 getByName(project, name).pipe(
409 Effect.repeat({
410 schedule: Schedule.spaced("1 second"),
411 until: (service) => service !== undefined,
412 times: 8,
413 }),
414 );
415
416export const BackendServiceProvider = () =>
417 Provider.succeed(BackendService, {
418 stables: [
419 "name",
420 "project",
421 "backendServiceId",
422 "selfLink",
423 "creationTimestamp",
424 "loadBalancingScheme",
425 ],
426
427 diff: Effect.fn(function* ({ news, olds, output }) {
428 if (!isResolved(news)) return undefined;
429 const previousName = olds?.name ?? output?.name;
430 const nextName = news.name ?? previousName;
431 if (
432 news.name !== undefined &&
433 previousName !== undefined &&
434 news.name !== previousName
435 ) {
436 return { action: "replace" as const };
437 }
438 const previousScheme =
439 olds?.loadBalancingScheme ??
440 output?.loadBalancingScheme ??
441 DEFAULT_SCHEME;
442 const nextScheme = news.loadBalancingScheme ?? DEFAULT_SCHEME;
443 if (previousScheme !== nextScheme) {
444 return {
445 action: "replace" as const,
446 deleteFirst:
447 previousName !== undefined &&
448 (nextName === undefined || nextName === previousName),
449 };
450 }
451 return undefined;
452 }),
453
454 read: Effect.fn(function* ({ id, olds, output }) {
455 const env = yield* GcpEnvironment.current;
456 const name = yield* toName(id, olds?.name, output?.name);
457 const existing = yield* getByName(env.project, name);
458 if (existing === undefined) return undefined;
459 const attrs = toAttrs(existing, env.project);
460 const parsed = parseDescription(existing.description);
461 return (yield* hasAlchemyLabels(id, parsed.labels))
462 ? attrs
463 : Unowned(attrs);
464 }),
465
466 list: () =>
467 Effect.gen(function* () {
468 const env = yield* GcpEnvironment.current;
469 return yield* compute.listBackendServices
470 .items({ project: env.project, maxResults: 500 })
471 .pipe(
472 Stream.filter((service) => hasOwnershipMarker(service.description)),
473 Stream.map((service) => toAttrs(service, env.project)),
474 Stream.runCollect,
475 Effect.map((chunk) => Array.from(chunk)),
476 );
477 }),
478
479 reconcile: Effect.fn(function* ({ id, news, output }) {
480 const env = yield* GcpEnvironment.current;
481 const name = yield* toName(id, news.name, output?.name);
482 const desiredLabels = {
483 ...toLabels(news.labels),
484 ...(yield* createInternalLabels(id)),
485 };
486 const desiredDescription = encodeDescription(
487 news.description,
488 desiredLabels,
489 );
490 const protocol = news.protocol ?? DEFAULT_PROTOCOL;
491 const loadBalancingScheme = news.loadBalancingScheme ?? DEFAULT_SCHEME;
492 const timeoutSec = news.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
493 const enableCDN = news.enableCDN === true;
494 const desiredBackends = (news.backends ?? []).map((backend) => ({
495 group: backend.group,
496 description: backend.description,
497 balancingMode: backend.balancingMode,
498 capacityScaler: backend.capacityScaler,
499 maxRate: backend.maxRate,
500 maxRatePerInstance: backend.maxRatePerInstance,
501 maxUtilization: backend.maxUtilization,
502 failover: backend.failover,
503 }));
504
505 let current = yield* getByName(env.project, name);
506
507 if (current === undefined) {
508 const inserted = yield* compute
509 .insertBackendServices({
510 project: env.project,
511 body: {
512 name,
513 description: desiredDescription,
514 protocol,
515 loadBalancingScheme,
516 timeoutSec,
517 portName: news.portName,
518 enableCDN,
519 sessionAffinity: news.sessionAffinity,
520 affinityCookieTtlSec: news.affinityCookieTtlSec,
521 healthChecks:
522 (news.healthChecks?.length ?? 0) > 0
523 ? news.healthChecks
524 : undefined,
525 backends:
526 desiredBackends.length > 0 ? desiredBackends : undefined,
527 connectionDraining:
528 news.connectionDrainingTimeoutSec !== undefined
529 ? {
530 drainingTimeoutSec: news.connectionDrainingTimeoutSec,
531 }
532 : undefined,
533 compressionMode: news.compressionMode,
534 customRequestHeaders:
535 (news.customRequestHeaders?.length ?? 0) > 0
536 ? news.customRequestHeaders
537 : undefined,
538 customResponseHeaders:
539 (news.customResponseHeaders?.length ?? 0) > 0
540 ? news.customResponseHeaders
541 : undefined,
542 logConfig: news.logConfig,
543 },
544 })
545 .pipe(
546 Effect.flatMap((operation) =>
547 waitGlobalOperation(env.project, operation),
548 ),
549 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
550 );
551 current =
552 inserted !== undefined
553 ? yield* awaitResource(env.project, name)
554 : yield* getByName(env.project, name);
555 }
556
557 if (current === undefined) {
558 return yield* new BackendServiceNotResolved({ name });
559 }
560
561 const timeoutChanged =
562 (current.timeoutSec ?? DEFAULT_TIMEOUT_SEC) !== timeoutSec;
563 const protocolChanged =
564 (current.protocol ?? DEFAULT_PROTOCOL) !== protocol;
565 const descriptionChanged =
566 (current.description ?? "") !== desiredDescription;
567 const enableCDNChanged = (current.enableCDN === true) !== enableCDN;
568 const sessionAffinityChanged =
569 (current.sessionAffinity ?? "NONE") !==
570 (news.sessionAffinity ?? "NONE");
571 const affinityCookieChanged =
572 news.affinityCookieTtlSec !== undefined &&
573 current.affinityCookieTtlSec !== news.affinityCookieTtlSec;
574 const portNameChanged =
575 news.portName !== undefined && current.portName !== news.portName;
576 const healthChecksChanged = !sameStringList(
577 current.healthChecks,
578 news.healthChecks,
579 );
580 const backendsChanged = !sameBackends(
581 (current.backends ?? []).map(toBackend),
582 news.backends,
583 );
584 const drainingChanged =
585 news.connectionDrainingTimeoutSec !== undefined &&
586 current.connectionDraining?.drainingTimeoutSec !==
587 news.connectionDrainingTimeoutSec;
588 const compressionChanged =
589 news.compressionMode !== undefined &&
590 current.compressionMode !== news.compressionMode;
591 const requestHeadersChanged =
592 news.customRequestHeaders !== undefined &&
593 !sameStringList(
594 current.customRequestHeaders,
595 news.customRequestHeaders,
596 );
597 const responseHeadersChanged =
598 news.customResponseHeaders !== undefined &&
599 !sameStringList(
600 current.customResponseHeaders,
601 news.customResponseHeaders,
602 );
603 const logConfigChanged =
604 news.logConfig !== undefined &&
605 !sameLogConfig(news.logConfig, current.logConfig);
606
607 if (
608 timeoutChanged ||
609 protocolChanged ||
610 descriptionChanged ||
611 enableCDNChanged ||
612 sessionAffinityChanged ||
613 affinityCookieChanged ||
614 portNameChanged ||
615 healthChecksChanged ||
616 backendsChanged ||
617 drainingChanged ||
618 compressionChanged ||
619 requestHeadersChanged ||
620 responseHeadersChanged ||
621 logConfigChanged
622 ) {
623 const patch: compute.BackendService = {
624 fingerprint: current.fingerprint,
625 };
626 if (timeoutChanged) patch.timeoutSec = timeoutSec;
627 if (protocolChanged) patch.protocol = protocol;
628 if (descriptionChanged) patch.description = desiredDescription;
629 if (enableCDNChanged) patch.enableCDN = enableCDN;
630 if (sessionAffinityChanged) {
631 patch.sessionAffinity = news.sessionAffinity ?? "NONE";
632 }
633 if (affinityCookieChanged) {
634 patch.affinityCookieTtlSec = news.affinityCookieTtlSec;
635 }
636 if (portNameChanged) patch.portName = news.portName;
637 if (healthChecksChanged) patch.healthChecks = news.healthChecks ?? [];
638 if (backendsChanged) patch.backends = desiredBackends;
639 if (drainingChanged) {
640 patch.connectionDraining = {
641 drainingTimeoutSec: news.connectionDrainingTimeoutSec,
642 };
643 }
644 if (compressionChanged) patch.compressionMode = news.compressionMode;
645 if (requestHeadersChanged) {
646 patch.customRequestHeaders = news.customRequestHeaders;
647 }
648 if (responseHeadersChanged) {
649 patch.customResponseHeaders = news.customResponseHeaders;
650 }
651 if (logConfigChanged) patch.logConfig = news.logConfig;
652
653 yield* compute
654 .patchBackendServices({
655 project: env.project,
656 backendService: name,
657 body: patch,
658 })
659 .pipe(
660 Effect.flatMap((operation) =>
661 waitGlobalOperation(env.project, operation),
662 ),
663 );
664 current = yield* awaitResource(env.project, name);
665 if (current === undefined) {
666 return yield* new BackendServiceNotResolved({ name });
667 }
668 }
669
670 return toAttrs(current, env.project);
671 }),
672
673 delete: Effect.fn(function* ({ output }) {
674 const env = yield* GcpEnvironment.current;
675 yield* compute
676 .deleteBackendServices({
677 project: env.project,
678 backendService: output.name,
679 })
680 .pipe(
681 Effect.flatMap((operation) =>
682 waitGlobalOperation(env.project, operation),
683 ),
684 Effect.catchTag("NotFound", () => Effect.void),
685 );
686 }),
687 });