@ghalex/kvbox-server 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -66,6 +66,10 @@ function paths(home = kvboxHome()) {
66
66
  * The next snapshot is a diff of the pages dirtied since, merged onto this. Absent after a boot.
67
67
  */
68
68
  memBase: join(dir, "mem.base"),
69
+ /** `kvbox hibernate`: the box's memory image and VM state while it is hibernated; gone again after `wake` or `stop`. */
70
+ hibernateDir: join(dir, "hibernate"),
71
+ hibernateVmstate: join(dir, "hibernate", "vmstate"),
72
+ hibernateMem: join(dir, "hibernate", "mem"),
69
73
  sock: join(dir, "fc.sock"),
70
74
  /** Firecracker's vsock endpoint: the host connects here to reach kvbox-agent inside the box (core/agent.ts). */
71
75
  vsock: join(dir, "vsock.sock"),
@@ -295,7 +299,9 @@ var MachineMetaSchema = z2.object({
295
299
  * each one, so when this is > 0 the next diff is only standalone merged onto `mem.base`; if that file
296
300
  * is missing the lineage is broken and the next snapshot has to be a full dump. Absent: 0.
297
301
  */
298
- memSnapshots: z2.number().int().optional()
302
+ memSnapshots: z2.number().int().optional(),
303
+ /** `kvbox pause`: Firecracker is alive but the VM is frozen (standby). Absent: not paused. */
304
+ paused: z2.boolean().optional()
299
305
  });
300
306
  var SnapshotMetaSchema = z2.object({
301
307
  name: z2.string(),
@@ -474,6 +480,7 @@ function migrate(db) {
474
480
  }
475
481
  }
476
482
  }
483
+ var LIVE_STATES = ["running", "standby"];
477
484
  function fromRaw(r) {
478
485
  return { boxId: r.box_id, owner: r.owner, name: r.name, at: r.at, state: r.state, vcpu: r.vcpu, memMib: r.mem_mib, diskGib: r.disk_gib, by: r.by };
479
486
  }
@@ -488,13 +495,13 @@ function insertRaw(e, db) {
488
495
  }
489
496
  function insertUsageEvent(e, p = paths()) {
490
497
  const db = openDb(p);
491
- if (e.state === "running") closeOpenRun(e.boxId, e.at, p);
498
+ if (e.state === "running" && e.by !== "resume") closeOpenRun(e.boxId, e.at, p);
492
499
  insertRaw(e, db);
493
500
  }
494
501
  function closeOpenRun(boxId, at, p = paths()) {
495
502
  const db = openDb(p);
496
503
  const last = lastEvent(boxId, db);
497
- if (last === void 0 || last.state !== "running") return false;
504
+ if (last === void 0 || !LIVE_STATES.includes(last.state)) return false;
498
505
  insertRaw({ ...last, at, state: "stopped", by: "server" }, db);
499
506
  return true;
500
507
  }
@@ -771,6 +778,16 @@ function recordState(meta, state, by, p, at = (/* @__PURE__ */ new Date()).toISO
771
778
  console.error(`warning: usage ledger: ${err.message}`);
772
779
  }
773
780
  }
781
+ function isHibernated(meta, p) {
782
+ return !pidAlive(meta.pid) && existsSync3(p.machine(meta.owner, meta.name).hibernateVmstate);
783
+ }
784
+ function statusOf(meta, p = paths()) {
785
+ if (pidAlive(meta.pid)) return meta.paused ? "standby" : "running";
786
+ return isHibernated(meta, p) ? "hibernated" : "stopped";
787
+ }
788
+ function stateMessage(ref, status) {
789
+ return `box ${shown(ref)} is ${status === "standby" ? "in standby" : status}`;
790
+ }
774
791
  function toMachine(meta, p = paths()) {
775
792
  const alive = pidAlive(meta.pid);
776
793
  if (!alive && meta.pid !== null) {
@@ -792,7 +809,7 @@ function toMachine(meta, p = paths()) {
792
809
  base: meta.base ?? null,
793
810
  createdAt: meta.createdAt,
794
811
  source: meta.source,
795
- status: alive ? "running" : "stopped"
812
+ status: statusOf(meta, p)
796
813
  };
797
814
  }
798
815
  async function readAllMeta(p) {
@@ -936,27 +953,26 @@ async function launch(spec, tap, cfg, pubkeyB64, p) {
936
953
  throw err;
937
954
  }
938
955
  }
939
- async function loadSnapshot(spec, snapshot, tap, cfg, p) {
956
+ async function loadSnapshot(spec, image, tap, cfg, p) {
940
957
  const m = p.machine(spec.owner, spec.name);
941
- const s = p.snapshot(snapshot.owner, snapshot.name);
942
958
  const pid = await spawnFirecracker(spec, cfg, p);
943
959
  try {
944
960
  const fc = new FirecrackerClient(m.sock);
945
961
  try {
946
962
  try {
947
963
  await fc.snapshotLoad({
948
- snapshotPath: s.vmstate,
949
- memFilePath: s.mem,
964
+ snapshotPath: image.vmstate,
965
+ memFilePath: image.mem,
950
966
  tap: { ifaceId: "eth0", hostDevName: tap },
951
967
  vsockPath: m.vsock,
952
968
  trackDirtyPages: true,
953
969
  resume: false
954
970
  });
955
971
  } catch (err) {
956
- if (/Unknown Vsock Device/.test(err.message)) throw new KvboxError(tooOldToRestore(shown(snapshot)));
972
+ if (/Unknown Vsock Device/.test(err.message)) throw new KvboxError(tooOldToRestore(image.label));
957
973
  throw err;
958
974
  }
959
- if (await namesOverlayAbsolutely(s.vmstate)) await fc.patchDrivePath("overlay", m.overlay);
975
+ if (await namesOverlayAbsolutely(image.vmstate)) await fc.patchDrivePath("overlay", m.overlay);
960
976
  } finally {
961
977
  await fc.close();
962
978
  }
@@ -1060,14 +1076,12 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1060
1076
  const tap = await createTap(name, cfg, { detached: true });
1061
1077
  tapCreated = true;
1062
1078
  const spec = { name, owner, ip, mac, vcpu: snap.vcpu, memMib: snap.memMib, base: snap.base };
1063
- pid = await loadSnapshot(spec, from, tap, cfg, p);
1064
- const fc = new FirecrackerClient(m.sock);
1065
- try {
1066
- await fc.resume();
1067
- } finally {
1068
- await fc.close();
1069
- }
1070
- await introduce(spec, m.vsock, cfg, shown(from));
1079
+ pid = await loadSnapshot(spec, { vmstate: s.vmstate, mem: s.mem, label: shown(from) }, tap, cfg, p);
1080
+ await resumeVm(m.sock);
1081
+ await introduce(spec, m.vsock, cfg, {
1082
+ silent: `nothing answered on the restored box's control channel for 3 s; ${tooOldToRestore(shown(from))}`,
1083
+ noAnswer: "the restored box was discarded. kvbox-agent inside it took the request but gave no answer; try again, and if it keeps happening look at the box's journal (kvbox exec <box> -- sudo journalctl -u kvbox-agent) on a box started from the same snapshot."
1084
+ });
1071
1085
  await attachTap(name, cfg);
1072
1086
  const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: `snapshot:${refKey(from)}`, memSnapshots: 1 };
1073
1087
  await writeMachineMeta(meta, p);
@@ -1081,7 +1095,7 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1081
1095
  }
1082
1096
  }
1083
1097
  var AGENT_REPLY_MS = 2e4;
1084
- async function introduce(spec, vsock, cfg, from) {
1098
+ async function introduce(spec, vsock, cfg, failure) {
1085
1099
  const id = { name: spec.name, ip: spec.ip, prefixLen: prefixLenOf(cfg.subnet), gateway: cfg.gateway, mac: spec.mac, time: Math.floor(Date.now() / 1e3) };
1086
1100
  const deadline = Date.now() + 3e3;
1087
1101
  for (; ; ) {
@@ -1096,31 +1110,43 @@ async function introduce(spec, vsock, cfg, from) {
1096
1110
  continue;
1097
1111
  }
1098
1112
  if (transient) throw new KvboxError(`${msg}
1099
- nothing answered on the restored box's control channel for 3 s; ${tooOldToRestore(from)}`);
1113
+ ${failure.silent}`);
1100
1114
  throw new KvboxError(`${msg}
1101
- the restored box was discarded. kvbox-agent inside it took the request but gave no answer; try again, and if it keeps happening look at the box's journal (kvbox exec <box> -- sudo journalctl -u kvbox-agent) on a box started from the same snapshot.`);
1115
+ ${failure.noAnswer}`);
1102
1116
  }
1103
1117
  }
1104
1118
  }
1119
+ async function resumeVm(sock) {
1120
+ const fc = new FirecrackerClient(sock);
1121
+ try {
1122
+ await fc.resume();
1123
+ } finally {
1124
+ await fc.close();
1125
+ }
1126
+ }
1105
1127
  async function shutdown(meta, cfg, p, graceMs) {
1106
1128
  const pid = meta.pid;
1107
1129
  if (pidAlive(pid)) {
1108
1130
  const fc = new FirecrackerClient(p.machine(meta.owner, meta.name).sock);
1109
1131
  try {
1132
+ if (meta.paused) await fc.resume();
1110
1133
  await fc.sendCtrlAltDel();
1111
1134
  } catch {
1112
1135
  process.kill(pid, "SIGTERM");
1113
1136
  } finally {
1114
1137
  await fc.close();
1115
1138
  }
1116
- if (!await waitFor(() => !pidAlive(pid), graceMs, 100)) {
1117
- try {
1118
- process.kill(pid, "SIGKILL");
1119
- } catch {
1120
- }
1121
- await waitFor(() => !pidAlive(pid), 1e3, 50);
1122
- }
1139
+ await waitDead(pid, graceMs);
1140
+ }
1141
+ await cleanupProcess(meta, cfg, p);
1142
+ }
1143
+ async function waitDead(pid, graceMs) {
1144
+ if (!await waitFor(() => !pidAlive(pid), graceMs, 100)) {
1145
+ killQuietly(pid);
1146
+ await waitFor(() => !pidAlive(pid), 1e3, 50);
1123
1147
  }
1148
+ }
1149
+ async function cleanupProcess(meta, cfg, p) {
1124
1150
  await deleteTap(meta.name, cfg);
1125
1151
  const m = p.machine(meta.owner, meta.name);
1126
1152
  await rm(m.sock, { force: true });
@@ -1129,10 +1155,12 @@ async function shutdown(meta, cfg, p, graceMs) {
1129
1155
  async function stopMachine(ref, p = paths()) {
1130
1156
  const meta = await readMeta(ref, p);
1131
1157
  const cfg = await loadConfig(p);
1132
- if (!pidAlive(meta.pid)) throw new KvboxError(`box ${shown(ref)} is already stopped`);
1158
+ const m = p.machine(ref.owner, ref.name);
1159
+ if (!pidAlive(meta.pid) && !isHibernated(meta, p)) throw new KvboxError(`box ${shown(ref)} is already stopped`);
1133
1160
  await shutdown(meta, cfg, p, 1e4);
1134
- await rm(p.machine(ref.owner, ref.name).memBase, { force: true });
1135
- const stopped = { ...meta, pid: null, memSnapshots: void 0 };
1161
+ await rm(m.hibernateDir, { recursive: true, force: true });
1162
+ await rm(m.memBase, { force: true });
1163
+ const stopped = { ...meta, pid: null, memSnapshots: void 0, paused: void 0 };
1136
1164
  await writeMachineMeta(stopped, p);
1137
1165
  recordState(stopped, "stopped", "stop", p);
1138
1166
  return toMachine(stopped, p);
@@ -1140,7 +1168,10 @@ async function stopMachine(ref, p = paths()) {
1140
1168
  async function startMachine(ref, p = paths()) {
1141
1169
  const meta = await readMeta(ref, p);
1142
1170
  const cfg = await loadConfig(p);
1143
- if (pidAlive(meta.pid)) throw new KvboxError(`box ${shown(ref)} is already running`);
1171
+ if (pidAlive(meta.pid)) throw new KvboxError(`box ${shown(ref)} is already ${meta.paused ? "up, in standby (kvbox resume " + shown(ref) + ")" : "running"}`);
1172
+ if (isHibernated(meta, p)) {
1173
+ throw new KvboxError(`${stateMessage(ref, "hibernated")}: kvbox wake ${shown(ref)} continues where it left off; to boot fresh instead, kvbox stop ${shown(ref)} first (discards its memory)`);
1174
+ }
1144
1175
  assertAssets(cfg);
1145
1176
  const pubkeyB64 = await readPubkeyB64(p);
1146
1177
  await ensureHostNetwork(cfg);
@@ -1156,21 +1187,125 @@ async function startMachine(ref, p = paths()) {
1156
1187
  throw err;
1157
1188
  }
1158
1189
  }
1190
+ async function pauseMachine(ref, p = paths()) {
1191
+ const meta = await readMeta(ref, p);
1192
+ const status = statusOf(meta, p);
1193
+ if (status === "standby") throw new KvboxError(`${stateMessage(ref, status)} already`);
1194
+ if (status === "hibernated") throw new KvboxError(`${stateMessage(ref, status)}; kvbox wake ${shown(ref)} brings it back`);
1195
+ if (status === "stopped") throw new KvboxError(`${stateMessage(ref, status)}; only a running box can be paused`);
1196
+ const fc = new FirecrackerClient(p.machine(ref.owner, ref.name).sock);
1197
+ try {
1198
+ await fc.pause();
1199
+ } finally {
1200
+ await fc.close();
1201
+ }
1202
+ const paused = { ...meta, paused: true };
1203
+ await writeMachineMeta(paused, p);
1204
+ recordState(paused, "standby", "pause", p);
1205
+ return toMachine(paused, p);
1206
+ }
1207
+ async function resumeMachine(ref, p = paths()) {
1208
+ const meta = await readMeta(ref, p);
1209
+ const status = statusOf(meta, p);
1210
+ if (status === "running") throw new KvboxError(`${stateMessage(ref, status)} already`);
1211
+ if (status === "hibernated") throw new KvboxError(`${stateMessage(ref, status)}, not in standby; kvbox wake ${shown(ref)} brings it back`);
1212
+ if (status === "stopped") throw new KvboxError(`${stateMessage(ref, status)}, not in standby; kvbox start ${shown(ref)} boots it`);
1213
+ await resumeVm(p.machine(ref.owner, ref.name).sock);
1214
+ const running = { ...meta, paused: void 0 };
1215
+ await writeMachineMeta(running, p);
1216
+ recordState(running, "running", "resume", p);
1217
+ return toMachine(running, p);
1218
+ }
1219
+ async function hibernateMachine(ref, p = paths()) {
1220
+ const meta = await readMeta(ref, p);
1221
+ const cfg = await loadConfig(p);
1222
+ const status = statusOf(meta, p);
1223
+ if (status === "hibernated") throw new KvboxError(`${stateMessage(ref, status)} already`);
1224
+ if (status === "stopped") throw new KvboxError(`${stateMessage(ref, status)}; only a running box can be hibernated (there is no memory to keep)`);
1225
+ if (meta.base === void 0) throw new KvboxError(`box ${shown(ref)} was made before overlays and runs on its own disk copy; it cannot be hibernated`);
1226
+ const m = p.machine(ref.owner, ref.name);
1227
+ await rm(m.hibernateDir, { recursive: true, force: true });
1228
+ await mkdir3(m.hibernateDir, { recursive: true, mode: 448 });
1229
+ const fc = new FirecrackerClient(m.sock);
1230
+ try {
1231
+ try {
1232
+ await fc.pause();
1233
+ await fc.snapshotCreate(m.hibernateVmstate, m.hibernateMem, "Full");
1234
+ } catch (err) {
1235
+ await rm(m.hibernateDir, { recursive: true, force: true });
1236
+ if (!meta.paused) await fc.resume().catch(() => void 0);
1237
+ throw err;
1238
+ }
1239
+ } finally {
1240
+ await fc.close();
1241
+ }
1242
+ killQuietly(meta.pid);
1243
+ await waitFor(() => !pidAlive(meta.pid), 5e3, 50);
1244
+ await cleanupProcess(meta, cfg, p);
1245
+ await run(["fallocate", "--dig-holes", m.hibernateMem]);
1246
+ await rm(m.memBase, { force: true });
1247
+ const hibernated = { ...meta, pid: null, memSnapshots: void 0, paused: void 0 };
1248
+ await writeMachineMeta(hibernated, p);
1249
+ recordState(hibernated, "hibernated", "hibernate", p);
1250
+ return toMachine(hibernated, p);
1251
+ }
1252
+ async function wakeMachine(ref, p = paths()) {
1253
+ const meta = await readMeta(ref, p);
1254
+ const cfg = await loadConfig(p);
1255
+ const status = statusOf(meta, p);
1256
+ if (status === "running" || status === "standby") throw new KvboxError(`${stateMessage(ref, status)}, not hibernated`);
1257
+ if (status === "stopped") throw new KvboxError(`${stateMessage(ref, status)}, not hibernated; kvbox start ${shown(ref)} boots it`);
1258
+ assertAssets(cfg);
1259
+ if (meta.base !== void 0 && !existsSync3(p.baseImage(meta.base))) {
1260
+ throw new KvboxError(`box ${shown(ref)} stacks on base image ${meta.base}, which is gone from ${p.assets}`);
1261
+ }
1262
+ const m = p.machine(ref.owner, ref.name);
1263
+ await ensureHostNetwork(cfg);
1264
+ const tap = await createTap(ref.name, cfg, { detached: true });
1265
+ let pid;
1266
+ try {
1267
+ const spec = { name: meta.name, owner: meta.owner, ip: meta.ip, mac: meta.mac, vcpu: meta.vcpu, memMib: meta.memMib, base: meta.base };
1268
+ pid = await loadSnapshot(spec, { vmstate: m.hibernateVmstate, mem: m.hibernateMem, label: `box ${shown(ref)}'s memory image` }, tap, cfg, p);
1269
+ await resumeVm(m.sock);
1270
+ await introduce(spec, m.vsock, cfg, {
1271
+ silent: `nothing answered on the box's control channel for 3 s; it stays hibernated, try kvbox wake ${shown(ref)} again`,
1272
+ noAnswer: `the box stays hibernated. kvbox-agent inside it took the request but gave no answer; try kvbox wake ${shown(ref)} again`
1273
+ });
1274
+ await attachTap(ref.name, cfg);
1275
+ } catch (err) {
1276
+ if (pid !== void 0) killQuietly(pid);
1277
+ await deleteTap(ref.name, cfg).catch(() => void 0);
1278
+ throw err;
1279
+ }
1280
+ await rm(m.memBase, { force: true });
1281
+ await link(m.hibernateMem, m.memBase);
1282
+ await rm(m.hibernateDir, { recursive: true, force: true });
1283
+ const running = { ...meta, pid, memSnapshots: 1, paused: void 0 };
1284
+ await writeMachineMeta(running, p);
1285
+ recordState(running, "running", "wake", p);
1286
+ return toMachine(running, p);
1287
+ }
1159
1288
  async function applyAtNextBoot(meta, changes, by, cfg, p, whileStopped) {
1160
1289
  const wasRunning = pidAlive(meta.pid);
1161
1290
  if (wasRunning) {
1162
1291
  await shutdown(meta, cfg, p, 1e4);
1163
1292
  await rm(p.machine(meta.owner, meta.name).memBase, { force: true });
1164
1293
  }
1165
- const updated = { ...meta, ...changes, pid: null, memSnapshots: void 0 };
1294
+ const updated = { ...meta, ...changes, pid: null, memSnapshots: void 0, paused: void 0 };
1166
1295
  await writeMachineMeta(updated, p);
1167
1296
  if (whileStopped) await whileStopped();
1168
1297
  recordState(updated, "stopped", by, p);
1169
1298
  return wasRunning ? startMachine(meta, p) : toMachine(updated, p);
1170
1299
  }
1300
+ function assertNotHibernated(meta, p) {
1301
+ if (isHibernated(meta, p)) {
1302
+ throw new KvboxError(`${stateMessage(meta, "hibernated")}; its memory image was made with its current shape and base: kvbox wake ${shown(meta)} first`);
1303
+ }
1304
+ }
1171
1305
  async function updateMachine(ref, p = paths()) {
1172
1306
  const meta = await readMeta(ref, p);
1173
1307
  const cfg = await loadConfig(p);
1308
+ assertNotHibernated(meta, p);
1174
1309
  if (meta.base === void 0) {
1175
1310
  throw new KvboxError(`box ${shown(ref)} was made before overlays and runs on its own disk copy; it cannot be moved to another base image`);
1176
1311
  }
@@ -1183,6 +1318,7 @@ async function updateMachine(ref, p = paths()) {
1183
1318
  async function rescaleMachine(ref, req, p = paths()) {
1184
1319
  const meta = await readMeta(ref, p);
1185
1320
  const cfg = await loadConfig(p);
1321
+ assertNotHibernated(meta, p);
1186
1322
  const disk = ownDisk(meta, p);
1187
1323
  const currentDiskGib = Math.round(statSync(disk).size / 1024 ** 3);
1188
1324
  const preset = req.size !== void 0 && isSizeName(req.size) ? SIZES[req.size] : void 0;
@@ -1229,7 +1365,7 @@ function computeUsage(events, w) {
1229
1365
  const sorted = [...events].sort((a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0);
1230
1366
  const from = Date.parse(w.from);
1231
1367
  const to = Date.parse(w.to);
1232
- const ms = { running: 0, stopped: 0 };
1368
+ const ms = { running: 0, standby: 0, hibernated: 0, stopped: 0 };
1233
1369
  let vcpuHours = 0;
1234
1370
  let ramGibHours = 0;
1235
1371
  let diskGibHours = 0;
@@ -1240,14 +1376,17 @@ function computeUsage(events, w) {
1240
1376
  if (end <= start) return;
1241
1377
  const h = (end - start) / HOUR_MS;
1242
1378
  ms[e.state] += end - start;
1243
- if (e.state === "running") {
1244
- vcpuHours += h * e.vcpu;
1245
- ramGibHours += h * e.memMib / 1024;
1246
- }
1379
+ if (e.state === "running") vcpuHours += h * e.vcpu;
1380
+ if (e.state === "running" || e.state === "standby") ramGibHours += h * e.memMib / 1024;
1247
1381
  diskGibHours += h * e.diskGib;
1248
1382
  });
1249
1383
  const totals = {
1250
- seconds: { running: Math.round(ms.running / 1e3), stopped: Math.round(ms.stopped / 1e3) },
1384
+ seconds: {
1385
+ running: Math.round(ms.running / 1e3),
1386
+ standby: Math.round(ms.standby / 1e3),
1387
+ hibernated: Math.round(ms.hibernated / 1e3),
1388
+ stopped: Math.round(ms.stopped / 1e3)
1389
+ },
1251
1390
  vcpuHours: round(vcpuHours, 3),
1252
1391
  ramGibHours: round(ramGibHours, 3),
1253
1392
  diskGibHours: round(diskGibHours, 3),
@@ -1307,7 +1446,7 @@ function versionOf(srcFileUrl, injected) {
1307
1446
  }
1308
1447
 
1309
1448
  // src/version.ts
1310
- var VERSION = versionOf(import.meta.url, true ? "0.1.0" : void 0);
1449
+ var VERSION = versionOf(import.meta.url, true ? "0.1.1" : void 0);
1311
1450
 
1312
1451
  // src/caller.ts
1313
1452
  function resolveCaller() {
@@ -1332,6 +1471,7 @@ import { Hono as Hono2 } from "hono";
1332
1471
  // ../shared/src/api.ts
1333
1472
  import { z as z3 } from "zod";
1334
1473
  var SizeSchema = z3.enum(SIZE_NAMES);
1474
+ var BoxStatusSchema = z3.enum(["running", "standby", "hibernated", "stopped"]);
1335
1475
  var MachineSchema = z3.object({
1336
1476
  name: z3.string(),
1337
1477
  /** Who the box belongs to; names are unique per owner. A bare name is local to the host, `user@control-plane` later. */
@@ -1350,7 +1490,7 @@ var MachineSchema = z3.object({
1350
1490
  createdAt: z3.string(),
1351
1491
  source: z3.string(),
1352
1492
  // "base" or "snapshot:<name>"
1353
- status: z3.enum(["running", "stopped"])
1493
+ status: BoxStatusSchema
1354
1494
  });
1355
1495
  var CreateMachineRequest = z3.object({
1356
1496
  name: z3.string().optional(),
@@ -1402,9 +1542,15 @@ var HealthSchema = z3.object({
1402
1542
  var MachineUsageSchema = z3.object({
1403
1543
  name: z3.string(),
1404
1544
  owner: z3.string(),
1405
- status: z3.enum(["running", "stopped"]),
1545
+ status: BoxStatusSchema,
1406
1546
  window: z3.object({ from: z3.string(), to: z3.string() }),
1407
- seconds: z3.object({ running: z3.number().int(), stopped: z3.number().int() }),
1547
+ /** standby and hibernated default to 0 so a newer CLI reads an older server's answer. */
1548
+ seconds: z3.object({
1549
+ running: z3.number().int(),
1550
+ standby: z3.number().int().default(0),
1551
+ hibernated: z3.number().int().default(0),
1552
+ stopped: z3.number().int()
1553
+ }),
1408
1554
  vcpuHours: z3.number(),
1409
1555
  ramGibHours: z3.number(),
1410
1556
  diskGibHours: z3.number(),
@@ -1427,6 +1573,10 @@ function machineRoutes() {
1427
1573
  });
1428
1574
  r.post("/:name/stop", async (c) => c.json(await stopMachine(parseRef(c.req.param("name"), c.get("owner")))));
1429
1575
  r.post("/:name/start", async (c) => c.json(await startMachine(parseRef(c.req.param("name"), c.get("owner")))));
1576
+ r.post("/:name/pause", async (c) => c.json(await pauseMachine(parseRef(c.req.param("name"), c.get("owner")))));
1577
+ r.post("/:name/resume", async (c) => c.json(await resumeMachine(parseRef(c.req.param("name"), c.get("owner")))));
1578
+ r.post("/:name/hibernate", async (c) => c.json(await hibernateMachine(parseRef(c.req.param("name"), c.get("owner")))));
1579
+ r.post("/:name/wake", async (c) => c.json(await wakeMachine(parseRef(c.req.param("name"), c.get("owner")))));
1430
1580
  r.post("/:name/update", async (c) => c.json(await updateMachine(parseRef(c.req.param("name"), c.get("owner")))));
1431
1581
  r.post("/:name/rescale", async (c) => {
1432
1582
  const body = RescaleMachineRequest.parse(await c.req.json().catch(() => ({})));
@@ -1504,6 +1654,9 @@ async function saveSnapshot(req, owner, p = paths()) {
1504
1654
  throw new KvboxError(`box ${req.box} was made before overlays and runs on its own disk copy; snapshots need a box made by this version of kvbox (kvbox new)`);
1505
1655
  }
1506
1656
  if (!pidAlive(box.pid)) {
1657
+ if (existsSync4(p.machine(box.owner, box.name).hibernateVmstate)) {
1658
+ throw new KvboxError(`box ${req.box} is hibernated; a snapshot freezes a running box (kvbox wake ${req.box} first)`);
1659
+ }
1507
1660
  throw new KvboxError(`box ${req.box} is not running; a snapshot freezes a running box (kvbox start ${req.box} first)`);
1508
1661
  }
1509
1662
  const s = p.snapshot(owner, req.name);
@@ -1526,7 +1679,7 @@ async function saveSnapshot(req, owner, p = paths()) {
1526
1679
  } catch (err) {
1527
1680
  failure = err;
1528
1681
  } finally {
1529
- await fc.resume();
1682
+ if (!box.paused) await fc.resume();
1530
1683
  }
1531
1684
  if (lineageTouched) {
1532
1685
  await writeMachineMeta({ ...box, memSnapshots: since + 1 }, p);