1import * as loadBalancers from "@distilled.cloud/hetzner/load_balancers";
2import * as Hetzner from "@/Hetzner";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: Hetzner.providers() });
11
12const logLevel = Effect.provideService(
13 MinimumLogLevel,
14 process.env.DEBUG ? "Debug" : "Info",
15);
16
17const hasHetznerCreds = !!process.env.HCLOUD_TOKEN;
18
19const waitUntilGone = (id: number) =>
20 loadBalancers.getLoadBalancer({ id }).pipe(
21 Effect.as("found" as const),
22 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
23 Effect.repeat({
24 schedule: Schedule.spaced("1 second"),
25 until: (status) => status === "gone",
26 times: 10,
27 }),
28 );
29
30const tcpService = (listenPort: number, destinationPort: number) => ({
31 protocol: "tcp" as const,
32 listenPort,
33 destinationPort,
34 healthCheck: {
35 protocol: "tcp" as const,
36 port: destinationPort,
37 interval: 15,
38 timeout: 10,
39 retries: 3,
40 },
41});
42
43test.provider.skipIf(!hasHetznerCreds)(
44 "create, update, and delete a load balancer",
45 (stack) =>
46 Effect.gen(function* () {
47 yield* stack.destroy();
48
49 const created = yield* stack.deploy(
50 Effect.gen(function* () {
51 const server = yield* Hetzner.Server("Target", {
52 serverType: "cx23",
53 image: "ubuntu-24.04",
54 location: "nbg1",
55 startAfterCreate: false,
56 });
57 const network = yield* Hetzner.Network("LbNet", {
58 ipRange: "10.30.0.0/16",
59 subnets: [
60 {
61 type: "cloud",
62 ipRange: "10.30.1.0/24",
63 networkZone: "eu-central",
64 },
65 ],
66 });
67 const lb = yield* Hetzner.LoadBalancer("Edge", {
68 location: "nbg1",
69 loadBalancerType: "lb11",
70 algorithm: "round_robin",
71 services: [tcpService(80, 80)],
72 targets: [{ type: "server", server }],
73 networks: [network],
74 labels: { env: "test" },
75 });
76 return {
77 lb,
78 serverId: server.serverId,
79 networkId: network.networkId,
80 };
81 }),
82 );
83
84 expect(created.lb.id).toEqual(expect.any(Number));
85 expect(created.lb.name).toEqual(expect.any(String));
86 expect(created.lb.loadBalancerType).toEqual("lb11");
87 expect(created.lb.location).toEqual("nbg1");
88 expect(created.lb.networkZone).toEqual("eu-central");
89 expect(created.lb.algorithm).toEqual("round_robin");
90 expect(created.lb.publicInterface).toEqual(true);
91 expect(created.lb.deleteProtection).toEqual(false);
92 expect(created.lb.ipv4).toEqual(expect.any(String));
93 expect(created.lb.labels).toMatchObject({ env: "test" });
94 expect(created.lb.services).toEqual(
95 expect.arrayContaining([
96 expect.objectContaining({
97 protocol: "tcp",
98 listenPort: 80,
99 destinationPort: 80,
100 }),
101 ]),
102 );
103 expect(created.lb.targets).toEqual(
104 expect.arrayContaining([
105 expect.objectContaining({
106 type: "server",
107 serverId: created.serverId,
108 }),
109 ]),
110 );
111 expect(created.lb.privateNetworks).toEqual(
112 expect.arrayContaining([
113 expect.objectContaining({ networkId: created.networkId }),
114 ]),
115 );
116
117 const fetched = yield* loadBalancers.getLoadBalancer({
118 id: created.lb.id,
119 });
120 expect(fetched.load_balancer.id).toEqual(created.lb.id);
121 expect(fetched.load_balancer.algorithm.type).toEqual("round_robin");
122 expect(fetched.load_balancer.load_balancer_type.name).toEqual("lb11");
123 expect(fetched.load_balancer.location.name).toEqual("nbg1");
124 expect(fetched.load_balancer.labels.env).toEqual("test");
125 expect(fetched.load_balancer.public_net.enabled).toEqual(true);
126 expect(fetched.load_balancer.private_net).toEqual(
127 expect.arrayContaining([
128 expect.objectContaining({ network: created.networkId }),
129 ]),
130 );
131 expect(fetched.load_balancer.targets).toEqual(
132 expect.arrayContaining([
133 expect.objectContaining({
134 type: "server",
135 server: expect.objectContaining({ id: created.serverId }),
136 }),
137 ]),
138 );
139
140 const updated = yield* stack.deploy(
141 Effect.gen(function* () {
142 const server = yield* Hetzner.Server("Target", {
143 serverType: "cx23",
144 image: "ubuntu-24.04",
145 location: "nbg1",
146 startAfterCreate: false,
147 });
148 const network = yield* Hetzner.Network("LbNet", {
149 ipRange: "10.30.0.0/16",
150 subnets: [
151 {
152 type: "cloud",
153 ipRange: "10.30.1.0/24",
154 networkZone: "eu-central",
155 },
156 ],
157 });
158 return yield* Hetzner.LoadBalancer("Edge", {
159 location: "nbg1",
160 loadBalancerType: "lb11",
161 algorithm: "least_connections",
162 services: [tcpService(80, 8080)],
163 targets: [{ type: "server", server }],
164 networks: [network],
165 deleteProtection: true,
166 labels: { env: "prod", role: "lb" },
167 });
168 }),
169 );
170
171 expect(updated.id).toEqual(created.lb.id);
172 expect(updated.algorithm).toEqual("least_connections");
173 expect(updated.deleteProtection).toEqual(true);
174 expect(updated.labels).toMatchObject({ env: "prod", role: "lb" });
175 expect(updated.services).toEqual(
176 expect.arrayContaining([
177 expect.objectContaining({
178 protocol: "tcp",
179 listenPort: 80,
180 destinationPort: 8080,
181 }),
182 ]),
183 );
184 expect(updated.targets).toEqual(
185 expect.arrayContaining([
186 expect.objectContaining({
187 type: "server",
188 serverId: created.serverId,
189 }),
190 ]),
191 );
192
193 const refetched = yield* loadBalancers.getLoadBalancer({
194 id: updated.id,
195 });
196 expect(refetched.load_balancer.algorithm.type).toEqual(
197 "least_connections",
198 );
199 expect(refetched.load_balancer.protection.delete).toEqual(true);
200 expect(refetched.load_balancer.labels.env).toEqual("prod");
201 expect(refetched.load_balancer.labels.role).toEqual("lb");
202 expect(refetched.load_balancer.services).toEqual(
203 expect.arrayContaining([
204 expect.objectContaining({
205 protocol: "tcp",
206 listen_port: 80,
207 destination_port: 8080,
208 }),
209 ]),
210 );
211
212 const provider = yield* Provider.findProvider(Hetzner.LoadBalancer);
213 const all = yield* provider.list();
214 const listed = all.find((item) => item.id === updated.id);
215 expect(listed).toBeDefined();
216 expect(listed?.algorithm).toEqual("least_connections");
217 expect(listed?.loadBalancerType).toEqual("lb11");
218
219 yield* stack.destroy();
220
221 const gone = yield* waitUntilGone(created.lb.id);
222 expect(gone).toEqual("gone");
223 }).pipe(logLevel),
224 {
225 tags: [
226 "provider:hetzner",
227 "provider:hetzner:loadbalancer",
228 "provider:hetzner:network",
229 "provider:hetzner:server",
230 "provider:hetzner:service",
231 "live",
232 ],
233 timeout: 180_000,
234 },
235);
236
237test.provider.skipIf(!hasHetznerCreds)(
238 "replace when location changes",
239 (stack) =>
240 Effect.gen(function* () {
241 yield* stack.destroy();
242
243 const created = yield* stack.deploy(
244 Effect.gen(function* () {
245 return yield* Hetzner.LoadBalancer("ReplaceEdge", {
246 location: "nbg1",
247 loadBalancerType: "lb11",
248 services: [tcpService(80, 80)],
249 });
250 }),
251 );
252
253 expect(created.location).toEqual("nbg1");
254
255 const replaced = yield* stack.deploy(
256 Effect.gen(function* () {
257 return yield* Hetzner.LoadBalancer("ReplaceEdge", {
258 location: "fsn1",
259 loadBalancerType: "lb11",
260 services: [tcpService(80, 80)],
261 });
262 }),
263 );
264
265 expect(replaced.id).not.toEqual(created.id);
266 expect(replaced.location).toEqual("fsn1");
267
268 const fetched = yield* loadBalancers.getLoadBalancer({
269 id: replaced.id,
270 });
271 expect(fetched.load_balancer.location.name).toEqual("fsn1");
272
273 const oldGone = yield* waitUntilGone(created.id);
274 expect(oldGone).toEqual("gone");
275
276 yield* stack.destroy();
277
278 const gone = yield* waitUntilGone(replaced.id);
279 expect(gone).toEqual("gone");
280 }).pipe(logLevel),
281 {
282 tags: [
283 "provider:hetzner",
284 "provider:hetzner:loadbalancer",
285 "provider:hetzner:service",
286 "live",
287 ],
288 timeout: 180_000,
289 },
290);