1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as Binding from "../Binding.ts";
4import type { Resource } from "../Resource.ts";
5import type { Volume } from "./Volume.ts";
6
7export interface MountVolumeOptions {
8 /**
9 * Absolute path on the Server the Volume is mounted at
10 * (e.g. `/data`).
11 */
12 path: string;
13}
14
15/**
16 * Runtime view of a Volume mounted into a {@link Service} (or bound onto
17 * a Server): the path and the linux device.
18 */
19export interface MountedVolume {
20 /** Mount path on the Server (same value as {@link MountVolumeOptions.path}). */
21 path: string;
22 /** Device path (e.g. `/dev/disk/by-id/scsi-0HC_Volume_…`). */
23 device: string;
24}
25
26const linuxDeviceOf = (volume: Volume): string => {
27 const value = (volume as { linuxDevice?: unknown }).linuxDevice;
28 return typeof value === "string" ? value : "";
29};
30
31const isBindHost = (
32 value: unknown,
33): value is Resource<string, any, any, ServiceBinding> =>
34 typeof value === "object" &&
35 value !== null &&
36 ((value as { Type?: string }).Type === "Hetzner.Service" ||
37 (value as { Type?: string }).Type === "Hetzner.Server");
38
39/**
40 * Binding contract accepted by {@link Service} (and optionally Server)
41 * for mounted volumes and injected env.
42 */
43export interface ServiceBinding {
44 env?: Record<string, any>;
45 volumes?: Array<{
46 volumeId: number;
47 path: string;
48 }>;
49}
50
51/**
52 * Mount a Hetzner Volume into a {@link Service}.
53 *
54 * `yield* Hetzner.MountVolume(volume, { path: "/data" })` inside a
55 * Service impl registers `{ env, volumes: [{ volumeId, path }] }` on the
56 * host. Service reconcile attaches the Volume (no automount) and SSHs
57 * `mkdir` + `mount` + fstab at `path`. The same `(volume, server, path)`
58 * from two Services is one attach and one mount.
59 *
60 * ### Mounting volumes
61 * **Example:** Share a Volume between two Services
62 * ```typescript
63 * const volume = yield* Hetzner.Volume("data", {
64 * size: 10,
65 * format: "ext4",
66 * location: "nbg1",
67 * });
68 * const mount = yield* Hetzner.MountVolume(volume, { path: "/data" });
69 * // mount.path === "/data"
70 * // mount.device === volume.linuxDevice
71 * ```
72 *
73 * @binding
74 * @product Volume
75 */
76export interface MountVolume extends Binding.Service<
77 MountVolume,
78 "Hetzner.MountVolume",
79 (volume: Volume, options: MountVolumeOptions) => Effect.Effect<MountedVolume>
80> {}
81
82export const MountVolume = Binding.Service<MountVolume>("Hetzner.MountVolume");
83
84export const MountVolumeLive = Layer.effect(
85 MountVolume,
86 Effect.succeed(
87 Effect.fn(function* (volume: Volume, options: MountVolumeOptions) {
88 const device = linuxDeviceOf(volume);
89 if (!globalThis.__ALCHEMY_RUNTIME__) {
90 const host = yield* Binding.Host;
91 if (isBindHost(host)) {
92 yield* host.bind`Allow(${host}, Hetzner.MountVolume(${volume}))`({
93 volumes: [{ volumeId: volume.id, path: options.path }],
94 });
95 }
96 }
97 return { path: options.path, device };
98 }),
99 ),
100);