1import * as Hetzner from "@distilled.cloud/hetzner";
2import type {
3 CreateLoadBalancerRequestServicesItem,
4 GetLoadBalancerResponseLoadBalancer,
5 ListLoadBalancersResponseLoadBalancersItem,
6} from "@distilled.cloud/hetzner/load_balancers";
7import * as Data from "effect/Data";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12import { Unowned } from "../AdoptPolicy.ts";
13import { isResolved } from "../Diff.ts";
14import { createPhysicalName } from "../PhysicalName.ts";
15import * as Provider from "../Provider.ts";
16import { Resource } from "../Resource.ts";
17import { tagRecord } from "../Tags.ts";
18import { waitForAction } from "./actions.ts";
19import type { Certificate } from "./Certificate.ts";
20import {
21 alchemyStackSelector,
22 createInternalLabels,
23 diffLabels,
24 hasAlchemyLabels,
25 labelSelector,
26 stripInternalLabels,
27 toLabels,
28} from "./Labels.ts";
29import type { Network } from "./Network.ts";
30import type { Providers } from "./Providers.ts";
31
32const DEFAULT_LOCATION = "nbg1";
33const DEFAULT_TYPE = "lb11";
34const DEFAULT_ALGORITHM = "round_robin";
35const MAX_NAME_LENGTH = 64;
36
37const DEFAULT_HC_INTERVAL = 15;
38const DEFAULT_HC_TIMEOUT = 10;
39const DEFAULT_HC_RETRIES = 3;
40const DEFAULT_COOKIE_NAME = "HCLBSTICKY";
41const DEFAULT_COOKIE_LIFETIME = 300;
42const DEFAULT_TIMEOUT_IDLE = 15;
43
44/**
45 * A resource-valued prop: the resource itself, or an Effect that produces
46 * it (so `yield* Server(...)` and `Server(...)` both type-check).
47 */
48type Ref<T> = T | Effect.Effect<T, never, Providers>;
49
50/**
51 * Server identity a Load Balancer target can point at. A `Hetzner.Server`
52 * resource satisfies this via `serverId`.
53 */
54export type LoadBalancerServer = {
55 readonly serverId: number;
56};
57
58export type LoadBalancerAlgorithm = "round_robin" | "least_connections";
59
60export type LoadBalancerProtocol = "tcp" | "http" | "https";
61
62export type HealthCheckProtocol = "tcp" | "http";
63
64export type TargetHealthStatus = "healthy" | "unhealthy" | "unknown";
65
66export interface LoadBalancerHealthCheckHttp {
67 /**
68 * Host header sent with the HTTP health check. `null` omits the header.
69 */
70 domain?: string | null;
71 /**
72 * HTTP path used for the health check.
73 * @default "/"
74 */
75 path?: string;
76 /**
77 * Substring that must appear in the health-check response body.
78 */
79 response?: string;
80 /**
81 * Status codes that count as healthy. `?` and `*` wildcards are allowed.
82 * @default ["2??", "3??"]
83 */
84 statusCodes?: string[];
85 /**
86 * Use HTTPS for the health-check request.
87 * @default false
88 */
89 tls?: boolean;
90}
91
92export interface LoadBalancerHealthCheck {
93 /**
94 * Health-check protocol. `tcp` for a connect check; `http` for an HTTP GET.
95 */
96 protocol: HealthCheckProtocol;
97 /**
98 * Port the health check is performed on.
99 */
100 port: number;
101 /**
102 * Interval between checks, in seconds.
103 * @default 15
104 */
105 interval?: number;
106 /**
107 * Per-attempt timeout, in seconds.
108 * @default 10
109 */
110 timeout?: number;
111 /**
112 * Consecutive failures (or successes) before flipping healthy/unhealthy.
113 * @default 3
114 */
115 retries?: number;
116 /**
117 * Extra options when `protocol` is `http`.
118 */
119 http?: LoadBalancerHealthCheckHttp;
120}
121
122export interface LoadBalancerServiceHttp {
123 /**
124 * Cookie name used for sticky sessions.
125 * @default "HCLBSTICKY"
126 */
127 cookieName?: string;
128 /**
129 * Sticky-session cookie lifetime in seconds.
130 * @default 300
131 */
132 cookieLifetime?: number;
133 /**
134 * Idle timeout in seconds for client and server connections.
135 * @default 15
136 */
137 timeoutIdle?: number;
138 /**
139 * Pin a client to one target via a cookie.
140 * @default false
141 */
142 stickySessions?: boolean;
143 /**
144 * Redirect HTTP to HTTPS. Only valid when the service protocol is `https`.
145 * @default false
146 */
147 redirectHttp?: boolean;
148 /**
149 * Certificates used for TLS termination. Empty means TLS passthrough.
150 * Accepts `Hetzner.Certificate` resources.
151 */
152 certificates?: Array<Ref<Certificate>>;
153}
154
155export interface LoadBalancerService {
156 /**
157 * Listener protocol.
158 */
159 protocol: LoadBalancerProtocol;
160 /**
161 * Port the Load Balancer listens on. Unique per Load Balancer.
162 */
163 listenPort: number;
164 /**
165 * Port traffic is forwarded to on each target.
166 */
167 destinationPort: number;
168 /**
169 * Enable the PROXY protocol on this service.
170 * @default false
171 */
172 proxyprotocol?: boolean;
173 /**
174 * Health check. Defaults to a TCP (or HTTP, for `http`/`https` services)
175 * check on `destinationPort`.
176 */
177 healthCheck?: LoadBalancerHealthCheck;
178 /**
179 * HTTP/HTTPS options. Ignored for `tcp` services.
180 */
181 http?: LoadBalancerServiceHttp;
182}
183
184export type LoadBalancerTarget =
185 | {
186 type: "server";
187 /**
188 * Server to receive traffic. Accepts a `Hetzner.Server` or `{ serverId }`.
189 */
190 server: Ref<LoadBalancerServer>;
191 /**
192 * Route via the Server's private IP. Requires the Server and Load
193 * Balancer to share a Network.
194 * @default false
195 */
196 usePrivateIp?: boolean;
197 /**
198 * Optional public IP of the Server to use as the target (Primary IPv4
199 * or an IPv6 host in the Server's `/64`).
200 */
201 ip?: string;
202 }
203 | {
204 type: "label_selector";
205 /**
206 * Label selector used to pick target Servers.
207 */
208 selector: string;
209 /**
210 * Route via each Server's private IP.
211 * @default false
212 */
213 usePrivateIp?: boolean;
214 }
215 | {
216 type: "ip";
217 /**
218 * Public or vSwitch IP of a Hetzner Online Root Server owned by the
219 * project owner.
220 */
221 ip: string;
222 };
223
224export interface LoadBalancerProps {
225 /**
226 * Name of the Load Balancer. Must be unique per project, 1–64 characters.
227 * If omitted, a unique name is generated from the stack, stage and
228 * logical ID.
229 */
230 name?: string;
231 /**
232 * Load Balancer type (`lb11`, `lb21`, `lb31`, …). Changing to a larger
233 * type updates in place; Hetzner cannot downgrade a type.
234 * @default "lb11"
235 */
236 loadBalancerType?: string;
237 /**
238 * Location to create the Load Balancer in (`nbg1`, `fsn1`, `hel1`, …).
239 * Mutually exclusive with `networkZone`. Cannot be changed after
240 * creation — changing it replaces the Load Balancer.
241 * @default "nbg1"
242 */
243 location?: string;
244 /**
245 * Network zone (`eu-central`, `us-east`, …). Mutually exclusive with
246 * `location`. Cannot be changed after creation.
247 */
248 networkZone?: string;
249 /**
250 * Balancing algorithm.
251 * @default "round_robin"
252 */
253 algorithm?: LoadBalancerAlgorithm;
254 /**
255 * Listeners. Synced via add/update/delete service actions, keyed by
256 * `listenPort`.
257 */
258 services?: LoadBalancerService[];
259 /**
260 * Backend targets. Server targets take a `Hetzner.Server` (or `{ serverId }`).
261 */
262 targets?: LoadBalancerTarget[];
263 /**
264 * Private Networks to attach. Accepts `Hetzner.Network` resources.
265 */
266 networks?: Array<Ref<Network>>;
267 /**
268 * Expose the public IPv4/IPv6 interface.
269 * @default true
270 */
271 publicInterface?: boolean;
272 /**
273 * Prevent the Load Balancer from being deleted in the Cloud Console /
274 * API until this is cleared. The provider disables protection before
275 * delete.
276 * @default false
277 */
278 deleteProtection?: boolean;
279 /**
280 * User-defined labels. Alchemy ownership labels (`alchemy.stack` /
281 * `alchemy.stage` / `alchemy.id`) are always merged in.
282 */
283 labels?: Record<string, string>;
284}
285
286export interface LoadBalancerServiceAttr {
287 protocol: LoadBalancerProtocol;
288 listenPort: number;
289 destinationPort: number;
290 proxyprotocol: boolean;
291 healthCheck: {
292 protocol: HealthCheckProtocol;
293 port: number;
294 interval: number;
295 timeout: number;
296 retries: number;
297 http?: {
298 domain: string | null;
299 path: string;
300 response?: string;
301 statusCodes?: string[];
302 tls?: boolean;
303 };
304 };
305 http?: {
306 cookieName: string;
307 cookieLifetime: number;
308 timeoutIdle: number;
309 stickySessions: boolean;
310 redirectHttp?: boolean;
311 certificates?: number[];
312 };
313}
314
315export type LoadBalancerTargetAttr =
316 | {
317 type: "server";
318 serverId: number;
319 ip?: string;
320 usePrivateIp: boolean;
321 healthStatus: { listenPort: number; status: TargetHealthStatus }[];
322 }
323 | {
324 type: "label_selector";
325 selector: string;
326 usePrivateIp: boolean;
327 }
328 | {
329 type: "ip";
330 ip: string;
331 healthStatus: { listenPort: number; status: TargetHealthStatus }[];
332 };
333
334export type LoadBalancer = Resource<
335 "Hetzner.LoadBalancer",
336 LoadBalancerProps,
337 {
338 /** Numeric Hetzner Load Balancer ID. */
339 id: number;
340 /** Load Balancer name (unique per project). */
341 name: string;
342 /** Load Balancer type name (`lb11`, …). */
343 loadBalancerType: string;
344 /** Numeric Load Balancer type ID. */
345 loadBalancerTypeId: number;
346 /** Location name (`nbg1`, `fsn1`, …). */
347 location: string;
348 /** Numeric location ID. */
349 locationId: number;
350 /** Network zone (`eu-central`, …). */
351 networkZone: string;
352 /** Balancing algorithm. */
353 algorithm: LoadBalancerAlgorithm;
354 /** Public IPv4, or `null` when the public interface is disabled. */
355 ipv4: string | null;
356 /** Public IPv6, or `null` when the public interface is disabled. */
357 ipv6: string | null;
358 /** Whether the public interface is enabled. */
359 publicInterface: boolean;
360 /** Whether delete protection is enabled. */
361 deleteProtection: boolean;
362 /** Observed listeners. */
363 services: LoadBalancerServiceAttr[];
364 /** Observed targets. */
365 targets: LoadBalancerTargetAttr[];
366 /** Attached private Networks and the assigned IPs. */
367 privateNetworks: { networkId: number; ip: string }[];
368 /** User-defined labels (Alchemy ownership labels stripped). */
369 labels: Record<string, string>;
370 /** RFC3339 creation timestamp. */
371 created: string;
372 },
373 never,
374 Providers
375>;
376
377/**
378 * A Hetzner Cloud Load Balancer. Create it in a Location (`nbg1` by
379 * default) with type `lb11`, then sync algorithm, listeners, targets,
380 * private Networks, delete protection, and labels.
381 *
382 * Location and network zone are immutable (changing either replaces the
383 * Load Balancer). Type can grow in place. Server targets take a
384 * `Hetzner.Server`; HTTPS listeners take `Hetzner.Certificate`s.
385 *
386 * @see https://docs.hetzner.cloud/reference/cloud#load-balancers
387 *
388 * ### Creating a Load Balancer
389 * **Example:** Basic TCP Load Balancer
390 * ```typescript
391 * const lb = yield* Hetzner.LoadBalancer("edge", {
392 * location: "nbg1",
393 * loadBalancerType: "lb11",
394 * services: [
395 * { protocol: "tcp", listenPort: 80, destinationPort: 80 },
396 * ],
397 * });
398 * ```
399 *
400 * **Example:** With a Server target
401 * ```typescript
402 * const server = yield* Hetzner.Server("web", {
403 * serverType: "cx23",
404 * image: "ubuntu-24.04",
405 * location: "nbg1",
406 * });
407 * const lb = yield* Hetzner.LoadBalancer("edge", {
408 * algorithm: "round_robin",
409 * services: [
410 * { protocol: "tcp", listenPort: 80, destinationPort: 80 },
411 * ],
412 * targets: [{ type: "server", server }],
413 * });
414 * ```
415 *
416 * ### HTTPS with a Certificate
417 * **Example:** Terminate TLS
418 * ```typescript
419 * const cert = yield* Hetzner.Certificate("web", {
420 * certificate: pem,
421 * privateKey: key,
422 * });
423 * const lb = yield* Hetzner.LoadBalancer("edge", {
424 * services: [
425 * {
426 * protocol: "https",
427 * listenPort: 443,
428 * destinationPort: 80,
429 * http: { certificates: [cert], redirectHttp: true },
430 * },
431 * ],
432 * });
433 * ```
434 *
435 * ### Private Networks
436 * **Example:** Attach to a Network
437 * ```typescript
438 * const network = yield* Hetzner.Network("vpc", {
439 * ipRange: "10.0.0.0/16",
440 * subnets: [
441 * { type: "cloud", ipRange: "10.0.1.0/24", networkZone: "eu-central" },
442 * ],
443 * });
444 * const lb = yield* Hetzner.LoadBalancer("edge", {
445 * networks: [network],
446 * services: [
447 * { protocol: "tcp", listenPort: 80, destinationPort: 80 },
448 * ],
449 * });
450 * ```
451 *
452 * @resource
453 * @product Load Balancer
454 */
455export const LoadBalancer = Resource<LoadBalancer>("Hetzner.LoadBalancer");
456
457type CloudLoadBalancer =
458 | GetLoadBalancerResponseLoadBalancer
459 | ListLoadBalancersResponseLoadBalancersItem;
460
461export class LoadBalancerNotCreated extends Data.TaggedError(
462 "Hetzner.LoadBalancerNotCreated",
463)<{
464 name: string;
465}> {}
466
467const asAlgorithm = (value: string): LoadBalancerAlgorithm =>
468 value === "least_connections" ? "least_connections" : "round_robin";
469
470const asProtocol = (value: string): LoadBalancerProtocol =>
471 value === "https" ? "https" : value === "http" ? "http" : "tcp";
472
473const asHealthProtocol = (value: string): HealthCheckProtocol =>
474 value === "http" ? "http" : "tcp";
475
476const asHealthStatus = (value: string): TargetHealthStatus =>
477 value === "healthy" || value === "unhealthy" ? value : "unknown";
478
479const userLabels = (
480 labels: Record<string, string | undefined> | null | undefined,
481): Record<string, string> => stripInternalLabels(tagRecord(labels));
482
483const serverIdOf = (value: unknown): number | undefined => {
484 if (value === null || typeof value !== "object") return undefined;
485 const rec = value as { serverId?: unknown; id?: unknown };
486 if (typeof rec.serverId === "number") return rec.serverId;
487 if (typeof rec.id === "number") return rec.id;
488 return undefined;
489};
490
491const networkIdOf = (value: unknown): number | undefined => {
492 if (typeof value === "number") return value;
493 if (value === null || typeof value !== "object") return undefined;
494 const rec = value as { networkId?: unknown; id?: unknown };
495 if (typeof rec.networkId === "number") return rec.networkId;
496 if (typeof rec.id === "number") return rec.id;
497 return undefined;
498};
499
500const certificateIdOf = (value: unknown): number | undefined => {
501 if (typeof value === "number") return value;
502 if (value === null || typeof value !== "object") return undefined;
503 const rec = value as { id?: unknown };
504 return typeof rec.id === "number" ? rec.id : undefined;
505};
506
507const recordOf = (value: unknown): Record<string, unknown> =>
508 value !== null && typeof value === "object"
509 ? (value as Record<string, unknown>)
510 : {};
511
512const asNumber = (value: unknown): number | undefined =>
513 typeof value === "number" ? value : undefined;
514
515const asString = (value: unknown): string | undefined =>
516 typeof value === "string" ? value : undefined;
517
518const asBoolean = (value: unknown): boolean | undefined =>
519 typeof value === "boolean" ? value : undefined;
520
521const asStringArray = (value: unknown): string[] | undefined =>
522 Array.isArray(value)
523 ? value.filter((item): item is string => typeof item === "string")
524 : undefined;
525
526type NormalizedHealthHttp = {
527 domain: string | null;
528 path: string;
529 response: string;
530 statusCodes: string[];
531 tls: boolean;
532};
533
534type NormalizedHealthCheck = {
535 protocol: HealthCheckProtocol;
536 port: number;
537 interval: number;
538 timeout: number;
539 retries: number;
540 http?: NormalizedHealthHttp;
541};
542
543type NormalizedServiceHttp = {
544 cookieName: string;
545 cookieLifetime: number;
546 timeoutIdle: number;
547 stickySessions: boolean;
548 redirectHttp?: boolean;
549 certificates?: number[];
550};
551
552type NormalizedService = {
553 protocol: LoadBalancerProtocol;
554 listenPort: number;
555 destinationPort: number;
556 proxyprotocol: boolean;
557 healthCheck: NormalizedHealthCheck;
558 http?: NormalizedServiceHttp;
559};
560
561type NormalizedTarget =
562 | {
563 type: "server";
564 serverId: number;
565 ip?: string;
566 usePrivateIp: boolean;
567 }
568 | {
569 type: "label_selector";
570 selector: string;
571 usePrivateIp: boolean;
572 }
573 | {
574 type: "ip";
575 ip: string;
576 };
577
578const defaultHealthCheck = (
579 protocol: LoadBalancerProtocol,
580 destinationPort: number,
581 override?: LoadBalancerHealthCheck,
582): NormalizedHealthCheck => {
583 const hcProtocol =
584 override?.protocol ?? (protocol === "tcp" ? "tcp" : "http");
585 const httpSource = override?.http;
586 return {
587 protocol: hcProtocol,
588 port: override?.port ?? destinationPort,
589 interval: override?.interval ?? DEFAULT_HC_INTERVAL,
590 timeout: override?.timeout ?? DEFAULT_HC_TIMEOUT,
591 retries: override?.retries ?? DEFAULT_HC_RETRIES,
592 http:
593 hcProtocol === "http"
594 ? {
595 domain: httpSource?.domain ?? null,
596 path: httpSource?.path ?? "/",
597 response: httpSource?.response ?? "",
598 statusCodes: [
599 ...(httpSource?.statusCodes ?? ["2??", "3??"]),
600 ].sort(),
601 tls: httpSource?.tls ?? false,
602 }
603 : undefined,
604 };
605};
606
607const defaultServiceHttp = (
608 protocol: LoadBalancerProtocol,
609 http: LoadBalancerServiceHttp | undefined,
610): NormalizedServiceHttp | undefined => {
611 if (protocol === "tcp") return undefined;
612 const certificates = (http?.certificates ?? [])
613 .map(certificateIdOf)
614 .filter((id): id is number => id !== undefined)
615 .sort((a, b) => a - b);
616 const userProvided = http !== undefined;
617 if (!userProvided && protocol !== "https") return undefined;
618 return {
619 cookieName: http?.cookieName ?? DEFAULT_COOKIE_NAME,
620 cookieLifetime: http?.cookieLifetime ?? DEFAULT_COOKIE_LIFETIME,
621 timeoutIdle: http?.timeoutIdle ?? DEFAULT_TIMEOUT_IDLE,
622 stickySessions: http?.stickySessions ?? false,
623 ...(protocol === "https"
624 ? {
625 redirectHttp: http?.redirectHttp ?? false,
626 certificates,
627 }
628 : {}),
629 };
630};
631
632const desiredServices = (
633 services: readonly LoadBalancerService[] | undefined,
634): NormalizedService[] =>
635 (services ?? [])
636 .map((service) => ({
637 protocol: service.protocol,
638 listenPort: service.listenPort,
639 destinationPort: service.destinationPort,
640 proxyprotocol: service.proxyprotocol ?? false,
641 healthCheck: defaultHealthCheck(
642 service.protocol,
643 service.destinationPort,
644 service.healthCheck,
645 ),
646 http: defaultServiceHttp(service.protocol, service.http),
647 }))
648 .sort((a, b) => a.listenPort - b.listenPort);
649
650const observedHealthHttp = (
651 value: unknown,
652): NormalizedHealthHttp | undefined => {
653 if (value === null || typeof value !== "object") return undefined;
654 const rec = recordOf(value);
655 const statusCodes = asStringArray(rec.status_codes) ?? [];
656 return {
657 domain:
658 rec.domain === undefined ? null : ((rec.domain as string | null) ?? null),
659 path: asString(rec.path) ?? "/",
660 response: asString(rec.response) ?? "",
661 statusCodes: [...statusCodes].sort(),
662 tls: asBoolean(rec.tls) ?? false,
663 };
664};
665
666const observedHealthCheck = (
667 value: unknown,
668 destinationPort: number,
669 protocol: LoadBalancerProtocol,
670): NormalizedHealthCheck => {
671 const rec = recordOf(value);
672 const hcProtocol = asHealthProtocol(
673 asString(rec.protocol) ?? (protocol === "tcp" ? "tcp" : "http"),
674 );
675 return {
676 protocol: hcProtocol,
677 port: asNumber(rec.port) ?? destinationPort,
678 interval: asNumber(rec.interval) ?? DEFAULT_HC_INTERVAL,
679 timeout: asNumber(rec.timeout) ?? DEFAULT_HC_TIMEOUT,
680 retries: asNumber(rec.retries) ?? DEFAULT_HC_RETRIES,
681 http: hcProtocol === "http" ? observedHealthHttp(rec.http) : undefined,
682 };
683};
684
685const observedServiceHttp = (
686 protocol: LoadBalancerProtocol,
687 value: unknown,
688): NormalizedServiceHttp | undefined => {
689 if (protocol === "tcp") return undefined;
690 const rec = recordOf(value);
691 const certificates = Array.isArray(rec.certificates)
692 ? rec.certificates
693 .filter((id): id is number => typeof id === "number")
694 .sort((a, b) => a - b)
695 : [];
696 return {
697 cookieName: asString(rec.cookie_name) ?? DEFAULT_COOKIE_NAME,
698 cookieLifetime: asNumber(rec.cookie_lifetime) ?? DEFAULT_COOKIE_LIFETIME,
699 timeoutIdle: asNumber(rec.timeout_idle) ?? DEFAULT_TIMEOUT_IDLE,
700 stickySessions: asBoolean(rec.sticky_sessions) ?? false,
701 ...(protocol === "https"
702 ? {
703 redirectHttp: asBoolean(rec.redirect_http) ?? false,
704 certificates,
705 }
706 : {}),
707 };
708};
709
710const observedServices = (value: unknown): NormalizedService[] => {
711 if (!Array.isArray(value)) return [];
712 return value
713 .map((item) => {
714 const rec = recordOf(item);
715 const protocol = asProtocol(asString(rec.protocol) ?? "tcp");
716 const destinationPort = asNumber(rec.destination_port) ?? 80;
717 return {
718 protocol,
719 listenPort: asNumber(rec.listen_port) ?? 0,
720 destinationPort,
721 proxyprotocol: asBoolean(rec.proxyprotocol) ?? false,
722 healthCheck: observedHealthCheck(
723 rec.health_check,
724 destinationPort,
725 protocol,
726 ),
727 http: observedServiceHttp(protocol, rec.http),
728 };
729 })
730 .sort((a, b) => a.listenPort - b.listenPort);
731};
732
733const serviceFingerprint = (service: NormalizedService): string =>
734 JSON.stringify(service);
735
736const toServiceAttr = (
737 service: NormalizedService,
738): LoadBalancerServiceAttr => ({
739 protocol: service.protocol,
740 listenPort: service.listenPort,
741 destinationPort: service.destinationPort,
742 proxyprotocol: service.proxyprotocol,
743 healthCheck: {
744 protocol: service.healthCheck.protocol,
745 port: service.healthCheck.port,
746 interval: service.healthCheck.interval,
747 timeout: service.healthCheck.timeout,
748 retries: service.healthCheck.retries,
749 ...(service.healthCheck.http
750 ? {
751 http: {
752 domain: service.healthCheck.http.domain,
753 path: service.healthCheck.http.path,
754 ...(service.healthCheck.http.response
755 ? { response: service.healthCheck.http.response }
756 : {}),
757 ...(service.healthCheck.http.statusCodes.length > 0
758 ? { statusCodes: service.healthCheck.http.statusCodes }
759 : {}),
760 ...(service.healthCheck.http.tls ? { tls: true } : {}),
761 },
762 }
763 : {}),
764 },
765 ...(service.http
766 ? {
767 http: {
768 cookieName: service.http.cookieName,
769 cookieLifetime: service.http.cookieLifetime,
770 timeoutIdle: service.http.timeoutIdle,
771 stickySessions: service.http.stickySessions,
772 ...(service.http.redirectHttp !== undefined
773 ? { redirectHttp: service.http.redirectHttp }
774 : {}),
775 ...(service.http.certificates !== undefined
776 ? { certificates: service.http.certificates }
777 : {}),
778 },
779 }
780 : {}),
781});
782
783const toHealthCheckRequest = (healthCheck: NormalizedHealthCheck) => ({
784 protocol: healthCheck.protocol,
785 port: healthCheck.port,
786 interval: healthCheck.interval,
787 timeout: healthCheck.timeout,
788 retries: healthCheck.retries,
789 ...(healthCheck.http
790 ? {
791 http: {
792 ...(healthCheck.http.domain !== null
793 ? { domain: healthCheck.http.domain }
794 : {}),
795 path: healthCheck.http.path,
796 ...(healthCheck.http.response
797 ? { response: healthCheck.http.response }
798 : {}),
799 status_codes: healthCheck.http.statusCodes,
800 tls: healthCheck.http.tls,
801 },
802 }
803 : {}),
804});
805
806const toHttpRequest = (http: NormalizedServiceHttp | undefined) => {
807 if (http === undefined) return undefined;
808 return {
809 cookie_name: http.cookieName,
810 cookie_lifetime: http.cookieLifetime,
811 sticky_sessions: http.stickySessions,
812 ...(http.redirectHttp !== undefined
813 ? { redirect_http: http.redirectHttp }
814 : {}),
815 ...(http.certificates !== undefined
816 ? { certificates: http.certificates }
817 : {}),
818 };
819};
820
821const toCreateService = (
822 service: NormalizedService,
823): CreateLoadBalancerRequestServicesItem =>
824 ({
825 protocol: service.protocol,
826 listen_port: service.listenPort,
827 destination_port: service.destinationPort,
828 proxyprotocol: service.proxyprotocol,
829 health_check: toHealthCheckRequest(service.healthCheck),
830 ...(service.http ? { http: toHttpRequest(service.http) } : {}),
831 }) as CreateLoadBalancerRequestServicesItem;
832
833const desiredTargets = (
834 targets: readonly LoadBalancerTarget[] | undefined,
835): NormalizedTarget[] => {
836 const next: NormalizedTarget[] = [];
837 for (const target of targets ?? []) {
838 if (target.type === "server") {
839 const serverId = serverIdOf(target.server);
840 if (serverId === undefined) continue;
841 next.push({
842 type: "server",
843 serverId,
844 ...(target.ip !== undefined ? { ip: target.ip } : {}),
845 usePrivateIp: target.usePrivateIp ?? false,
846 });
847 } else if (target.type === "label_selector") {
848 next.push({
849 type: "label_selector",
850 selector: target.selector,
851 usePrivateIp: target.usePrivateIp ?? false,
852 });
853 } else {
854 next.push({ type: "ip", ip: target.ip });
855 }
856 }
857 return next.sort((a, b) => targetKey(a).localeCompare(targetKey(b)));
858};
859
860const targetKey = (target: NormalizedTarget): string => {
861 if (target.type === "server") {
862 return `server:${target.serverId}:${target.usePrivateIp ? 1 : 0}:${target.ip ?? ""}`;
863 }
864 if (target.type === "label_selector") {
865 return `label_selector:${target.selector}:${target.usePrivateIp ? 1 : 0}`;
866 }
867 return `ip:${target.ip}`;
868};
869
870const observedHealthStatus = (
871 value: unknown,
872): { listenPort: number; status: TargetHealthStatus }[] => {
873 if (!Array.isArray(value)) return [];
874 return value.flatMap((item) => {
875 const rec = recordOf(item);
876 const listenPort = asNumber(rec.listen_port);
877 if (listenPort === undefined) return [];
878 return [
879 {
880 listenPort,
881 status: asHealthStatus(asString(rec.status) ?? "unknown"),
882 },
883 ];
884 });
885};
886
887const observedTargets = (value: unknown): NormalizedTarget[] => {
888 if (!Array.isArray(value)) return [];
889 const next: NormalizedTarget[] = [];
890 for (const item of value) {
891 const rec = recordOf(item);
892 const type = asString(rec.type);
893 if (type === "server") {
894 const server = recordOf(rec.server);
895 const serverId = asNumber(server.id);
896 if (serverId === undefined) continue;
897 const ip = asString(server.ip);
898 next.push({
899 type: "server",
900 serverId,
901 ...(ip !== undefined ? { ip } : {}),
902 usePrivateIp: asBoolean(rec.use_private_ip) ?? false,
903 });
904 } else if (type === "label_selector") {
905 const selector = asString(recordOf(rec.label_selector).selector);
906 if (selector === undefined) continue;
907 next.push({
908 type: "label_selector",
909 selector,
910 usePrivateIp: asBoolean(rec.use_private_ip) ?? false,
911 });
912 } else if (type === "ip") {
913 const ip = asString(recordOf(rec.ip).ip);
914 if (ip === undefined) continue;
915 next.push({ type: "ip", ip });
916 }
917 }
918 return next.sort((a, b) => targetKey(a).localeCompare(targetKey(b)));
919};
920
921const toTargetAttr = (
922 target: NormalizedTarget,
923 raw: unknown,
924): LoadBalancerTargetAttr => {
925 const rec = recordOf(raw);
926 if (target.type === "server") {
927 return {
928 type: "server",
929 serverId: target.serverId,
930 ...(target.ip !== undefined ? { ip: target.ip } : {}),
931 usePrivateIp: target.usePrivateIp,
932 healthStatus: observedHealthStatus(rec.health_status),
933 };
934 }
935 if (target.type === "label_selector") {
936 return {
937 type: "label_selector",
938 selector: target.selector,
939 usePrivateIp: target.usePrivateIp,
940 };
941 }
942 return {
943 type: "ip",
944 ip: target.ip,
945 healthStatus: observedHealthStatus(rec.health_status),
946 };
947};
948
949const desiredNetworkIds = (
950 networks: LoadBalancerProps["networks"] | undefined,
951): number[] => {
952 const ids = new Set<number>();
953 for (const item of networks ?? []) {
954 const id = networkIdOf(item);
955 if (id !== undefined) ids.add(id);
956 }
957 return [...ids].sort((a, b) => a - b);
958};
959
960const observedPrivateNetworks = (
961 value: unknown,
962): { networkId: number; ip: string }[] => {
963 if (!Array.isArray(value)) return [];
964 return value
965 .flatMap((item) => {
966 const rec = recordOf(item);
967 const networkId = asNumber(rec.network);
968 const ip = asString(rec.ip);
969 if (networkId === undefined || ip === undefined) return [];
970 return [{ networkId, ip }];
971 })
972 .sort((a, b) => a.networkId - b.networkId);
973};
974
975const toAttrs = (lb: CloudLoadBalancer): LoadBalancer["Attributes"] => {
976 const services = observedServices(lb.services);
977 const rawTargets = Array.isArray(lb.targets) ? lb.targets : [];
978 const targets = observedTargets(lb.targets);
979 const targetAttrs = targets.map((target, index) =>
980 toTargetAttr(target, rawTargets[index]),
981 );
982 return {
983 id: lb.id,
984 name: lb.name,
985 loadBalancerType: lb.load_balancer_type.name,
986 loadBalancerTypeId: lb.load_balancer_type.id,
987 location: lb.location.name,
988 locationId: lb.location.id,
989 networkZone: lb.location.network_zone,
990 algorithm: asAlgorithm(lb.algorithm.type),
991 ipv4: lb.public_net.ipv4.ip,
992 ipv6: lb.public_net.ipv6.ip,
993 publicInterface: lb.public_net.enabled,
994 deleteProtection: lb.protection.delete,
995 services: services.map(toServiceAttr),
996 targets: targetAttrs,
997 privateNetworks: observedPrivateNetworks(lb.private_net),
998 labels: userLabels(lb.labels),
999 created: lb.created,
1000 };
1001};
1002
1003const retryable = (e: { readonly _tag: string }): boolean =>
1004 e._tag === "TooManyRequests" ||
1005 e._tag === "ServiceUnavailable" ||
1006 e._tag === "InternalServerError" ||
1007 e._tag === "BadGateway" ||
1008 e._tag === "GatewayTimeout" ||
1009 e._tag === "Locked" ||
1010 e._tag === "Conflict";
1011
1012const backoff = Schedule.min([
1013 Schedule.exponential(Duration.millis(500), 1.5),
1014 Schedule.spaced(Duration.seconds(5)),
1015]);
1016
1017const busyRetry = {
1018 while: retryable,
1019 times: 8,
1020 schedule: backoff,
1021} as const;
1022
1023const alreadyThere = (e: { readonly _tag: string }): boolean =>
1024 e._tag === "Conflict" || e._tag === "UnprocessableEntity";
1025
1026const alreadyGone = (e: { readonly _tag: string }): boolean =>
1027 e._tag === "NotFound" || e._tag === "UnprocessableEntity";
1028
1029const createLoadBalancerName = (
1030 id: string,
1031 name: string | undefined,
1032 existing?: string,
1033) =>
1034 Effect.gen(function* () {
1035 return (
1036 name ??
1037 existing ??
1038 (yield* createPhysicalName({
1039 id,
1040 maxLength: MAX_NAME_LENGTH,
1041 lowercase: true,
1042 }))
1043 );
1044 });
1045
1046const getById = (id: number) =>
1047 Hetzner.loadBalancers.getLoadBalancer({ id }).pipe(
1048 Effect.map(({ load_balancer }) => load_balancer),
1049 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
1050 );
1051
1052const getByName = (name: string) =>
1053 Hetzner.loadBalancers
1054 .listLoadBalancers({ name, per_page: 50 })
1055 .pipe(
1056 Effect.map(({ load_balancers }) =>
1057 load_balancers.find((item) => item.name === name),
1058 ),
1059 );
1060
1061const getByLabels = (labels: Record<string, string>) =>
1062 Hetzner.loadBalancers
1063 .listLoadBalancers({
1064 label_selector: labelSelector(labels),
1065 per_page: 50,
1066 })
1067 .pipe(Effect.map(({ load_balancers }) => load_balancers[0]));
1068
1069const observe = Effect.fn(function* ({
1070 id,
1071 name,
1072 outputId,
1073}: {
1074 id: string;
1075 name?: string;
1076 outputId?: number;
1077}) {
1078 if (outputId !== undefined) {
1079 const byId = yield* getById(outputId);
1080 if (byId !== undefined) return byId;
1081 }
1082 if (name !== undefined) {
1083 const byName = yield* getByName(name);
1084 if (byName !== undefined) return byName;
1085 }
1086 const internal = yield* createInternalLabels(id);
1087 return yield* getByLabels(internal);
1088});
1089
1090const refresh = (id: number) =>
1091 Hetzner.loadBalancers.getLoadBalancer({ id }).pipe(
1092 Effect.map(({ load_balancer }) => load_balancer),
1093 Effect.retry({
1094 while: (e) => e._tag === "NotFound" || retryable(e),
1095 times: 8,
1096 schedule: backoff,
1097 }),
1098 );
1099
1100const runAction = <E extends { readonly _tag: string }, R>(
1101 effect: Effect.Effect<{ action: { id: number } }, E, R>,
1102) =>
1103 effect.pipe(
1104 Effect.retry(busyRetry),
1105 Effect.flatMap(({ action }) => waitForAction(action)),
1106 );
1107
1108const matchesPlacement = (
1109 current: CloudLoadBalancer,
1110 location: string | undefined,
1111 networkZone: string | undefined,
1112): boolean => {
1113 if (location !== undefined && current.location.name !== location) {
1114 return false;
1115 }
1116 if (
1117 networkZone !== undefined &&
1118 current.location.network_zone !== networkZone
1119 ) {
1120 return false;
1121 }
1122 return true;
1123};
1124
1125const syncMetadata = (args: {
1126 current: CloudLoadBalancer;
1127 name: string;
1128 labels: Record<string, string>;
1129}) =>
1130 Effect.gen(function* () {
1131 const observedLabels = tagRecord(args.current.labels);
1132 const { removed, upsert } = diffLabels(observedLabels, args.labels);
1133 const labelsChanged = removed.length > 0 || upsert.length > 0;
1134 const nameChanged = args.current.name !== args.name;
1135 if (!labelsChanged && !nameChanged) return args.current;
1136 const updated = yield* Hetzner.loadBalancers
1137 .updateLoadBalancer({
1138 id: args.current.id,
1139 name: nameChanged ? args.name : undefined,
1140 labels: labelsChanged ? args.labels : undefined,
1141 })
1142 .pipe(Effect.retry(busyRetry));
1143 return updated.load_balancer;
1144 });
1145
1146const syncAlgorithm = (
1147 current: CloudLoadBalancer,
1148 desired: LoadBalancerAlgorithm,
1149) =>
1150 Effect.gen(function* () {
1151 if (asAlgorithm(current.algorithm.type) === desired) return;
1152 yield* runAction(
1153 Hetzner.loadBalancerActions.changeLoadBalancerAlgorithm({
1154 id: current.id,
1155 type: desired,
1156 }),
1157 );
1158 });
1159
1160const syncType = (current: CloudLoadBalancer, desired: string) =>
1161 Effect.gen(function* () {
1162 if (current.load_balancer_type.name === desired) return;
1163 yield* runAction(
1164 Hetzner.loadBalancerActions.changeLoadBalancerType({
1165 id: current.id,
1166 load_balancer_type: desired,
1167 }),
1168 );
1169 });
1170
1171const syncProtection = (current: CloudLoadBalancer, desired: boolean) =>
1172 Effect.gen(function* () {
1173 if (current.protection.delete === desired) return;
1174 yield* runAction(
1175 Hetzner.loadBalancerActions.changeLoadBalancerProtection({
1176 id: current.id,
1177 delete: desired,
1178 }),
1179 );
1180 });
1181
1182const syncPublicInterface = (current: CloudLoadBalancer, desired: boolean) =>
1183 Effect.gen(function* () {
1184 if (current.public_net.enabled === desired) return;
1185 if (desired) {
1186 yield* runAction(
1187 Hetzner.loadBalancerActions.enableLoadBalancerPublicInterface({
1188 id: current.id,
1189 }),
1190 );
1191 return;
1192 }
1193 yield* runAction(
1194 Hetzner.loadBalancerActions.disableLoadBalancerPublicInterface({
1195 id: current.id,
1196 }),
1197 );
1198 });
1199
1200const syncServices = (
1201 loadBalancerId: number,
1202 observed: readonly NormalizedService[],
1203 desired: readonly NormalizedService[],
1204) =>
1205 Effect.gen(function* () {
1206 const observedByPort = new Map(
1207 observed.map((service) => [service.listenPort, service]),
1208 );
1209 const desiredByPort = new Map(
1210 desired.map((service) => [service.listenPort, service]),
1211 );
1212
1213 for (const service of observed) {
1214 if (desiredByPort.has(service.listenPort)) continue;
1215 yield* runAction(
1216 Hetzner.loadBalancerActions.deleteLoadBalancerService({
1217 id: loadBalancerId,
1218 listen_port: service.listenPort,
1219 }),
1220 ).pipe(Effect.catchIf(alreadyGone, () => Effect.void));
1221 }
1222
1223 for (const service of desired) {
1224 const existing = observedByPort.get(service.listenPort);
1225 if (existing === undefined) {
1226 yield* runAction(
1227 Hetzner.loadBalancerActions.addLoadBalancerService({
1228 id: loadBalancerId,
1229 protocol: service.protocol,
1230 listen_port: service.listenPort,
1231 destination_port: service.destinationPort,
1232 proxyprotocol: service.proxyprotocol,
1233 health_check: toHealthCheckRequest(service.healthCheck),
1234 http: toHttpRequest(service.http),
1235 }),
1236 ).pipe(
1237 Effect.catchIf(
1238 (e) => e._tag === "Conflict" || e._tag === "PreconditionFailed",
1239 () => Effect.void,
1240 ),
1241 );
1242 continue;
1243 }
1244 if (serviceFingerprint(existing) === serviceFingerprint(service)) {
1245 continue;
1246 }
1247 yield* runAction(
1248 Hetzner.loadBalancerActions.updateLoadBalancerService({
1249 id: loadBalancerId,
1250 protocol: service.protocol,
1251 listen_port: service.listenPort,
1252 destination_port: service.destinationPort,
1253 proxyprotocol: service.proxyprotocol,
1254 health_check: toHealthCheckRequest(service.healthCheck),
1255 http: toHttpRequest(service.http),
1256 }),
1257 );
1258 }
1259 });
1260
1261const addTargetRequest = (loadBalancerId: number, target: NormalizedTarget) => {
1262 if (target.type === "server") {
1263 return Hetzner.loadBalancerActions.addLoadBalancerTarget({
1264 id: loadBalancerId,
1265 type: "server",
1266 server: {
1267 id: target.serverId,
1268 ...(target.ip !== undefined ? { ip: target.ip } : {}),
1269 },
1270 use_private_ip: target.usePrivateIp,
1271 });
1272 }
1273 if (target.type === "label_selector") {
1274 return Hetzner.loadBalancerActions.addLoadBalancerTarget({
1275 id: loadBalancerId,
1276 type: "label_selector",
1277 label_selector: { selector: target.selector },
1278 use_private_ip: target.usePrivateIp,
1279 });
1280 }
1281 return Hetzner.loadBalancerActions.addLoadBalancerTarget({
1282 id: loadBalancerId,
1283 type: "ip",
1284 ip: { ip: target.ip },
1285 });
1286};
1287
1288const removeTargetRequest = (
1289 loadBalancerId: number,
1290 target: NormalizedTarget,
1291) => {
1292 if (target.type === "server") {
1293 return Hetzner.loadBalancerActions.removeLoadBalancerTarget({
1294 id: loadBalancerId,
1295 type: "server",
1296 server: {
1297 id: target.serverId,
1298 ...(target.ip !== undefined ? { ip: target.ip } : {}),
1299 },
1300 });
1301 }
1302 if (target.type === "label_selector") {
1303 return Hetzner.loadBalancerActions.removeLoadBalancerTarget({
1304 id: loadBalancerId,
1305 type: "label_selector",
1306 label_selector: { selector: target.selector },
1307 });
1308 }
1309 return Hetzner.loadBalancerActions.removeLoadBalancerTarget({
1310 id: loadBalancerId,
1311 type: "ip",
1312 ip: { ip: target.ip },
1313 });
1314};
1315
1316const syncTargets = (
1317 loadBalancerId: number,
1318 observed: readonly NormalizedTarget[],
1319 desired: readonly NormalizedTarget[],
1320) =>
1321 Effect.gen(function* () {
1322 const observedKeys = new Set(observed.map(targetKey));
1323 const desiredKeys = new Set(desired.map(targetKey));
1324
1325 for (const target of observed) {
1326 if (desiredKeys.has(targetKey(target))) continue;
1327 yield* runAction(removeTargetRequest(loadBalancerId, target)).pipe(
1328 Effect.catchIf(alreadyGone, () => Effect.void),
1329 );
1330 }
1331
1332 for (const target of desired) {
1333 if (observedKeys.has(targetKey(target))) continue;
1334 yield* addTargetRequest(loadBalancerId, target).pipe(
1335 Effect.retry({
1336 while: (e) => retryable(e) || e._tag === "UnprocessableEntity",
1337 times: 8,
1338 schedule: backoff,
1339 }),
1340 Effect.flatMap(({ action }) => waitForAction(action)),
1341 Effect.catchIf(alreadyThere, () => Effect.void),
1342 );
1343 }
1344 });
1345
1346const syncNetworks = (
1347 loadBalancerId: number,
1348 observed: readonly { networkId: number }[],
1349 desired: readonly number[],
1350) =>
1351 Effect.gen(function* () {
1352 const observedIds = new Set(observed.map((item) => item.networkId));
1353 const desiredIds = new Set(desired);
1354
1355 for (const item of observed) {
1356 if (desiredIds.has(item.networkId)) continue;
1357 yield* runAction(
1358 Hetzner.loadBalancerActions.detachLoadBalancerFromNetwork({
1359 id: loadBalancerId,
1360 network: item.networkId,
1361 }),
1362 ).pipe(Effect.catchIf(alreadyGone, () => Effect.void));
1363 }
1364
1365 for (const networkId of desired) {
1366 if (observedIds.has(networkId)) continue;
1367 yield* runAction(
1368 Hetzner.loadBalancerActions.attachLoadBalancerToNetwork({
1369 id: loadBalancerId,
1370 network: networkId,
1371 }),
1372 ).pipe(Effect.catchIf(alreadyThere, () => Effect.void));
1373 }
1374 });
1375
1376export const LoadBalancerProvider = () =>
1377 Provider.succeed(LoadBalancer, {
1378 stables: ["id", "location", "locationId", "networkZone", "created"],
1379
1380 list: Effect.fn(function* () {
1381 const items = yield* Hetzner.loadBalancers.listLoadBalancers
1382 .items({ label_selector: alchemyStackSelector, per_page: 50 })
1383 .pipe(
1384 Stream.runCollect,
1385 Effect.map((chunk) => Array.from(chunk)),
1386 );
1387 return items.map(toAttrs);
1388 }),
1389
1390 diff: Effect.fn(function* ({ news, output }) {
1391 if (!isResolved(news)) return undefined;
1392 if (output !== undefined) {
1393 if (news.location !== undefined && news.location !== output.location) {
1394 return { action: "replace" } as const;
1395 }
1396 if (
1397 news.networkZone !== undefined &&
1398 news.networkZone !== output.networkZone
1399 ) {
1400 return { action: "replace" } as const;
1401 }
1402 }
1403 return undefined;
1404 }),
1405
1406 read: Effect.fn(function* ({ id, olds, output }) {
1407 const found = yield* observe({
1408 id,
1409 name: olds?.name ?? output?.name,
1410 outputId: output?.id,
1411 });
1412 if (found === undefined) return undefined;
1413 const attrs = toAttrs(found);
1414 const owned = yield* hasAlchemyLabels(id, tagRecord(found.labels));
1415 return owned ? attrs : Unowned(attrs);
1416 }),
1417
1418 reconcile: Effect.fn(function* ({ id, news, output }) {
1419 const name = yield* createLoadBalancerName(id, news.name, output?.name);
1420 const internalLabels = yield* createInternalLabels(id);
1421 const desiredLabels = {
1422 ...toLabels(news.labels),
1423 ...internalLabels,
1424 };
1425 const loadBalancerType = news.loadBalancerType ?? DEFAULT_TYPE;
1426 const algorithm = news.algorithm ?? DEFAULT_ALGORITHM;
1427 const location =
1428 news.location ??
1429 (news.networkZone === undefined ? DEFAULT_LOCATION : undefined);
1430 const networkZone =
1431 news.location === undefined ? news.networkZone : undefined;
1432 const publicInterface = news.publicInterface ?? true;
1433 const deleteProtection = news.deleteProtection ?? false;
1434 const services = desiredServices(news.services);
1435 const targets = desiredTargets(news.targets);
1436 const networks = desiredNetworkIds(news.networks);
1437
1438 // Observe by id then desired name only. Do not fall back to
1439 // ownership labels — a create-first replacement still has the old
1440 // generation live under the same logical id.
1441 let current =
1442 output?.id !== undefined ? yield* getById(output.id) : undefined;
1443 if (current === undefined) {
1444 current = yield* getByName(name);
1445 }
1446 if (
1447 current !== undefined &&
1448 !matchesPlacement(current, location, networkZone)
1449 ) {
1450 current = undefined;
1451 }
1452
1453 if (current === undefined) {
1454 const created = yield* Hetzner.loadBalancers
1455 .createLoadBalancer({
1456 name,
1457 load_balancer_type: loadBalancerType,
1458 algorithm: { type: algorithm },
1459 labels: desiredLabels,
1460 public_interface: publicInterface,
1461 ...(location !== undefined ? { location } : {}),
1462 ...(networkZone !== undefined ? { network_zone: networkZone } : {}),
1463 ...(networks[0] !== undefined ? { network: networks[0] } : {}),
1464 ...(services.length > 0
1465 ? { services: services.map(toCreateService) }
1466 : {}),
1467 })
1468 .pipe(
1469 Effect.retry(busyRetry),
1470 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
1471 );
1472 if (created !== undefined) {
1473 if (created.action) {
1474 yield* waitForAction(created.action);
1475 }
1476 current = created.load_balancer;
1477 } else {
1478 const hit = yield* getByName(name);
1479 if (
1480 hit !== undefined &&
1481 matchesPlacement(hit, location, networkZone)
1482 ) {
1483 current = hit;
1484 }
1485 }
1486 }
1487
1488 if (current === undefined) {
1489 return yield* new LoadBalancerNotCreated({ name });
1490 }
1491
1492 current = yield* syncMetadata({
1493 current,
1494 name,
1495 labels: desiredLabels,
1496 });
1497 yield* syncAlgorithm(current, algorithm);
1498 yield* syncType(current, loadBalancerType);
1499 const afterType = (yield* getById(current.id)) ?? current;
1500 yield* syncServices(
1501 current.id,
1502 observedServices(afterType.services),
1503 services,
1504 );
1505 const afterServices = (yield* getById(current.id)) ?? afterType;
1506 yield* syncTargets(
1507 current.id,
1508 observedTargets(afterServices.targets),
1509 targets,
1510 );
1511 const afterTargets = (yield* getById(current.id)) ?? afterServices;
1512 yield* syncNetworks(
1513 current.id,
1514 observedPrivateNetworks(afterTargets.private_net),
1515 networks,
1516 );
1517 const afterNetworks = (yield* getById(current.id)) ?? afterTargets;
1518 yield* syncPublicInterface(afterNetworks, publicInterface);
1519 const afterPublic = (yield* getById(current.id)) ?? afterNetworks;
1520 yield* syncProtection(afterPublic, deleteProtection);
1521
1522 return toAttrs(yield* refresh(current.id));
1523 }),
1524
1525 delete: Effect.fn(function* ({ output }) {
1526 const current = yield* getById(output.id);
1527 if (current === undefined) return;
1528
1529 if (current.protection.delete) {
1530 yield* runAction(
1531 Hetzner.loadBalancerActions.changeLoadBalancerProtection({
1532 id: current.id,
1533 delete: false,
1534 }),
1535 ).pipe(Effect.catchIf(alreadyGone, () => Effect.void));
1536 }
1537
1538 yield* Hetzner.loadBalancers.deleteLoadBalancer({ id: current.id }).pipe(
1539 Effect.catchTag("NotFound", () => Effect.void),
1540 Effect.retry({
1541 while: retryable,
1542 times: 8,
1543 schedule: backoff,
1544 }),
1545 );
1546 }),
1547 });