@ghalex/kvbox-server 0.1.2 → 0.1.4

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.
@@ -278,9 +278,16 @@ import { rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
278
278
  import { createAdaptorServer } from "@hono/node-server";
279
279
 
280
280
  // src/core/state.ts
281
+ import { randomBytes } from "node:crypto";
281
282
  import { mkdir, readFile as readFile2, readdir, writeFile as writeFile2 } from "node:fs/promises";
282
283
  import { z as z2 } from "zod";
283
284
  var MachineMetaSchema = z2.object({
285
+ /**
286
+ * Host-unique, minted at `kvbox new` (fresh or restored) and kept for the box's life; the tap is
287
+ * `kv-<id>`. Absent only on a box that predates ids and is still running on its `kv-<name>` tap:
288
+ * `migrateBoxIds` gives stopped ones theirs at server start, a running one gets it when it next starts.
289
+ */
290
+ id: z2.string().optional(),
284
291
  name: z2.string(),
285
292
  /** Part of the box's identity: machines/<owner>/<name>. See refs.ts. */
286
293
  owner: z2.string(),
@@ -303,6 +310,12 @@ var MachineMetaSchema = z2.object({
303
310
  /** `kvbox pause`: Firecracker is alive but the VM is frozen (standby). Absent: not paused. */
304
311
  paused: z2.boolean().optional()
305
312
  });
313
+ function newBoxId(taken) {
314
+ for (; ; ) {
315
+ const id = randomBytes(3).toString("hex");
316
+ if (!taken.has(id)) return id;
317
+ }
318
+ }
306
319
  var SnapshotMetaSchema = z2.object({
307
320
  name: z2.string(),
308
321
  owner: z2.string(),
@@ -377,57 +390,6 @@ function pidAlive(pid) {
377
390
  import { existsSync as existsSync2 } from "node:fs";
378
391
  import { mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rename, writeFile as writeFile3 } from "node:fs/promises";
379
392
  import { join as join2 } from "node:path";
380
- async function migrateOwnerLayout(owner, p = paths()) {
381
- const done = { boxes: [], snapshots: [], conflicts: [] };
382
- for (const name of await oldDepthEntries(p.machines)) {
383
- const from = join2(p.machines, name);
384
- const to = p.machine(owner, name).dir;
385
- if (existsSync2(to)) {
386
- done.conflicts.push(`machines/${name}`);
387
- continue;
388
- }
389
- await patchJson(join2(from, "meta.json"), (meta) => ({
390
- ...meta,
391
- owner,
392
- // a restored box names its snapshot: that moved under the same owner
393
- source: typeof meta["source"] === "string" && meta["source"].startsWith("snapshot:") && !meta["source"].includes("/") ? `snapshot:${owner}/${meta["source"].slice("snapshot:".length)}` : meta["source"]
394
- }));
395
- await mkdir2(p.ownerMachines(owner), { recursive: true, mode: 448 });
396
- await rename(from, to);
397
- done.boxes.push(name);
398
- }
399
- for (const name of await oldDepthEntries(p.snapshots)) {
400
- const from = join2(p.snapshots, name);
401
- const to = p.snapshot(owner, name).dir;
402
- if (existsSync2(to)) {
403
- done.conflicts.push(`snapshots/${name}`);
404
- continue;
405
- }
406
- await patchJson(join2(from, "meta.json"), (meta) => ({
407
- ...meta,
408
- owner,
409
- source: typeof meta["source"] === "string" && !meta["source"].includes("/") ? `${owner}/${meta["source"]}` : meta["source"]
410
- }));
411
- await mkdir2(p.ownerSnapshots(owner), { recursive: true, mode: 448 });
412
- await rename(from, to);
413
- done.snapshots.push(name);
414
- }
415
- return done;
416
- }
417
- async function oldDepthEntries(root) {
418
- let entries;
419
- try {
420
- entries = await readdir2(root, { withFileTypes: true });
421
- } catch (err) {
422
- if (err.code === "ENOENT") return [];
423
- throw err;
424
- }
425
- return entries.filter((e) => e.isDirectory() && existsSync2(join2(root, e.name, "meta.json"))).map((e) => e.name).sort();
426
- }
427
- async function patchJson(file, patch) {
428
- const raw = JSON.parse(await readFile3(file, "utf8"));
429
- await writeFile3(file, JSON.stringify(patch(raw), null, 2) + "\n");
430
- }
431
393
 
432
394
  // src/core/db.ts
433
395
  import { chmodSync, closeSync, mkdirSync, openSync } from "node:fs";
@@ -509,6 +471,91 @@ function usageEventsFor(boxId, since, p = paths()) {
509
471
  const rows = openDb(p).prepare("select * from usage_events where box_id = ? and at >= ? order by at, id").all(boxId, since);
510
472
  return rows.map(fromRaw);
511
473
  }
474
+ function rekeyUsageEvents(from, to, p = paths()) {
475
+ return Number(openDb(p).prepare("update usage_events set box_id = ? where box_id = ?").run(to, from).changes);
476
+ }
477
+
478
+ // src/core/migrate.ts
479
+ async function migrateOwnerLayout(owner, p = paths()) {
480
+ const done = { boxes: [], snapshots: [], conflicts: [] };
481
+ for (const name of await oldDepthEntries(p.machines)) {
482
+ const from = join2(p.machines, name);
483
+ const to = p.machine(owner, name).dir;
484
+ if (existsSync2(to)) {
485
+ done.conflicts.push(`machines/${name}`);
486
+ continue;
487
+ }
488
+ await patchJson(join2(from, "meta.json"), (meta) => ({
489
+ ...meta,
490
+ owner,
491
+ // a restored box names its snapshot: that moved under the same owner
492
+ source: typeof meta["source"] === "string" && meta["source"].startsWith("snapshot:") && !meta["source"].includes("/") ? `snapshot:${owner}/${meta["source"].slice("snapshot:".length)}` : meta["source"]
493
+ }));
494
+ await mkdir2(p.ownerMachines(owner), { recursive: true, mode: 448 });
495
+ await rename(from, to);
496
+ done.boxes.push(name);
497
+ }
498
+ for (const name of await oldDepthEntries(p.snapshots)) {
499
+ const from = join2(p.snapshots, name);
500
+ const to = p.snapshot(owner, name).dir;
501
+ if (existsSync2(to)) {
502
+ done.conflicts.push(`snapshots/${name}`);
503
+ continue;
504
+ }
505
+ await patchJson(join2(from, "meta.json"), (meta) => ({
506
+ ...meta,
507
+ owner,
508
+ source: typeof meta["source"] === "string" && !meta["source"].includes("/") ? `${owner}/${meta["source"]}` : meta["source"]
509
+ }));
510
+ await mkdir2(p.ownerSnapshots(owner), { recursive: true, mode: 448 });
511
+ await rename(from, to);
512
+ done.snapshots.push(name);
513
+ }
514
+ return done;
515
+ }
516
+ async function oldDepthEntries(root) {
517
+ let entries;
518
+ try {
519
+ entries = await readdir2(root, { withFileTypes: true });
520
+ } catch (err) {
521
+ if (err.code === "ENOENT") return [];
522
+ throw err;
523
+ }
524
+ return entries.filter((e) => e.isDirectory() && existsSync2(join2(root, e.name, "meta.json"))).map((e) => e.name).sort();
525
+ }
526
+ async function patchJson(file, patch) {
527
+ const raw = JSON.parse(await readFile3(file, "utf8"));
528
+ await writeFile3(file, JSON.stringify(patch(raw), null, 2) + "\n");
529
+ }
530
+ async function migrateBoxIds(p = paths()) {
531
+ const metas = [];
532
+ for (const ref of await listMachineRefs(p)) {
533
+ try {
534
+ metas.push(await readMachineMeta(ref, p));
535
+ } catch {
536
+ continue;
537
+ }
538
+ }
539
+ const taken = new Set(metas.flatMap((m) => m.id === void 0 ? [] : [m.id]));
540
+ const done = [];
541
+ for (const meta of metas) {
542
+ if (meta.id !== void 0) {
543
+ rekeyUsageEvents(refKey(meta), meta.id, p);
544
+ continue;
545
+ }
546
+ if (pidAlive(meta.pid)) continue;
547
+ await adoptBoxId(meta, taken, p);
548
+ done.push(refKey(meta));
549
+ }
550
+ return done;
551
+ }
552
+ async function adoptBoxId(meta, taken, p = paths()) {
553
+ if (meta.id !== void 0) return meta;
554
+ const withId2 = { ...meta, id: newBoxId(taken) };
555
+ await writeMachineMeta(withId2, p);
556
+ rekeyUsageEvents(refKey(meta), withId2.id, p);
557
+ return withId2;
558
+ }
512
559
 
513
560
  // src/core/machines.ts
514
561
  import { spawn } from "node:child_process";
@@ -752,6 +799,12 @@ var NAME_RE = /^[a-z0-9][a-z0-9-]{0,11}$/;
752
799
  var AUTO_PREFIX = "box";
753
800
  var GUEST_CID = 3;
754
801
  var OVERLAY_REL = "overlay.ext4";
802
+ var tapKey = (meta) => meta.id ?? meta.name;
803
+ var usageKey = (meta) => meta.id ?? refKey(meta);
804
+ var idsInUse = (metas) => new Set(metas.flatMap((m) => m.id === void 0 ? [] : [m.id]));
805
+ async function withId(meta, p) {
806
+ return meta.id !== void 0 ? meta : adoptBoxId(meta, idsInUse(await readAllMeta(p)), p);
807
+ }
755
808
  function diskStats(disk) {
756
809
  try {
757
810
  const st = statSync(disk);
@@ -773,7 +826,7 @@ function diskGibOf(meta, p) {
773
826
  }
774
827
  function recordState(meta, state, by, p, at = (/* @__PURE__ */ new Date()).toISOString()) {
775
828
  try {
776
- insertUsageEvent({ boxId: refKey(meta), owner: meta.owner, name: meta.name, at, state, by, vcpu: meta.vcpu, memMib: meta.memMib, diskGib: diskGibOf(meta, p) }, p);
829
+ insertUsageEvent({ boxId: usageKey(meta), owner: meta.owner, name: meta.name, at, state, by, vcpu: meta.vcpu, memMib: meta.memMib, diskGib: diskGibOf(meta, p) }, p);
777
830
  } catch (err) {
778
831
  console.error(`warning: usage ledger: ${err.message}`);
779
832
  }
@@ -792,12 +845,13 @@ function toMachine(meta, p = paths()) {
792
845
  const alive = pidAlive(meta.pid);
793
846
  if (!alive && meta.pid !== null) {
794
847
  try {
795
- closeOpenRun(refKey(meta), (/* @__PURE__ */ new Date()).toISOString(), p);
848
+ closeOpenRun(usageKey(meta), (/* @__PURE__ */ new Date()).toISOString(), p);
796
849
  } catch (err) {
797
850
  console.error(`warning: usage ledger: ${err.message}`);
798
851
  }
799
852
  }
800
853
  return {
854
+ id: meta.id,
801
855
  name: meta.name,
802
856
  owner: meta.owner,
803
857
  ip: meta.ip,
@@ -1014,6 +1068,7 @@ async function createMachine(req, owner, p = paths()) {
1014
1068
  const pubkeyB64 = await readPubkeyB64(p);
1015
1069
  const existing = await readAllMeta(p);
1016
1070
  const name = pickName(req.name, new Set(existing.filter((m2) => m2.owner === owner).map((m2) => m2.name)));
1071
+ const id = newBoxId(idsInUse(existing));
1017
1072
  const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
1018
1073
  const mac = macFor(ip);
1019
1074
  const m = p.machine(owner, name);
@@ -1024,16 +1079,16 @@ async function createMachine(req, owner, p = paths()) {
1024
1079
  let tapCreated = false;
1025
1080
  try {
1026
1081
  await createOverlay(m.overlay, size.diskGib);
1027
- const tap = await createTap(name, cfg);
1082
+ const tap = await createTap(id, cfg);
1028
1083
  tapCreated = true;
1029
- const spec = { name, owner, ip, mac, vcpu: size.vcpu, memMib: size.memMib, base: base.version };
1084
+ const spec = { id, name, owner, ip, mac, vcpu: size.vcpu, memMib: size.memMib, base: base.version };
1030
1085
  const pid = await launch(spec, tap, cfg, pubkeyB64, p);
1031
1086
  const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: "base" };
1032
1087
  await writeMachineMeta(meta, p);
1033
1088
  recordState(meta, "running", "new", p, meta.createdAt);
1034
1089
  return toMachine(meta, p);
1035
1090
  } catch (err) {
1036
- if (tapCreated) await deleteTap(name, cfg).catch(() => void 0);
1091
+ if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
1037
1092
  await rm(m.dir, { recursive: true, force: true });
1038
1093
  throw err;
1039
1094
  }
@@ -1062,6 +1117,7 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1062
1117
  await assertRoomFor(snap.vcpu, snap.memMib, req.force ?? false, p);
1063
1118
  const existing = await readAllMeta(p);
1064
1119
  const name = pickName(req.name, new Set(existing.filter((m2) => m2.owner === owner).map((m2) => m2.name)));
1120
+ const id = newBoxId(idsInUse(existing));
1065
1121
  const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
1066
1122
  const mac = macFor(ip);
1067
1123
  const m = p.machine(owner, name);
@@ -1073,23 +1129,23 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1073
1129
  try {
1074
1130
  await run(["cp", "--sparse=always", "--reflink=auto", s.overlay, m.overlay]);
1075
1131
  await link(s.mem, m.memBase);
1076
- const tap = await createTap(name, cfg, { detached: true });
1132
+ const tap = await createTap(id, cfg, { detached: true });
1077
1133
  tapCreated = true;
1078
- const spec = { name, owner, ip, mac, vcpu: snap.vcpu, memMib: snap.memMib, base: snap.base };
1134
+ const spec = { id, name, owner, ip, mac, vcpu: snap.vcpu, memMib: snap.memMib, base: snap.base };
1079
1135
  pid = await loadSnapshot(spec, { vmstate: s.vmstate, mem: s.mem, label: shown(from) }, tap, cfg, p);
1080
1136
  await resumeVm(m.sock);
1081
1137
  await introduce(spec, m.vsock, cfg, {
1082
1138
  silent: `nothing answered on the restored box's control channel for 3 s; ${tooOldToRestore(shown(from))}`,
1083
1139
  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
1140
  });
1085
- await attachTap(name, cfg);
1141
+ await attachTap(id, cfg);
1086
1142
  const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: `snapshot:${refKey(from)}`, memSnapshots: 1 };
1087
1143
  await writeMachineMeta(meta, p);
1088
1144
  recordState(meta, "running", "new", p, meta.createdAt);
1089
1145
  return toMachine(meta, p);
1090
1146
  } catch (err) {
1091
1147
  if (pid !== void 0) killQuietly(pid);
1092
- if (tapCreated) await deleteTap(name, cfg).catch(() => void 0);
1148
+ if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
1093
1149
  await rm(m.dir, { recursive: true, force: true });
1094
1150
  throw err;
1095
1151
  }
@@ -1147,7 +1203,7 @@ async function waitDead(pid, graceMs) {
1147
1203
  }
1148
1204
  }
1149
1205
  async function cleanupProcess(meta, cfg, p) {
1150
- await deleteTap(meta.name, cfg);
1206
+ await deleteTap(tapKey(meta), cfg);
1151
1207
  const m = p.machine(meta.owner, meta.name);
1152
1208
  await rm(m.sock, { force: true });
1153
1209
  await rm(m.vsock, { force: true });
@@ -1166,7 +1222,7 @@ async function stopMachine(ref, p = paths()) {
1166
1222
  return toMachine(stopped, p);
1167
1223
  }
1168
1224
  async function startMachine(ref, p = paths()) {
1169
- const meta = await readMeta(ref, p);
1225
+ const meta = await withId(await readMeta(ref, p), p);
1170
1226
  const cfg = await loadConfig(p);
1171
1227
  if (pidAlive(meta.pid)) throw new KvboxError(`box ${shown(ref)} is already ${meta.paused ? "up, in standby (kvbox resume " + shown(ref) + ")" : "running"}`);
1172
1228
  if (isHibernated(meta, p)) {
@@ -1175,7 +1231,7 @@ async function startMachine(ref, p = paths()) {
1175
1231
  assertAssets(cfg);
1176
1232
  const pubkeyB64 = await readPubkeyB64(p);
1177
1233
  await ensureHostNetwork(cfg);
1178
- const tap = await createTap(ref.name, cfg);
1234
+ const tap = await createTap(meta.id, cfg);
1179
1235
  try {
1180
1236
  const pid = await launch(meta, tap, cfg, pubkeyB64, p);
1181
1237
  const started = { ...meta, pid };
@@ -1183,7 +1239,7 @@ async function startMachine(ref, p = paths()) {
1183
1239
  recordState(started, "running", "start", p);
1184
1240
  return toMachine(started, p);
1185
1241
  } catch (err) {
1186
- await deleteTap(ref.name, cfg).catch(() => void 0);
1242
+ await deleteTap(meta.id, cfg).catch(() => void 0);
1187
1243
  throw err;
1188
1244
  }
1189
1245
  }
@@ -1250,7 +1306,7 @@ async function hibernateMachine(ref, p = paths()) {
1250
1306
  return toMachine(hibernated, p);
1251
1307
  }
1252
1308
  async function wakeMachine(ref, p = paths()) {
1253
- const meta = await readMeta(ref, p);
1309
+ const meta = await withId(await readMeta(ref, p), p);
1254
1310
  const cfg = await loadConfig(p);
1255
1311
  const status = statusOf(meta, p);
1256
1312
  if (status === "running" || status === "standby") throw new KvboxError(`${stateMessage(ref, status)}, not hibernated`);
@@ -1261,20 +1317,20 @@ async function wakeMachine(ref, p = paths()) {
1261
1317
  }
1262
1318
  const m = p.machine(ref.owner, ref.name);
1263
1319
  await ensureHostNetwork(cfg);
1264
- const tap = await createTap(ref.name, cfg, { detached: true });
1320
+ const tap = await createTap(meta.id, cfg, { detached: true });
1265
1321
  let pid;
1266
1322
  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 };
1323
+ const spec = { id: meta.id, name: meta.name, owner: meta.owner, ip: meta.ip, mac: meta.mac, vcpu: meta.vcpu, memMib: meta.memMib, base: meta.base };
1268
1324
  pid = await loadSnapshot(spec, { vmstate: m.hibernateVmstate, mem: m.hibernateMem, label: `box ${shown(ref)}'s memory image` }, tap, cfg, p);
1269
1325
  await resumeVm(m.sock);
1270
1326
  await introduce(spec, m.vsock, cfg, {
1271
1327
  silent: `nothing answered on the box's control channel for 3 s; it stays hibernated, try kvbox wake ${shown(ref)} again`,
1272
1328
  noAnswer: `the box stays hibernated. kvbox-agent inside it took the request but gave no answer; try kvbox wake ${shown(ref)} again`
1273
1329
  });
1274
- await attachTap(ref.name, cfg);
1330
+ await attachTap(meta.id, cfg);
1275
1331
  } catch (err) {
1276
1332
  if (pid !== void 0) killQuietly(pid);
1277
- await deleteTap(ref.name, cfg).catch(() => void 0);
1333
+ await deleteTap(meta.id, cfg).catch(() => void 0);
1278
1334
  throw err;
1279
1335
  }
1280
1336
  await rm(m.memBase, { force: true });
@@ -1404,7 +1460,7 @@ async function machineUsage(ref, since, p = paths()) {
1404
1460
  const box = await getMachine(ref, p);
1405
1461
  const cfg = await loadConfig(p);
1406
1462
  const now = (/* @__PURE__ */ new Date()).toISOString();
1407
- const boxId = refKey(ref);
1463
+ const boxId = box.id ?? refKey(ref);
1408
1464
  let events = usageEventsFor(boxId, box.createdAt, p);
1409
1465
  if (events.length === 0) {
1410
1466
  insertUsageEvent(
@@ -1419,7 +1475,7 @@ async function machineUsage(ref, since, p = paths()) {
1419
1475
  async function seedUsageLedger(p = paths()) {
1420
1476
  let seeded = 0;
1421
1477
  for (const box of await listMachines(null, p)) {
1422
- const boxId = `${box.owner}/${box.name}`;
1478
+ const boxId = box.id ?? `${box.owner}/${box.name}`;
1423
1479
  if (usageEventsFor(boxId, box.createdAt, p).length > 0) continue;
1424
1480
  insertUsageEvent(
1425
1481
  { boxId, owner: box.owner, name: box.name, at: (/* @__PURE__ */ new Date()).toISOString(), state: box.status, by: "upgrade", vcpu: box.vcpu, memMib: box.memMib, diskGib: Math.round(box.diskBytes / GIB) },
@@ -1446,7 +1502,7 @@ function versionOf(srcFileUrl, injected) {
1446
1502
  }
1447
1503
 
1448
1504
  // src/version.ts
1449
- var VERSION = versionOf(import.meta.url, true ? "0.1.2" : void 0);
1505
+ var VERSION = versionOf(import.meta.url, true ? "0.1.4" : void 0);
1450
1506
 
1451
1507
  // src/caller.ts
1452
1508
  function resolveCaller() {
@@ -1473,6 +1529,8 @@ import { z as z3 } from "zod";
1473
1529
  var SizeSchema = z3.enum(SIZE_NAMES);
1474
1530
  var BoxStatusSchema = z3.enum(["running", "standby", "hibernated", "stopped"]);
1475
1531
  var MachineSchema = z3.object({
1532
+ /** Host-unique short id (the tap is `kv-<id>`). Missing only on a box made before ids that has not restarted since. */
1533
+ id: z3.string().optional(),
1476
1534
  name: z3.string(),
1477
1535
  /** Who the box belongs to; names are unique per owner. A bare name is local to the host, `user@control-plane` later. */
1478
1536
  owner: z3.string(),
@@ -1780,6 +1838,8 @@ async function listen(opts = {}) {
1780
1838
  console.error(`boxes and snapshots now belong to an owner: moved ${moved.boxes.length} box(es) and ${moved.snapshots.length} snapshot(s) under ${cfg.owner}/`);
1781
1839
  }
1782
1840
  for (const c of moved.conflicts) console.error(`warning: ${c} left in place: ${cfg.owner} already has one with that name`);
1841
+ const withIds = await migrateBoxIds(p);
1842
+ if (withIds.length > 0) console.error(`boxes now have an id of their own: ${withIds.length} box(es) made before this version got theirs (${withIds.join(", ")})`);
1783
1843
  const seeded = await seedUsageLedger(p);
1784
1844
  if (seeded > 0) console.error(`usage: started keeping time for ${seeded} box(es) made before this version; kvbox vm usage counts from now for them`);
1785
1845
  const socketPath = opts.socketPath ?? p.serverSock;