1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as Binding from "../Binding.ts";
4import type { Resource } from "../Resource.ts";
5
6/**
7 * Spec for a per-replica Fly disk. Fly Volumes cannot be shared —
8 * `count` Machines get `count` Volumes in one name-group.
9 */
10export interface DiskSpec {
11 /**
12 * Absolute path inside the Machine the disk is mounted at
13 * (e.g. `/data`).
14 */
15 path: string;
16 /**
17 * Size in GB. Fly minimum is 1. Increasing size updates in place
18 * via `extendVolume`. Fly cannot shrink a Volume.
19 */
20 sizeGb: number;
21 /**
22 * Encrypt the Volume at rest. Create-only.
23 */
24 encrypted?: boolean;
25 /**
26 * Filesystem type (`ext4`, …). Create-only.
27 */
28 fstype?: string;
29 /**
30 * Enable scheduled automatic snapshots.
31 *
32 * @default true
33 */
34 autoBackupEnabled?: boolean;
35 /**
36 * Snapshot retention in days. Updated in place via `updateVolume`.
37 */
38 snapshotRetention?: number;
39 /**
40 * Restore from an existing snapshot. Create-only.
41 */
42 snapshotId?: string;
43 /**
44 * Fork from an existing volume. Create-only.
45 */
46 sourceVolumeId?: string;
47 /**
48 * Require the Volume to land in a unique zone. Create-only.
49 */
50 requireUniqueZone?: boolean;
51 /**
52 * Fly volume-group name. If omitted, a unique name is generated
53 * from the host's logical ID and {@link path}.
54 */
55 name?: string;
56}
57
58export interface MountVolumeOptions extends DiskSpec {}
59
60/**
61 * Runtime view of a disk mounted into a {@link Service}: the path
62 * inside the Machine.
63 */
64export interface MountedVolume {
65 /** Mount path inside the Machine (same value as {@link DiskSpec.path}). */
66 path: string;
67}
68
69/**
70 * Observed disk attached to a Machine / Service replica.
71 */
72export interface MountedDisk {
73 /** Mount path inside the Machine. */
74 path: string;
75 /** Fly Volume id. */
76 volumeId: string;
77 /** Size in GB. */
78 sizeGb: number;
79 /** Fly volume-group name. */
80 name: string;
81}
82
83const isBindHost = (
84 value: unknown,
85): value is Resource<string, any, any, ServiceBinding> =>
86 typeof value === "object" &&
87 value !== null &&
88 ((value as { Type?: string }).Type === "Fly.Service" ||
89 (value as { Type?: string }).Type === "Fly.Machine");
90
91/**
92 * Binding contract accepted by {@link Service} (and Machine) for mounted
93 * disks and injected env.
94 */
95export interface ServiceBinding {
96 env?: Record<string, any>;
97 mounts?: DiskSpec[];
98 /**
99 * Upstash Redis add-on to attach. Redis HTTP bindings write
100 * `REDIS_URL` as an App secret during Service reconcile.
101 */
102 redis?: { name: string; id?: string };
103 /**
104 * Tigris bucket to attach. Object HTTP bindings write `AWS_*` /
105 * `BUCKET_NAME` as App secrets during Service reconcile.
106 */
107 bucket?: { name: string; id?: string };
108 /**
109 * Managed Postgres cluster to attach. `Fly.ConnectPostgres` records
110 * the MPG attachment during Service reconcile. Connection URIs travel
111 * as Outputs (`yield* postgres.connectionUri`), not bind env.
112 */
113 postgres?: { clusterId: string; variableName?: string };
114 /**
115 * Another Service this one calls, recorded by `Fly.bindService` and
116 * `Fly.bindEndpoint`. Service reconcile checks the target is reachable
117 * from this Service's network and, for an endpoint, that the port is
118 * published.
119 */
120 target?: BoundTarget;
121}
122
123/** A Service bound by `Fly.bindService` or `Fly.bindEndpoint`. */
124export interface BoundTarget {
125 /** Logical id of the bound Service. */
126 service: string;
127 /** Fly App the bound Service runs in. */
128 appName: string;
129 /** Private network of that App. `undefined` for the default network. */
130 network: string | undefined;
131 /** The bound Service's private address; `undefined` when it publishes nothing. */
132 privateUrl: string | undefined;
133 /** The bound Service's caller token. */
134 rpcToken?: import("effect/Redacted").Redacted<string>;
135 /** Port `Fly.bindEndpoint` targets; the binding port for `bindService`. */
136 port?: number;
137 /** Ports the bound Service publishes, for `Fly.bindEndpoint`. */
138 endpoints?: Array<{ port: number; handlers: string[] }>;
139}
140
141/**
142 * Create a per-replica Fly Volume and mount it into a {@link Service}.
143 *
144 * There is no standalone Volume resource. From a {@link Machine}, pass
145 * `mounts: [{ path, sizeGb }]` instead.
146 *
147 *
148 * ### Mount into a Service
149 * Yield `MountVolume` inside init. App and region come from the
150 * parent Service. Provide {@link MountVolumeLive}. At runtime you get
151 * `disk.path`.
152 *
153 * A Volume attaches to one Machine. `count: 3` creates three Volumes
154 * in one name-group, one per replica.
155 *
156 * **Example:** Bind a path
157 * ```typescript
158 * export default class Api extends Fly.Service<Api>()(
159 * "Api",
160 * { app: Site, main: import.meta.url, region: "iad", count: 3, port: 3000 },
161 * Effect.gen(function* () {
162 * const disk = yield* Fly.MountVolume({ path: "/data", sizeGb: 1 });
163 * const fs = yield* FileSystem.FileSystem;
164 * return {
165 * fetch: Effect.gen(function* () {
166 * const text = yield* fs.readFileString(`${disk.path}/hello.txt`);
167 * return HttpServerResponse.text(text);
168 * }),
169 * };
170 * }).pipe(Effect.provide(Fly.MountVolumeLive)),
171 * ) {}
172 * ```
173 *
174 * ### Grow a disk
175 * `sizeGb` can grow in place via `extendVolume`. Fly cannot shrink a
176 * Volume. Minimum size is 1 GB.
177 *
178 * **Example:** 10 GB
179 * ```typescript
180 * const disk = yield* Fly.MountVolume({ path: "/data", sizeGb: 10 });
181 * ```
182 *
183 * ### Encryption
184 * `encrypted` encrypts the Volume at rest.
185 *
186 * **Example:** Encrypted
187 * ```typescript
188 * const disk = yield* Fly.MountVolume({
189 * path: "/data",
190 * sizeGb: 1,
191 * encrypted: true,
192 * });
193 * ```
194 *
195 * :::note[Create-only]
196 * Flipping `encrypted` later is ignored.
197 * :::
198 *
199 * ### Filesystem
200 * `fstype` is the filesystem (`ext4`, …).
201 *
202 * **Example:** ext4
203 * ```typescript
204 * const disk = yield* Fly.MountVolume({
205 * path: "/data",
206 * sizeGb: 1,
207 * fstype: "ext4",
208 * });
209 * ```
210 *
211 * :::note[Create-only]
212 * Flipping `fstype` later is ignored.
213 * :::
214 *
215 * ### Scheduled snapshots
216 * Scheduled snapshots default on (`autoBackupEnabled`).
217 * `snapshotRetention` is days and updates in place. An on-demand
218 * snapshot is {@link VolumeSnapshot}.
219 *
220 * **Example:** Retention
221 * ```typescript
222 * const disk = yield* Fly.MountVolume({
223 * path: "/data",
224 * sizeGb: 1,
225 * autoBackupEnabled: true,
226 * snapshotRetention: 5,
227 * });
228 * ```
229 *
230 * ### Restore from a snapshot
231 * `snapshotId` restores into a new disk.
232 *
233 * **Example:** snapshotId
234 * ```typescript
235 * const disk = yield* Fly.MountVolume({
236 * path: "/data",
237 * sizeGb: 1,
238 * snapshotId: Nightly.snapshotId,
239 * });
240 * ```
241 *
242 * :::note[Create-only]
243 * Changing `snapshotId` later is ignored.
244 * :::
245 *
246 * ### Fork a volume
247 * `sourceVolumeId` forks from an existing Volume.
248 *
249 * **Example:** sourceVolumeId
250 * ```typescript
251 * const disk = yield* Fly.MountVolume({
252 * path: "/data",
253 * sizeGb: 1,
254 * sourceVolumeId: box.mounts[0].volumeId,
255 * });
256 * ```
257 *
258 * :::note[Create-only]
259 * Changing `sourceVolumeId` later is ignored.
260 * :::
261 *
262 * ### Unique zone
263 * `requireUniqueZone` asks Fly to land the Volume in a unique zone.
264 *
265 * **Example:** Unique zone
266 * ```typescript
267 * const disk = yield* Fly.MountVolume({
268 * path: "/data",
269 * sizeGb: 1,
270 * requireUniqueZone: true,
271 * });
272 * ```
273 *
274 * :::note[Create-only]
275 * Flipping `requireUniqueZone` later is ignored.
276 * :::
277 *
278 * ### Volume name
279 * `name` is the Fly volume-group name. If omitted, a unique name is
280 * generated from the host's logical ID and `path`.
281 *
282 * **Example:** Named group
283 * ```typescript
284 * const disk = yield* Fly.MountVolume({
285 * path: "/data",
286 * sizeGb: 1,
287 * name: "api_data",
288 * });
289 * ```
290 *
291 * @binding
292 * @product Volume
293 */
294export interface MountVolume extends Binding.Service<
295 MountVolume,
296 "Fly.MountVolume",
297 (options: MountVolumeOptions) => Effect.Effect<MountedVolume>
298> {}
299
300export const MountVolume = Binding.Service<MountVolume>("Fly.MountVolume");
301
302export const MountVolumeLive = Layer.effect(
303 MountVolume,
304 Effect.succeed(
305 Effect.fn(function* (options: MountVolumeOptions) {
306 if (!globalThis.__ALCHEMY_RUNTIME__) {
307 const host = yield* Binding.Host;
308 if (isBindHost(host)) {
309 yield* host.bind`Fly.MountVolume(${options.path})`({
310 mounts: [options],
311 });
312 }
313 }
314 return { path: options.path };
315 }),
316 ),
317);