1import type { VolumeSnapshot as FlyVolumeSnapshot } from "@distilled.cloud/fly-io/machines";
2import * as machines from "@distilled.cloud/fly-io/machines";
3import * as Data from "effect/Data";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import { isResolved } from "../Diff.ts";
8import * as Provider from "../Provider.ts";
9import { Resource } from "../Resource.ts";
10import type { App } from "./App.ts";
11import type { Providers } from "./Providers.ts";
12import { listOwnedVolumes } from "./Volume.ts";
13
14/**
15 * A resource-valued prop: the resource itself, or an Effect that produces
16 * it (so `yield* App(...)` and `App(...)` both type-check).
17 */
18type Ref<T> = T | Effect.Effect<T, never, Providers>;
19
20export interface VolumeSnapshotProps {
21 /**
22 * Parent Fly App. Changing it replaces the snapshot (a new snapshot is
23 * created on the new App's Volume). There is no delete API for the old
24 * snapshot — it follows Volume `snapshot_retention`.
25 */
26 app: Ref<App>;
27 /**
28 * Fly Volume id to snapshot (`vol_…`). Changing it replaces the
29 * snapshot. Identity is the snapshot `id` returned by a subsequent
30 * `listVolumeSnapshots`.
31 */
32 volumeId: string;
33}
34
35export type VolumeSnapshot = Resource<
36 "Fly.VolumeSnapshot",
37 VolumeSnapshotProps,
38 {
39 /** Parent Fly App name. */
40 appName: string;
41 /** Fly Volume id this snapshot belongs to. */
42 volumeId: string;
43 /** Fly snapshot id (`vs_…`). Identity of the resource. */
44 snapshotId: string;
45 /** Observed snapshot status, if the API returned one. */
46 status: string | undefined;
47 /** Content digest of the snapshot. */
48 digest: string | undefined;
49 /** Snapshot size in bytes, if the API returned one. */
50 size: number | undefined;
51 /** Source volume size in GB at snapshot time. */
52 volumeSize: number | undefined;
53 /** Retention in days. */
54 retentionDays: number | undefined;
55 /** RFC3339 creation timestamp. */
56 createdAt: string | undefined;
57 },
58 never,
59 Providers
60>;
61
62/**
63 * A Fly.VolumeSnapshot is an on-demand snapshot of a mounted disk.
64 *
65 * Create is fire-and-forget. Identity is the snapshot id from a
66 * subsequent list. Destroy is a no-op. Snapshots follow Volume
67 * retention. `nuke` skips this type.
68 *
69 * @see https://fly.io/docs/machines/api/volumes-resource/
70 *
71 * ### Create a snapshot
72 * Point it at a Volume id from the parent {@link Machine} or
73 * {@link Service} (`mounts[0].volumeId`).
74 *
75 * **Example:** Snapshot a mounted disk
76 * ```typescript
77 * const box = yield* Fly.Machine("Box", {
78 * app: Site,
79 * region: "iad",
80 * image: "nginx:alpine",
81 * mounts: [{ path: "/data", sizeGb: 1 }],
82 * });
83 *
84 * export const Nightly = Fly.VolumeSnapshot("Nightly", {
85 * app: Site,
86 * volumeId: box.mounts[0].volumeId,
87 * });
88 * ```
89 *
90 * :::caution[Changing `app` or `volumeId` replaces the snapshot]
91 * A new snapshot is created. There is no delete API for the old one.
92 * It follows Volume `snapshot_retention`.
93 * :::
94 *
95 * ### Restore
96 * Restore into a new disk with `snapshotId` on the mount. Create-only.
97 * The new Machine gets a copy. The original Volume is unchanged.
98 *
99 * **Example:** Restore onto a Machine
100 * ```typescript
101 * const restored = yield* Fly.Machine("Restored", {
102 * app: Site,
103 * region: "iad",
104 * image: "nginx:alpine",
105 * mounts: [{ path: "/data", sizeGb: 1, snapshotId: Nightly.snapshotId }],
106 * });
107 * ```
108 *
109 * ### Restore into a Service
110 * Pass `snapshotId` on {@link MountVolume}. Same create-only rule.
111 *
112 * **Example:** Restore onto a Service
113 * ```typescript
114 * export default class Api extends Fly.Service<Api>()(
115 * "Api",
116 * { app: Site, main: import.meta.url, region: "iad", port: 3000 },
117 * Effect.gen(function* () {
118 * const disk = yield* Fly.MountVolume({
119 * path: "/data",
120 * sizeGb: 1,
121 * snapshotId: Nightly.snapshotId,
122 * });
123 * return {
124 * fetch: Effect.succeed(HttpServerResponse.text(disk.path)),
125 * };
126 * }).pipe(Effect.provide(Fly.MountVolumeLive)),
127 * ) {}
128 * ```
129 *
130 * @resource
131 * @product Volume
132 */
133export const VolumeSnapshot = Resource<VolumeSnapshot>("Fly.VolumeSnapshot");
134
135export class VolumeSnapshotNotCreated extends Data.TaggedError(
136 "Fly.VolumeSnapshotNotCreated",
137)<{
138 appName: string;
139 volumeId: string;
140}> {}
141
142export class VolumeSnapshotRefsMissing extends Data.TaggedError(
143 "Fly.VolumeSnapshotRefsMissing",
144)<{
145 message: string;
146}> {}
147
148class VolumeSnapshotPending extends Data.TaggedError(
149 "Fly.VolumeSnapshotPending",
150)<{
151 volumeId: string;
152}> {}
153
154const backoff = Schedule.min([
155 Schedule.exponential(Duration.millis(500), 1.5),
156 Schedule.spaced(Duration.seconds(5)),
157]);
158
159const appNameOf = (value: unknown): string | undefined => {
160 if (value == null || typeof value !== "object") return undefined;
161 const name = (value as { appName?: unknown }).appName;
162 return typeof name === "string" && name.length > 0 ? name : undefined;
163};
164
165const volumeIdOf = (value: unknown): string | undefined => {
166 if (typeof value === "string" && value.length > 0) return value;
167 if (value == null || typeof value !== "object") return undefined;
168 const id = (value as { volumeId?: unknown }).volumeId;
169 return typeof id === "string" && id.length > 0 ? id : undefined;
170};
171
172const toAttrs = (
173 appName: string,
174 volumeId: string,
175 snapshot: FlyVolumeSnapshot,
176): VolumeSnapshot["Attributes"] => ({
177 appName,
178 volumeId,
179 snapshotId: snapshot.id ?? "",
180 status: snapshot.status,
181 digest: snapshot.digest,
182 size: snapshot.size,
183 volumeSize: snapshot.volume_size,
184 retentionDays: snapshot.retention_days,
185 createdAt: snapshot.created_at,
186});
187
188const listSnapshots = (appName: string, volumeId: string) =>
189 machines
190 .listVolumeSnapshots({
191 app_name: appName,
192 volume_id: volumeId,
193 })
194 .pipe(
195 Effect.catchTag(["NotFound", "Forbidden"], () =>
196 Effect.succeed([] as FlyVolumeSnapshot[]),
197 ),
198 );
199
200const findById = (appName: string, volumeId: string, snapshotId: string) =>
201 listSnapshots(appName, volumeId).pipe(
202 Effect.map((snapshots) =>
203 snapshots.find((snapshot) => snapshot.id === snapshotId),
204 ),
205 );
206
207const newestFirst = (left: FlyVolumeSnapshot, right: FlyVolumeSnapshot) =>
208 Date.parse(right.created_at ?? "") - Date.parse(left.created_at ?? "");
209
210const pickNewest = (
211 snapshots: FlyVolumeSnapshot[],
212): FlyVolumeSnapshot | undefined =>
213 snapshots
214 .filter((snapshot) => (snapshot.id ?? "").length > 0)
215 .sort(newestFirst)[0];
216
217const waitForNewSnapshot = (
218 appName: string,
219 volumeId: string,
220 knownIds: ReadonlySet<string>,
221) =>
222 listSnapshots(appName, volumeId).pipe(
223 Effect.flatMap((snapshots) => {
224 const next = pickNewest(
225 snapshots.filter(
226 (snapshot) => snapshot.id !== undefined && !knownIds.has(snapshot.id),
227 ),
228 );
229 if (next !== undefined) return Effect.succeed(next);
230 return Effect.fail(new VolumeSnapshotPending({ volumeId }));
231 }),
232 Effect.retry({
233 while: (e) => e._tag === "Fly.VolumeSnapshotPending",
234 times: 8,
235 schedule: backoff,
236 }),
237 Effect.catchTag("Fly.VolumeSnapshotPending", () =>
238 Effect.succeed(undefined),
239 ),
240 );
241
242export const VolumeSnapshotProvider = () =>
243 Provider.succeed(VolumeSnapshot, {
244 stables: ["snapshotId", "volumeId", "appName"],
245 nuke: { skip: true },
246
247 diff: Effect.fn(function* ({ news, output }) {
248 if (news === undefined || !isResolved(news)) return undefined;
249 if (output === undefined) return undefined;
250 const desiredApp = appNameOf(news.app);
251 const appChanged =
252 desiredApp !== undefined && desiredApp !== output.appName;
253 const desiredVolume = volumeIdOf(news.volumeId);
254 const volumeChanged =
255 desiredVolume !== undefined && desiredVolume !== output.volumeId;
256 if (appChanged || volumeChanged) {
257 return { action: "replace" as const };
258 }
259 return undefined;
260 }),
261
262 read: Effect.fn(function* ({ olds, output }) {
263 const appName = output?.appName ?? appNameOf(olds?.app);
264 const volumeId = output?.volumeId ?? volumeIdOf(olds?.volumeId);
265 if (appName === undefined || volumeId === undefined) return undefined;
266 const snapshotId = output?.snapshotId;
267 if (snapshotId === undefined || snapshotId.length === 0) {
268 return undefined;
269 }
270 const found = yield* findById(appName, volumeId, snapshotId);
271 if (found === undefined) return undefined;
272 return toAttrs(appName, volumeId, found);
273 }),
274
275 list: Effect.fn(function* () {
276 const volumes = yield* listOwnedVolumes();
277 const groups = yield* Effect.forEach(
278 volumes,
279 ({ appName, volumeId }) =>
280 listSnapshots(appName, volumeId).pipe(
281 Effect.map((snapshots) =>
282 snapshots
283 .filter(
284 (snapshot) =>
285 snapshot.id !== undefined && snapshot.id.length > 0,
286 )
287 .map((snapshot) => toAttrs(appName, volumeId, snapshot)),
288 ),
289 ),
290 { concurrency: 8 },
291 );
292 return groups.flat();
293 }),
294
295 reconcile: Effect.fn(function* ({ news, output }) {
296 const props = news ?? ({} as VolumeSnapshotProps);
297 const appName = appNameOf(props.app) ?? output?.appName;
298 const volumeId = volumeIdOf(props.volumeId) ?? output?.volumeId;
299 if (appName === undefined || volumeId === undefined) {
300 return yield* new VolumeSnapshotRefsMissing({
301 message:
302 "Fly.VolumeSnapshot requires a resolved App with appName and a volumeId.",
303 });
304 }
305
306 // Observe by cached snapshot id on the target volume.
307 const observed = yield* listSnapshots(appName, volumeId);
308 let current =
309 output?.snapshotId !== undefined && output.snapshotId.length > 0
310 ? observed.find((snapshot) => snapshot.id === output.snapshotId)
311 : undefined;
312
313 if (current === undefined) {
314 const knownIds = new Set(
315 observed.flatMap((snapshot) =>
316 snapshot.id !== undefined && snapshot.id.length > 0
317 ? [snapshot.id]
318 : [],
319 ),
320 );
321 yield* machines
322 .createVolumeSnapshot({
323 app_name: appName,
324 volume_id: volumeId,
325 })
326 .pipe(
327 Effect.asVoid,
328 Effect.catchTag("Conflict", () => Effect.void),
329 Effect.retry({
330 while: (e) =>
331 e._tag === "NotFound" ||
332 (e._tag === "BadRequest" &&
333 e.message.includes("uninitialized volume")),
334 times: 8,
335 schedule: backoff,
336 }),
337 );
338 current = yield* waitForNewSnapshot(appName, volumeId, knownIds);
339 }
340
341 if (current === undefined || current.id === undefined) {
342 return yield* new VolumeSnapshotNotCreated({ appName, volumeId });
343 }
344
345 return toAttrs(appName, volumeId, current);
346 }),
347
348 delete: Effect.fn(function* () {
349 // Fly has no snapshot delete API. Snapshots expire with Volume
350 // snapshot_retention or when the Volume is deleted.
351 }),
352 });