1import * as Hetzner from "@distilled.cloud/hetzner";
2import * as Data from "effect/Data";
3import * as Duration from "effect/Duration";
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 { waitForAction } from "./actions.ts";
14import {
15 alchemyStackSelector,
16 createInternalLabels,
17 diffLabels,
18 hasAlchemyLabels,
19 labelSelector,
20 stripInternalLabels,
21 toLabels,
22} from "./Labels.ts";
23import type { Providers } from "./Providers.ts";
24
25export class NetworkNotCreated extends Data.TaggedError(
26 "Hetzner.NetworkNotCreated",
27)<{
28 name: string;
29}> {}
30
31export type NetworkSubnetType = "cloud" | "server" | "vswitch";
32
33export interface NetworkSubnet {
34 /**
35 * Subnet type. `cloud` is the usual choice for Cloud Servers and Load
36 * Balancers. `vswitch` attaches a Robot vSwitch and requires `vswitchId`.
37 */
38 type: NetworkSubnetType;
39 /**
40 * IPv4 range of the subnet in CIDR notation. Must be a subnet of the
41 * parent network `ipRange` and must not overlap other subnets or route
42 * destinations. Minimum size is `/30`. If omitted on create, Hetzner
43 * allocates the first free `/24`.
44 */
45 ipRange?: string;
46 /**
47 * Network zone this subnet lives in (`eu-central`, `us-east`, `us-west`,
48 * `ap-southeast`). `nbg1` / `fsn1` / `hel1` are in `eu-central`.
49 */
50 networkZone: string;
51 /**
52 * Robot vSwitch id. Required when `type` is `vswitch`.
53 */
54 vswitchId?: number;
55}
56
57export interface NetworkRoute {
58 /**
59 * Destination prefix. Must be an RFC1918 IPv4 range or `0.0.0.0/0`, and
60 * must not overlap any subnet or other route destination.
61 */
62 destination: string;
63 /**
64 * Next-hop IP inside the network. Cannot be the first IP of the
65 * network's `ipRange` or `172.31.1.1`.
66 */
67 gateway: string;
68}
69
70export interface NetworkProps {
71 /**
72 * Name of the Network. Must be unique per project. If omitted, a unique
73 * name is generated from the app, stage, and logical ID.
74 */
75 name?: string;
76 /**
77 * IPv4 range of the whole Network in CIDR notation. Must be RFC1918 and
78 * at least `/24`. Can only be **extended** later (`change_ip_range`);
79 * shrinking or moving to a non-superset range replaces the Network.
80 */
81 ipRange: string;
82 /**
83 * Subnets allocated in this Network. Synced via `add_subnet` /
84 * `delete_subnet` — they are not their own resources.
85 */
86 subnets?: NetworkSubnet[];
87 /**
88 * Static routes in this Network. Synced via `add_route` / `delete_route`.
89 */
90 routes?: NetworkRoute[];
91 /**
92 * Expose this Network's routes to a connected Robot vSwitch.
93 * @default false
94 */
95 exposeRoutesToVswitch?: boolean;
96 /**
97 * Prevent the Network from being deleted in the Cloud Console / API
98 * until this is cleared. The provider disables protection before delete.
99 * @default false
100 */
101 deleteProtection?: boolean;
102 /**
103 * User-defined labels. Alchemy ownership labels (`alchemy.stack` /
104 * `alchemy.stage` / `alchemy.id`) are merged in automatically.
105 */
106 labels?: Record<string, string>;
107}
108
109export interface NetworkSubnetAttr extends NetworkSubnet {
110 /** Gateway Hetzner assigned to this subnet. */
111 gateway: string;
112}
113
114export type Network = Resource<
115 "Hetzner.Network",
116 NetworkProps,
117 {
118 /** Numeric Hetzner Network id. */
119 networkId: number;
120 /** Observed Network name. */
121 name: string;
122 /** Observed IPv4 range (CIDR). */
123 ipRange: string;
124 /** Observed subnets, including the assigned gateway. */
125 subnets: NetworkSubnetAttr[];
126 /** Observed static routes. */
127 routes: NetworkRoute[];
128 /** Server ids currently attached to this Network. */
129 servers: number[];
130 /** Load Balancer ids currently attached to this Network. */
131 loadBalancers: number[];
132 /** Whether delete protection is enabled. */
133 deleteProtection: boolean;
134 /** Whether routes are exposed to a connected vSwitch. */
135 exposeRoutesToVswitch: boolean;
136 /** User-facing labels (Alchemy ownership labels stripped). */
137 labels: Record<string, string>;
138 /** RFC3339 creation timestamp. */
139 created: string;
140 },
141 never,
142 Providers
143>;
144
145/**
146 * A Hetzner Cloud private Network — an isolated IPv4 range that Cloud
147 * Servers and Load Balancers attach to. Subnets, routes, delete protection,
148 * and labels are synced on the Network itself; `network_actions` are not
149 * modeled as their own resources.
150 *
151 * @see https://docs.hetzner.cloud/reference/cloud#networks
152 *
153 * ### Creating a Network
154 * **Example:** Basic Network
155 * ```typescript
156 * const network = yield* Hetzner.Network("vpc", {
157 * ipRange: "10.0.0.0/16",
158 * });
159 * ```
160 *
161 * **Example:** Network with a cloud subnet
162 * ```typescript
163 * const network = yield* Hetzner.Network("vpc", {
164 * ipRange: "10.0.0.0/16",
165 * subnets: [
166 * { type: "cloud", ipRange: "10.0.1.0/24", networkZone: "eu-central" },
167 * ],
168 * });
169 * ```
170 *
171 * ### Routes and protection
172 * **Example:** Static route and delete protection
173 * ```typescript
174 * const network = yield* Hetzner.Network("vpc", {
175 * ipRange: "10.0.0.0/16",
176 * subnets: [
177 * { type: "cloud", ipRange: "10.0.1.0/24", networkZone: "eu-central" },
178 * ],
179 * routes: [{ destination: "10.10.0.0/24", gateway: "10.0.1.2" }],
180 * deleteProtection: true,
181 * });
182 * ```
183 *
184 * ### Labels
185 * **Example:** User labels
186 * ```typescript
187 * const network = yield* Hetzner.Network("vpc", {
188 * ipRange: "10.0.0.0/16",
189 * labels: { env: "prod", role: "vpc" },
190 * });
191 * ```
192 *
193 * @resource
194 * @product Network
195 */
196export const Network = Resource<Network>("Hetzner.Network");
197
198type ObservedNetwork = {
199 id: number;
200 name: string;
201 ip_range: string;
202 subnets: ReadonlyArray<{
203 type: string;
204 ip_range?: string;
205 network_zone: string;
206 gateway: string;
207 vswitch_id?: number | null;
208 }>;
209 routes: ReadonlyArray<{ destination: string; gateway: string }>;
210 servers: ReadonlyArray<number>;
211 load_balancers?: ReadonlyArray<number>;
212 protection: { delete: boolean };
213 labels: Record<string, string | undefined>;
214 created: string;
215 expose_routes_to_vswitch: boolean;
216};
217
218const busy = (e: { readonly _tag: string }): boolean =>
219 e._tag === "Locked" || e._tag === "Conflict";
220
221const busyRetry = {
222 while: busy,
223 times: 8,
224 schedule: Schedule.min([
225 Schedule.exponential(Duration.millis(500), 1.5),
226 Schedule.spaced(Duration.seconds(5)),
227 ]),
228} as const;
229
230const alreadyThere = (e: { readonly _tag: string }): boolean =>
231 e._tag === "Conflict" || e._tag === "UnprocessableEntity";
232
233const alreadyGone = (e: { readonly _tag: string }): boolean =>
234 e._tag === "NotFound" || e._tag === "UnprocessableEntity";
235
236const createNetworkName = (id: string, name: string | undefined) =>
237 Effect.gen(function* () {
238 return (
239 name ??
240 (yield* createPhysicalName({
241 id,
242 maxLength: 63,
243 lowercase: true,
244 }))
245 );
246 });
247
248const desiredLabelsOf = Effect.fn(function* (
249 id: string,
250 labels: Record<string, string> | undefined,
251) {
252 return {
253 ...toLabels(labels),
254 ...(yield* createInternalLabels(id)),
255 };
256});
257
258const subnetKey = (subnet: {
259 type: string;
260 ipRange?: string;
261 networkZone: string;
262 vswitchId?: number | null;
263}): string =>
264 `${subnet.type}|${subnet.ipRange ?? ""}|${subnet.networkZone}|${subnet.vswitchId ?? ""}`;
265
266const routeKey = (route: { destination: string; gateway: string }): string =>
267 `${route.destination}|${route.gateway}`;
268
269const toSubnetAttr = (
270 subnet: ObservedNetwork["subnets"][number],
271): NetworkSubnetAttr => ({
272 type: subnet.type as NetworkSubnetType,
273 ipRange: subnet.ip_range,
274 networkZone: subnet.network_zone,
275 gateway: subnet.gateway,
276 vswitchId: subnet.vswitch_id ?? undefined,
277});
278
279const toAttrs = (network: ObservedNetwork): Network["Attributes"] => ({
280 networkId: network.id,
281 name: network.name,
282 ipRange: network.ip_range,
283 subnets: network.subnets.map(toSubnetAttr),
284 routes: network.routes.map((route) => ({
285 destination: route.destination,
286 gateway: route.gateway,
287 })),
288 servers: [...network.servers],
289 loadBalancers: [...(network.load_balancers ?? [])],
290 deleteProtection: network.protection.delete,
291 exposeRoutesToVswitch: network.expose_routes_to_vswitch,
292 labels: stripInternalLabels(tagRecord(network.labels)),
293 created: network.created,
294});
295
296const parseCidr = (
297 cidr: string,
298): { ip: number; prefix: number } | undefined => {
299 const [addr, prefixRaw] = cidr.split("/");
300 if (addr === undefined || prefixRaw === undefined) return undefined;
301 const parts = addr.split(".").map(Number);
302 if (
303 parts.length !== 4 ||
304 parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
305 ) {
306 return undefined;
307 }
308 const prefix = Number(prefixRaw);
309 if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return undefined;
310 const ip =
311 ((parts[0]! << 24) | (parts[1]! << 16) | (parts[2]! << 8) | parts[3]!) >>>
312 0;
313 return { ip, prefix };
314};
315
316/** True when `outer` is equal to or a supernet of `inner`. */
317const cidrContains = (outer: string, inner: string): boolean => {
318 const a = parseCidr(outer);
319 const b = parseCidr(inner);
320 if (a === undefined || b === undefined) return false;
321 if (a.prefix > b.prefix) return false;
322 const mask = a.prefix === 0 ? 0 : (0xffffffff << (32 - a.prefix)) >>> 0;
323 return (a.ip & mask) === (b.ip & mask);
324};
325
326const getById = (id: number) =>
327 Hetzner.networks.getNetwork({ id }).pipe(
328 Effect.map(({ network }) => network),
329 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
330 );
331
332const findByName = (name: string) =>
333 Hetzner.networks
334 .listNetworks({ name, per_page: 50 })
335 .pipe(
336 Effect.map(({ networks }) => networks.find((item) => item.name === name)),
337 );
338
339const findByLabels = (labels: Record<string, string>) =>
340 Hetzner.networks
341 .listNetworks({
342 label_selector: labelSelector(labels),
343 per_page: 50,
344 })
345 .pipe(Effect.map(({ networks }) => networks[0]));
346
347const observe = (networkId: number | undefined, name: string, id: string) =>
348 Effect.gen(function* () {
349 if (networkId !== undefined) {
350 const byId = yield* getById(networkId);
351 if (byId) return byId;
352 }
353 const byLabels = yield* findByLabels(yield* createInternalLabels(id));
354 if (byLabels) return byLabels;
355 return yield* findByName(name);
356 });
357
358const runAction = <E extends { readonly _tag: string }, R>(
359 effect: Effect.Effect<{ action: { id: number } }, E, R>,
360) =>
361 effect.pipe(
362 Effect.retry(busyRetry),
363 Effect.flatMap(({ action }) => waitForAction(action)),
364 );
365
366const syncIpRange = (networkId: number, observed: string, desired: string) =>
367 Effect.gen(function* () {
368 if (observed === desired) return;
369 yield* runAction(
370 Hetzner.networkActions.changeNetworkIpRange({
371 id: networkId,
372 ip_range: desired,
373 }),
374 );
375 });
376
377const syncMetadata = (args: {
378 networkId: number;
379 observed: ObservedNetwork;
380 name: string;
381 exposeRoutesToVswitch: boolean;
382 labels: Record<string, string>;
383}) =>
384 Effect.gen(function* () {
385 const observedLabels = tagRecord(args.observed.labels);
386 const { removed, upsert } = diffLabels(observedLabels, args.labels);
387 const labelsChanged = removed.length > 0 || upsert.length > 0;
388 const nameChanged = args.observed.name !== args.name;
389 const exposeChanged =
390 args.observed.expose_routes_to_vswitch !== args.exposeRoutesToVswitch;
391 if (!labelsChanged && !nameChanged && !exposeChanged) return;
392 yield* Hetzner.networks
393 .updateNetwork({
394 id: args.networkId,
395 name: nameChanged ? args.name : undefined,
396 expose_routes_to_vswitch: exposeChanged
397 ? args.exposeRoutesToVswitch
398 : undefined,
399 labels: labelsChanged ? args.labels : undefined,
400 })
401 .pipe(Effect.retry(busyRetry));
402 });
403
404const syncSubnets = (
405 networkId: number,
406 observed: ObservedNetwork["subnets"],
407 desired: readonly NetworkSubnet[],
408) =>
409 Effect.gen(function* () {
410 const observedMapped = observed.map((subnet) => ({
411 type: subnet.type,
412 ipRange: subnet.ip_range,
413 networkZone: subnet.network_zone,
414 vswitchId: subnet.vswitch_id ?? undefined,
415 }));
416 const observedKeys = new Set(observedMapped.map(subnetKey));
417 const desiredKeys = new Set(desired.map(subnetKey));
418
419 for (const subnet of observedMapped) {
420 if (desiredKeys.has(subnetKey(subnet))) continue;
421 if (subnet.ipRange === undefined) continue;
422 yield* runAction(
423 Hetzner.networkActions.deleteNetworkSubnet({
424 id: networkId,
425 ip_range: subnet.ipRange,
426 }),
427 ).pipe(Effect.catchIf(alreadyGone, () => Effect.void));
428 }
429
430 for (const subnet of desired) {
431 if (observedKeys.has(subnetKey(subnet))) continue;
432 yield* runAction(
433 Hetzner.networkActions.addNetworkSubnet({
434 id: networkId,
435 type: subnet.type,
436 ip_range: subnet.ipRange,
437 network_zone: subnet.networkZone,
438 vswitch_id: subnet.vswitchId,
439 }),
440 ).pipe(Effect.catchIf(alreadyThere, () => Effect.void));
441 }
442 });
443
444const syncRoutes = (
445 networkId: number,
446 observed: ObservedNetwork["routes"],
447 desired: readonly NetworkRoute[],
448) =>
449 Effect.gen(function* () {
450 const observedKeys = new Set(observed.map(routeKey));
451 const desiredKeys = new Set(desired.map(routeKey));
452
453 for (const route of observed) {
454 if (desiredKeys.has(routeKey(route))) continue;
455 yield* runAction(
456 Hetzner.networkActions.deleteNetworkRoute({
457 id: networkId,
458 destination: route.destination,
459 gateway: route.gateway,
460 }),
461 ).pipe(Effect.catchIf(alreadyGone, () => Effect.void));
462 }
463
464 for (const route of desired) {
465 if (observedKeys.has(routeKey(route))) continue;
466 yield* runAction(
467 Hetzner.networkActions.addNetworkRoute({
468 id: networkId,
469 destination: route.destination,
470 gateway: route.gateway,
471 }),
472 ).pipe(Effect.catchIf(alreadyThere, () => Effect.void));
473 }
474 });
475
476const syncProtection = (
477 networkId: number,
478 observed: boolean,
479 desired: boolean,
480) =>
481 Effect.gen(function* () {
482 if (observed === desired) return;
483 yield* runAction(
484 Hetzner.networkActions.changeNetworkProtection({
485 id: networkId,
486 delete: desired,
487 }),
488 );
489 });
490
491export const NetworkProvider = () =>
492 Provider.succeed(Network, {
493 stables: ["networkId", "created"],
494
495 list: Effect.fn(function* () {
496 return yield* Hetzner.networks.listNetworks
497 .items({ label_selector: alchemyStackSelector, per_page: 50 })
498 .pipe(
499 Stream.runCollect,
500 Effect.map((chunk) => Array.from(chunk, toAttrs)),
501 );
502 }),
503
504 diff: Effect.fn(function* ({ news, output }) {
505 if (!isResolved(news)) return undefined;
506 if (
507 output !== undefined &&
508 news.ipRange !== output.ipRange &&
509 !cidrContains(news.ipRange, output.ipRange)
510 ) {
511 return { action: "replace" } as const;
512 }
513 return undefined;
514 }),
515
516 read: Effect.fn(function* ({ id, olds, output }) {
517 const name = yield* createNetworkName(id, olds?.name ?? output?.name);
518 const observed = yield* observe(output?.networkId, name, id);
519 if (observed === undefined) return undefined;
520 const attrs = toAttrs(observed);
521 return (yield* hasAlchemyLabels(id, tagRecord(observed.labels)))
522 ? attrs
523 : Unowned(attrs);
524 }),
525
526 reconcile: Effect.fn(function* ({ id, news, output }) {
527 const name = yield* createNetworkName(
528 id,
529 news.name ?? (output === undefined ? undefined : output.name),
530 );
531 const desiredLabels = yield* desiredLabelsOf(id, news.labels);
532 const desiredSubnets = news.subnets ?? [];
533 const desiredRoutes = news.routes ?? [];
534 const exposeRoutesToVswitch = news.exposeRoutesToVswitch ?? false;
535 const deleteProtection = news.deleteProtection ?? false;
536
537 // Observe — cached `output.networkId` is a hint. Fall back to
538 // ownership labels, then the deterministic name.
539 let current = yield* observe(output?.networkId, name, id);
540
541 // A replacement create shares the logical id (and thus ownership
542 // labels) with the outgoing generation. If the observed row cannot
543 // accept the desired `ipRange` (Hetzner can only extend a range),
544 // do not reuse it — create a new Network and let the engine delete
545 // the old generation.
546 if (
547 current !== undefined &&
548 current.ip_range !== news.ipRange &&
549 !cidrContains(news.ipRange, current.ip_range)
550 ) {
551 current = undefined;
552 }
553
554 // Ensure — create when missing. A name-collision race is treated as
555 // the peer winning; we pick that row up and continue into sync.
556 if (current === undefined) {
557 const created = yield* Hetzner.networks
558 .createNetwork({
559 name,
560 ip_range: news.ipRange,
561 labels: desiredLabels,
562 subnets: desiredSubnets.map((subnet) => ({
563 type: subnet.type,
564 ip_range: subnet.ipRange,
565 network_zone: subnet.networkZone,
566 vswitch_id: subnet.vswitchId,
567 })),
568 routes: desiredRoutes.map((route) => ({
569 destination: route.destination,
570 gateway: route.gateway,
571 })),
572 expose_routes_to_vswitch: exposeRoutesToVswitch,
573 })
574 .pipe(
575 Effect.retry(busyRetry),
576 Effect.map((response) => response.network),
577 Effect.catchIf(alreadyThere, () => findByName(name)),
578 );
579 current = created;
580 }
581
582 if (current === undefined) {
583 return yield* new NetworkNotCreated({ name });
584 }
585
586 // Sync each mutable aspect from observed cloud state, not `olds`.
587 yield* syncIpRange(current.id, current.ip_range, news.ipRange);
588 yield* syncMetadata({
589 networkId: current.id,
590 observed: current,
591 name,
592 exposeRoutesToVswitch,
593 labels: desiredLabels,
594 });
595 const afterMeta = (yield* getById(current.id)) ?? current;
596 yield* syncSubnets(current.id, afterMeta.subnets, desiredSubnets);
597 const afterSubnets = (yield* getById(current.id)) ?? afterMeta;
598 yield* syncRoutes(current.id, afterSubnets.routes, desiredRoutes);
599 const afterRoutes = (yield* getById(current.id)) ?? afterSubnets;
600 yield* syncProtection(
601 current.id,
602 afterRoutes.protection.delete,
603 deleteProtection,
604 );
605
606 const fresh = yield* getById(current.id);
607 return toAttrs(fresh ?? afterRoutes);
608 }),
609
610 delete: Effect.fn(function* ({ output }) {
611 if (output.deleteProtection) {
612 yield* runAction(
613 Hetzner.networkActions.changeNetworkProtection({
614 id: output.networkId,
615 delete: false,
616 }),
617 ).pipe(Effect.catchIf(alreadyGone, () => Effect.void));
618 }
619 yield* Hetzner.networks.deleteNetwork({ id: output.networkId }).pipe(
620 Effect.retry(busyRetry),
621 Effect.catchTag("NotFound", () => Effect.void),
622 );
623 }),
624 });