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 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 = "INTERNAL_MANAGED";
24const DEFAULT_TIMEOUT_SEC = 30;
25const MAX_NAME_LENGTH = 63;
26
27export type RegionBackendServiceBackend = {
28 /** Fully-qualified instance group or NEG URL. */
29 group: string;
30 /** Optional backend description. */
31 description?: string;
32 /** Balancing mode (`UTILIZATION`, `RATE`, `CONNECTION`, …). */
33 balancingMode?: string;
34 /** Capacity scaler in `[0.0, 1.0]`. */
35 capacityScaler?: number;
36 /** Max requests per second (RATE mode). */
37 maxRate?: number;
38 /** Max RPS per instance. */
39 maxRatePerInstance?: number;
40 /** Target utilization in `[0.0, 1.0]`. */
41 maxUtilization?: number;
42 /** Whether this backend is a failover group. */
43 failover?: boolean;
44};
45
46export type RegionBackendServiceLogConfigProps = {
47 /**
48 * Export load-balancer logs to Cloud Logging.
49 * @default false
50 */
51 enable?: boolean;
52 /**
53 * Sampling rate in `[0, 1]` when logging is enabled.
54 * @default 1.0
55 */
56 sampleRate?: number;
57};
58
59export type RegionBackendServiceProps = {
60 /**
61 * Name of the backend service. If omitted, a unique RFC1035 name is
62 * generated from the stack, stage, and logical id. Must be 1-63
63 * characters and match `[a-z]([-a-z0-9]*[a-z0-9])?`. Immutable —
64 * changing it replaces the resource.
65 */
66 name?: string;
67 /**
68 * Region the backend service lives in (e.g. `us-central1`). Immutable —
69 * changing it replaces the resource. `US-CENTRAL1` is accepted and
70 * normalized to `us-central1`.
71 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
72 */
73 region?: string;
74 /**
75 * User-facing description. Alchemy ownership is stamped into the stored
76 * description (`[alchemy alchemy-stack=… alchemy-stage=… alchemy-id=…]`);
77 * regional backend services have no labels API.
78 */
79 description?: string;
80 /**
81 * Protocol used to talk to backends (`HTTP`, `HTTPS`, `HTTP2`, `TCP`,
82 * `SSL`, `UDP`, `GRPC`, `H2C`).
83 * @default "HTTP"
84 */
85 protocol?: string;
86 /**
87 * Load balancer type. Immutable — changing it replaces the resource.
88 * @default "INTERNAL_MANAGED"
89 */
90 loadBalancingScheme?: string;
91 /**
92 * Backend timeout in seconds.
93 * @default 30
94 */
95 timeoutSec?: number;
96 /**
97 * Named port on instance-group backends. Ignored for NEGs and for
98 * passthrough Network Load Balancers.
99 */
100 portName?: string;
101 /**
102 * Enable Cloud CDN. Only valid for some Application Load Balancer
103 * schemes; ignored or rejected otherwise.
104 * @default false
105 */
106 enableCDN?: boolean;
107 /**
108 * Session affinity (`NONE`, `CLIENT_IP`, `GENERATED_COOKIE`, …).
109 * @default "NONE"
110 */
111 sessionAffinity?: string;
112 /**
113 * Cookie lifetime in seconds for `GENERATED_COOKIE` / `HTTP_COOKIE`
114 * affinity. `0` is a session cookie.
115 */
116 affinityCookieTtlSec?: number;
117 /**
118 * Fully-qualified health-check URLs. At most one is typically allowed.
119 * Required for instance-group / zonal NEG backends; forbidden for
120 * internet and serverless NEGs.
121 */
122 healthChecks?: string[];
123 /**
124 * Backends (instance groups or NEGs) that serve this service.
125 */
126 backends?: RegionBackendServiceBackend[];
127 /**
128 * Connection-draining timeout in seconds. GCP defaults this to `300`
129 * when omitted.
130 */
131 connectionDrainingTimeoutSec?: number;
132 /**
133 * Compress text responses (`AUTOMATIC` or `DISABLED`).
134 */
135 compressionMode?: string;
136 /**
137 * Headers the load balancer adds to proxied requests.
138 */
139 customRequestHeaders?: string[];
140 /**
141 * Headers the load balancer adds to proxied responses.
142 */
143 customResponseHeaders?: string[];
144 /**
145 * Cloud Logging options for traffic served by this backend service.
146 */
147 logConfig?: RegionBackendServiceLogConfigProps;
148 /**
149 * Intra-locality load-balancing algorithm (`ROUND_ROBIN`, `MAGLEV`,
150 * `RING_HASH`, …). Applicable to `INTERNAL_MANAGED` HTTP(S) services.
151 */
152 localityLbPolicy?: string;
153 /**
154 * VPC network URL or name. Only valid for passthrough Network Load
155 * Balancers (`INTERNAL` / some `EXTERNAL` HA setups). Immutable —
156 * changing it replaces the resource.
157 */
158 network?: string;
159 /**
160 * User labels. Encoded into the description marker alongside Alchemy
161 * ownership labels (the API has no `labels` field).
162 */
163 labels?: Record<string, string>;
164};
165
166export type RegionBackendService = Resource<
167 "GCP.Compute.RegionBackendService",
168 RegionBackendServiceProps,
169 {
170 /** RFC1035 resource name. */
171 name: string;
172 /** Project id. */
173 project: string;
174 /** Region short name (`us-central1`). */
175 region: string;
176 /** Server-assigned numeric id. */
177 backendServiceId: string | undefined;
178 /** Compute self-link. */
179 selfLink: string | undefined;
180 /** RFC3339 creation timestamp. */
181 creationTimestamp: string | undefined;
182 /** Optimistic-locking fingerprint. */
183 fingerprint: string | undefined;
184 /** User-facing description (ownership marker stripped). */
185 description: string | undefined;
186 /** Protocol used to talk to backends. */
187 protocol: string | undefined;
188 /** Load balancer type. */
189 loadBalancingScheme: string | undefined;
190 /** Backend timeout in seconds. */
191 timeoutSec: number | undefined;
192 /** Named port on instance-group backends. */
193 portName: string | undefined;
194 /** Whether Cloud CDN is enabled. */
195 enableCDN: boolean;
196 /** Session affinity mode. */
197 sessionAffinity: string | undefined;
198 /** Cookie TTL in seconds, if set. */
199 affinityCookieTtlSec: number | undefined;
200 /** Fully-qualified health-check URLs. */
201 healthChecks: string[];
202 /** Attached backends. */
203 backends: RegionBackendServiceBackend[];
204 /** Connection-draining timeout in seconds. */
205 connectionDrainingTimeoutSec: number | undefined;
206 /** Response compression mode. */
207 compressionMode: string | undefined;
208 /** Custom request headers. */
209 customRequestHeaders: string[];
210 /** Custom response headers. */
211 customResponseHeaders: string[];
212 /** Logging configuration. */
213 logConfig: RegionBackendServiceLogConfigProps | undefined;
214 /** Intra-locality load-balancing algorithm. */
215 localityLbPolicy: string | undefined;
216 /** Network URL, if set. */
217 network: string | undefined;
218 /** User labels (Alchemy ownership labels stripped). */
219 labels: Record<string, string>;
220 /** Resource kind (`compute#backendService`). */
221 kind: string | undefined;
222 },
223 never,
224 Providers
225>;
226
227/**
228 * A regional Compute Engine backend service. Backend services define how
229 * Google Cloud load balancers distribute traffic — protocol, timeout,
230 * session affinity, health checks, and the backends themselves.
231 *
232 * Compute Engine backend services have no labels field, so Alchemy stamps
233 * ownership into the description (`[alchemy alchemy-stack=… alchemy-stage=…
234 * alchemy-id=…]`) so `list` / `pnpm nuke:gcp` can find them. Changing
235 * `name`, `region`, `loadBalancingScheme`, or `network` replaces the
236 * resource.
237 *
238 * ### Creating a Region Backend Service
239 * **Example:** Generated name
240 * ```typescript
241 * const backend = yield* GCP.Compute.RegionBackendService("web", {
242 * protocol: "HTTP",
243 * loadBalancingScheme: "INTERNAL_MANAGED",
244 * });
245 * ```
246 *
247 * **Example:** Explicit name, timeout, and labels
248 * ```typescript
249 * const backend = yield* GCP.Compute.RegionBackendService("web", {
250 * name: "web-backend",
251 * region: "us-central1",
252 * protocol: "HTTP",
253 * loadBalancingScheme: "INTERNAL_MANAGED",
254 * timeoutSec: 30,
255 * localityLbPolicy: "ROUND_ROBIN",
256 * labels: { env: "prod" },
257 * });
258 * ```
259 *
260 * ### Updating a Region Backend Service
261 * **Example:** Raise the timeout
262 * ```typescript
263 * const backend = yield* GCP.Compute.RegionBackendService("web", {
264 * name: "web-backend",
265 * timeoutSec: 60,
266 * });
267 * ```
268 *
269 * @resource
270 * @category Compute
271 */
272export const RegionBackendService = Resource<RegionBackendService>(
273 "GCP.Compute.RegionBackendService",
274);
275
276export class RegionBackendServiceNotResolved extends Data.TaggedError(
277 "GCP.Compute.RegionBackendServiceNotResolved",
278)<{
279 name: string;
280 region: string;
281}> {}
282
283export class RegionBackendServiceStillExists extends Data.TaggedError(
284 "GCP.Compute.RegionBackendServiceStillExists",
285)<{
286 name: string;
287 region: string;
288}> {}
289
290const lastSegment = (value: string | undefined): string => {
291 if (value === undefined || value.length === 0) return "";
292 const parts = value.split("/").filter((part) => part.length > 0);
293 return parts[parts.length - 1] ?? value;
294};
295
296const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
297 lastSegment(region ?? defaultRegion).toLowerCase();
298
299const rfc1035 = (name: string): string => {
300 let next = name
301 .toLowerCase()
302 .replace(/[^a-z0-9-]/g, "-")
303 .replace(/-+/g, "-")
304 .replace(/^-+/, "")
305 .replace(/-+$/, "");
306 if (!/^[a-z]/.test(next)) {
307 next = `b${next}`;
308 }
309 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
310 return next.length > 0 ? next : "backend";
311};
312
313const toName = (id: string, name: string | undefined, existing?: string) =>
314 Effect.gen(function* () {
315 if (name !== undefined) return name;
316 if (existing !== undefined) return existing;
317 return rfc1035(
318 yield* createPhysicalName({
319 id,
320 maxLength: MAX_NAME_LENGTH,
321 lowercase: true,
322 }),
323 );
324 });
325
326const toNetworkUrl = (project: string, network: string) =>
327 network.includes("/")
328 ? network
329 : `projects/${project}/global/networks/${network}`;
330
331const encodeDescription = (
332 user: string | undefined,
333 labels: Record<string, string>,
334): string => {
335 const packed = Object.entries(labels)
336 .sort(([a], [b]) => a.localeCompare(b))
337 .map(([key, value]) => `${key}=${value}`)
338 .join(" ");
339 const marker = `[alchemy ${packed}]`;
340 return user ? `${marker}\n${user}` : marker;
341};
342
343const parseDescription = (
344 description: string | undefined,
345): { labels: Record<string, string>; user: string | undefined } => {
346 if (!description?.startsWith("[alchemy ")) {
347 return { labels: {}, user: description };
348 }
349 const end = description.indexOf("]");
350 if (end < 0) return { labels: {}, user: description };
351 const labels: Record<string, string> = {};
352 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
353 const eq = part.indexOf("=");
354 if (eq > 0) {
355 labels[part.slice(0, eq)] = part.slice(eq + 1);
356 }
357 }
358 const rest = description.slice(end + 1).replace(/^\n/, "");
359 return { labels, user: rest.length > 0 ? rest : undefined };
360};
361
362const hasOwnershipMarker = (description: string | undefined): boolean =>
363 (description ?? "").startsWith("[alchemy ");
364
365const userLabels = (
366 labels: Record<string, string | undefined> | null | undefined,
367): Record<string, string> => stripInternalLabels(tagRecord(labels));
368
369const toBackend = (backend: compute.Backend): RegionBackendServiceBackend => ({
370 group: backend.group ?? "",
371 description: backend.description,
372 balancingMode: backend.balancingMode,
373 capacityScaler: backend.capacityScaler,
374 maxRate: backend.maxRate,
375 maxRatePerInstance: backend.maxRatePerInstance,
376 maxUtilization: backend.maxUtilization,
377 failover: backend.failover,
378});
379
380const toLogConfig = (
381 logConfig: compute.BackendServiceLogConfig | undefined,
382): RegionBackendServiceLogConfigProps | undefined => {
383 if (logConfig === undefined) return undefined;
384 return {
385 enable: logConfig.enable,
386 sampleRate: logConfig.sampleRate,
387 };
388};
389
390const toAttrs = (service: compute.BackendService, project: string) => {
391 const decoded = parseDescription(service.description);
392 return {
393 name: service.name ?? "",
394 project,
395 region: lastSegment(service.region).toLowerCase(),
396 backendServiceId: service.id,
397 selfLink: service.selfLink,
398 creationTimestamp: service.creationTimestamp,
399 fingerprint: service.fingerprint,
400 description: decoded.user,
401 protocol: service.protocol,
402 loadBalancingScheme: service.loadBalancingScheme,
403 timeoutSec: service.timeoutSec,
404 portName: service.portName,
405 enableCDN: service.enableCDN === true,
406 sessionAffinity: service.sessionAffinity,
407 affinityCookieTtlSec: service.affinityCookieTtlSec,
408 healthChecks: [...(service.healthChecks ?? [])],
409 backends: (service.backends ?? []).map(toBackend),
410 connectionDrainingTimeoutSec:
411 service.connectionDraining?.drainingTimeoutSec,
412 compressionMode: service.compressionMode,
413 customRequestHeaders: [...(service.customRequestHeaders ?? [])],
414 customResponseHeaders: [...(service.customResponseHeaders ?? [])],
415 logConfig: toLogConfig(service.logConfig),
416 localityLbPolicy: service.localityLbPolicy,
417 network: service.network,
418 labels: userLabels(decoded.labels),
419 kind: service.kind,
420 };
421};
422
423const sameStringList = (
424 left: readonly string[] | undefined,
425 right: readonly string[] | undefined,
426) =>
427 JSON.stringify([...(left ?? [])].sort()) ===
428 JSON.stringify([...(right ?? [])].sort());
429
430const backendKey = (backend: RegionBackendServiceBackend) =>
431 JSON.stringify({
432 group: backend.group,
433 description: backend.description ?? "",
434 balancingMode: backend.balancingMode ?? "",
435 capacityScaler: backend.capacityScaler ?? null,
436 maxRate: backend.maxRate ?? null,
437 maxRatePerInstance: backend.maxRatePerInstance ?? null,
438 maxUtilization: backend.maxUtilization ?? null,
439 failover: backend.failover ?? false,
440 });
441
442const sameBackends = (
443 left: readonly RegionBackendServiceBackend[] | undefined,
444 right: readonly RegionBackendServiceBackend[] | undefined,
445) =>
446 JSON.stringify([...(left ?? [])].map(backendKey).sort()) ===
447 JSON.stringify([...(right ?? [])].map(backendKey).sort());
448
449const sameLogConfig = (
450 left: RegionBackendServiceLogConfigProps | undefined,
451 right: compute.BackendServiceLogConfig | undefined,
452) =>
453 (left?.enable ?? false) === (right?.enable ?? false) &&
454 (left?.sampleRate === undefined || left.sampleRate === right?.sampleRate);
455
456const getByName = (project: string, region: string, name: string) =>
457 compute
458 .getRegionBackendServices({ project, region, backendService: name })
459 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
460
461const waitPresent = (project: string, region: string, name: string) =>
462 getByName(project, region, name).pipe(
463 Effect.flatMap((service) =>
464 service === undefined
465 ? Effect.fail(new RegionBackendServiceNotResolved({ name, region }))
466 : Effect.succeed(service),
467 ),
468 Effect.retry({
469 while: (error) =>
470 error._tag === "GCP.Compute.RegionBackendServiceNotResolved",
471 times: 8,
472 schedule: Schedule.exponential("250 millis"),
473 }),
474 Effect.catchTag("GCP.Compute.RegionBackendServiceNotResolved", () =>
475 Effect.succeed(undefined),
476 ),
477 );
478
479const waitGone = (project: string, region: string, name: string) =>
480 getByName(project, region, name).pipe(
481 Effect.flatMap((service) =>
482 service === undefined
483 ? Effect.void
484 : Effect.fail(new RegionBackendServiceStillExists({ name, region })),
485 ),
486 Effect.retry({
487 while: (error) =>
488 error._tag === "GCP.Compute.RegionBackendServiceStillExists",
489 times: 8,
490 schedule: Schedule.spaced("1 second"),
491 }),
492 );
493
494export const RegionBackendServiceProvider = () =>
495 Provider.succeed(RegionBackendService, {
496 stables: [
497 "name",
498 "project",
499 "region",
500 "backendServiceId",
501 "selfLink",
502 "creationTimestamp",
503 "loadBalancingScheme",
504 ],
505
506 diff: Effect.fn(function* ({ news, olds, output }) {
507 if (!isResolved(news)) return undefined;
508 const env = yield* GcpEnvironment.current;
509 const previousName = olds?.name ?? output?.name;
510 const nextName = news.name ?? previousName;
511 const previousRegion = normalizeRegion(
512 olds?.region ?? output?.region,
513 env.region,
514 );
515 const nextRegion = normalizeRegion(
516 news.region ?? previousRegion,
517 env.region,
518 );
519 const nameChanged =
520 news.name !== undefined &&
521 previousName !== undefined &&
522 news.name !== previousName;
523 const regionChanged = previousRegion !== nextRegion;
524 const previousScheme =
525 olds?.loadBalancingScheme ??
526 output?.loadBalancingScheme ??
527 DEFAULT_SCHEME;
528 const nextScheme = news.loadBalancingScheme ?? DEFAULT_SCHEME;
529 const schemeChanged = previousScheme !== nextScheme;
530 const previousNetwork = lastSegment(
531 olds?.network ?? output?.network,
532 ).toLowerCase();
533 const nextNetwork =
534 news.network !== undefined
535 ? lastSegment(news.network).toLowerCase()
536 : previousNetwork;
537 const networkChanged = previousNetwork !== nextNetwork;
538 if (!nameChanged && !regionChanged && !schemeChanged && !networkChanged) {
539 return undefined;
540 }
541 const samePhysicalName =
542 previousName !== undefined &&
543 (nextName === undefined || nextName === previousName) &&
544 previousRegion === nextRegion;
545 return {
546 action: "replace" as const,
547 deleteFirst: samePhysicalName && (schemeChanged || networkChanged),
548 };
549 }),
550
551 read: Effect.fn(function* ({ id, olds, output }) {
552 const env = yield* GcpEnvironment.current;
553 const name = yield* toName(id, olds?.name, output?.name);
554 const region = normalizeRegion(
555 olds?.region ?? output?.region,
556 env.region,
557 );
558 const existing = yield* getByName(env.project, region, name);
559 if (existing === undefined) return undefined;
560 const attrs = toAttrs(existing, env.project);
561 const parsed = parseDescription(existing.description);
562 return (yield* hasAlchemyLabels(id, parsed.labels))
563 ? attrs
564 : Unowned(attrs);
565 }),
566
567 list: () =>
568 Effect.gen(function* () {
569 const env = yield* GcpEnvironment.current;
570 const pages = yield* compute.aggregatedListBackendServices
571 .pages({
572 project: env.project,
573 maxResults: 500,
574 returnPartialSuccess: true,
575 })
576 .pipe(Stream.runCollect);
577 return Array.from(pages).flatMap((page) =>
578 Object.values(page.items ?? {}).flatMap((scoped) =>
579 (scoped?.backendServices ?? [])
580 .filter(
581 (service) =>
582 (service.region ?? "").length > 0 &&
583 hasOwnershipMarker(service.description),
584 )
585 .map((service) => toAttrs(service, env.project)),
586 ),
587 );
588 }),
589
590 reconcile: Effect.fn(function* ({ id, news, output }) {
591 const env = yield* GcpEnvironment.current;
592 const name = yield* toName(id, news.name, output?.name);
593 const region = normalizeRegion(news.region ?? output?.region, env.region);
594 const desiredLabels = {
595 ...toLabels(news.labels),
596 ...(yield* createInternalLabels(id)),
597 };
598 const desiredDescription = encodeDescription(
599 news.description,
600 desiredLabels,
601 );
602 const protocol = news.protocol ?? DEFAULT_PROTOCOL;
603 const loadBalancingScheme = news.loadBalancingScheme ?? DEFAULT_SCHEME;
604 const timeoutSec = news.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
605 const enableCDN = news.enableCDN === true;
606 const desiredBackends = (news.backends ?? []).map((backend) => ({
607 group: backend.group,
608 description: backend.description,
609 balancingMode: backend.balancingMode,
610 capacityScaler: backend.capacityScaler,
611 maxRate: backend.maxRate,
612 maxRatePerInstance: backend.maxRatePerInstance,
613 maxUtilization: backend.maxUtilization,
614 failover: backend.failover,
615 }));
616 const network =
617 news.network !== undefined
618 ? toNetworkUrl(env.project, news.network)
619 : undefined;
620
621 let current = yield* getByName(env.project, region, name);
622
623 if (current === undefined) {
624 yield* compute
625 .insertRegionBackendServices({
626 project: env.project,
627 region,
628 body: {
629 name,
630 description: desiredDescription,
631 protocol,
632 loadBalancingScheme,
633 timeoutSec,
634 portName: news.portName,
635 enableCDN,
636 sessionAffinity: news.sessionAffinity,
637 affinityCookieTtlSec: news.affinityCookieTtlSec,
638 healthChecks:
639 (news.healthChecks?.length ?? 0) > 0
640 ? news.healthChecks
641 : undefined,
642 backends:
643 desiredBackends.length > 0 ? desiredBackends : undefined,
644 connectionDraining:
645 news.connectionDrainingTimeoutSec !== undefined
646 ? {
647 drainingTimeoutSec: news.connectionDrainingTimeoutSec,
648 }
649 : undefined,
650 compressionMode: news.compressionMode,
651 customRequestHeaders:
652 (news.customRequestHeaders?.length ?? 0) > 0
653 ? news.customRequestHeaders
654 : undefined,
655 customResponseHeaders:
656 (news.customResponseHeaders?.length ?? 0) > 0
657 ? news.customResponseHeaders
658 : undefined,
659 logConfig: news.logConfig,
660 localityLbPolicy: news.localityLbPolicy,
661 network,
662 },
663 })
664 .pipe(
665 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
666 Effect.flatMap((operation) =>
667 operation === undefined
668 ? Effect.void
669 : waitRegionOperation(env.project, region, operation, {
670 ignore: ["RESOURCE_ALREADY_EXISTS"],
671 }).pipe(Effect.asVoid),
672 ),
673 );
674 current = yield* waitPresent(env.project, region, name);
675 }
676
677 if (current === undefined) {
678 return yield* new RegionBackendServiceNotResolved({ name, region });
679 }
680
681 const timeoutChanged =
682 (current.timeoutSec ?? DEFAULT_TIMEOUT_SEC) !== timeoutSec;
683 const protocolChanged =
684 (current.protocol ?? DEFAULT_PROTOCOL) !== protocol;
685 const descriptionChanged =
686 (current.description ?? "") !== desiredDescription;
687 const enableCDNChanged = (current.enableCDN === true) !== enableCDN;
688 const sessionAffinityChanged =
689 (current.sessionAffinity ?? "NONE") !==
690 (news.sessionAffinity ?? "NONE");
691 const affinityCookieChanged =
692 news.affinityCookieTtlSec !== undefined &&
693 current.affinityCookieTtlSec !== news.affinityCookieTtlSec;
694 const portNameChanged =
695 news.portName !== undefined && current.portName !== news.portName;
696 const healthChecksChanged = !sameStringList(
697 current.healthChecks,
698 news.healthChecks,
699 );
700 const backendsChanged = !sameBackends(
701 (current.backends ?? []).map(toBackend),
702 news.backends,
703 );
704 const drainingChanged =
705 news.connectionDrainingTimeoutSec !== undefined &&
706 current.connectionDraining?.drainingTimeoutSec !==
707 news.connectionDrainingTimeoutSec;
708 const compressionChanged =
709 news.compressionMode !== undefined &&
710 current.compressionMode !== news.compressionMode;
711 const requestHeadersChanged =
712 news.customRequestHeaders !== undefined &&
713 !sameStringList(
714 current.customRequestHeaders,
715 news.customRequestHeaders,
716 );
717 const responseHeadersChanged =
718 news.customResponseHeaders !== undefined &&
719 !sameStringList(
720 current.customResponseHeaders,
721 news.customResponseHeaders,
722 );
723 const logConfigChanged =
724 news.logConfig !== undefined &&
725 !sameLogConfig(news.logConfig, current.logConfig);
726 const localityChanged =
727 news.localityLbPolicy !== undefined &&
728 (current.localityLbPolicy ?? "") !== news.localityLbPolicy;
729
730 if (
731 timeoutChanged ||
732 protocolChanged ||
733 descriptionChanged ||
734 enableCDNChanged ||
735 sessionAffinityChanged ||
736 affinityCookieChanged ||
737 portNameChanged ||
738 healthChecksChanged ||
739 backendsChanged ||
740 drainingChanged ||
741 compressionChanged ||
742 requestHeadersChanged ||
743 responseHeadersChanged ||
744 logConfigChanged ||
745 localityChanged
746 ) {
747 const patch: compute.BackendService = {
748 fingerprint: current.fingerprint,
749 };
750 if (timeoutChanged) patch.timeoutSec = timeoutSec;
751 if (protocolChanged) patch.protocol = protocol;
752 if (descriptionChanged) patch.description = desiredDescription;
753 if (enableCDNChanged) patch.enableCDN = enableCDN;
754 if (sessionAffinityChanged) {
755 patch.sessionAffinity = news.sessionAffinity ?? "NONE";
756 }
757 if (affinityCookieChanged) {
758 patch.affinityCookieTtlSec = news.affinityCookieTtlSec;
759 }
760 if (portNameChanged) patch.portName = news.portName;
761 if (healthChecksChanged) patch.healthChecks = news.healthChecks ?? [];
762 if (backendsChanged) patch.backends = desiredBackends;
763 if (drainingChanged) {
764 patch.connectionDraining = {
765 drainingTimeoutSec: news.connectionDrainingTimeoutSec,
766 };
767 }
768 if (compressionChanged) patch.compressionMode = news.compressionMode;
769 if (requestHeadersChanged) {
770 patch.customRequestHeaders = news.customRequestHeaders;
771 }
772 if (responseHeadersChanged) {
773 patch.customResponseHeaders = news.customResponseHeaders;
774 }
775 if (logConfigChanged) patch.logConfig = news.logConfig;
776 if (localityChanged) patch.localityLbPolicy = news.localityLbPolicy;
777
778 yield* compute
779 .patchRegionBackendServices({
780 project: env.project,
781 region,
782 backendService: name,
783 body: patch,
784 })
785 .pipe(
786 Effect.flatMap((operation) =>
787 waitRegionOperation(env.project, region, operation),
788 ),
789 Effect.retry({
790 while: (error) => error._tag === "Conflict",
791 times: 5,
792 schedule: Schedule.exponential("250 millis"),
793 }),
794 );
795 current = (yield* getByName(env.project, region, name)) ?? current;
796 }
797
798 return toAttrs(current, env.project);
799 }),
800
801 delete: Effect.fn(function* ({ output }) {
802 yield* compute
803 .deleteRegionBackendServices({
804 project: output.project,
805 region: output.region,
806 backendService: output.name,
807 })
808 .pipe(
809 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
810 Effect.flatMap((operation) =>
811 operation === undefined
812 ? Effect.void
813 : waitRegionOperation(output.project, output.region, operation, {
814 ignore: ["RESOURCE_NOT_FOUND"],
815 }).pipe(Effect.asVoid),
816 ),
817 Effect.catchTag("NotFound", () => Effect.void),
818 Effect.retry({
819 while: (error) => error._tag === "Conflict",
820 times: 5,
821 schedule: Schedule.spaced("1 second"),
822 }),
823 // Compute rejects the delete while another operation is still in
824 // flight on the backend (e.g. packet-mirroring teardown of the
825 // forwarding rule that fronted it).
826 Effect.retry({
827 while: (error) => error._tag === "ResourceNotReady",
828 times: 20,
829 schedule: Schedule.spaced("10 seconds"),
830 }),
831 );
832 yield* waitGone(output.project, output.region, output.name);
833 }),
834 });