@ghalex/kvbox-server 0.0.15 → 0.0.16

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.
@@ -47,31 +47,40 @@ function paths(home = kvboxHome()) {
47
47
  baseImage: (version) => join(home, "assets", `rootfs-${version}.ext4`),
48
48
  machines: join(home, "machines"),
49
49
  snapshots: join(home, "snapshots"),
50
- machine: (name) => ({
51
- dir: join(home, "machines", name),
52
- meta: join(home, "machines", name, "meta.json"),
53
- /** The box's writable layer, stacked on the read-only base image inside the guest. */
54
- overlay: join(home, "machines", name, "overlay.ext4"),
55
- /** Boxes made before overlays: a full copy of the base image. Kept bootable, never created anymore. */
56
- disk: join(home, "machines", name, "disk.ext4"),
57
- /**
58
- * The box's memory as of its last snapshot (a hard link to that snapshot's `mem`, never modified).
59
- * The next snapshot is a diff of the pages dirtied since, merged onto this. Absent after a boot.
60
- */
61
- memBase: join(home, "machines", name, "mem.base"),
62
- sock: join(home, "machines", name, "fc.sock"),
63
- /** Firecracker's vsock endpoint: the host connects here to reach kvbox-agent inside the box (core/agent.ts). */
64
- vsock: join(home, "machines", name, "vsock.sock"),
65
- log: join(home, "machines", name, "fc.log")
66
- }),
67
- snapshot: (name) => ({
68
- dir: join(home, "snapshots", name),
69
- meta: join(home, "snapshots", name, "meta.json"),
70
- vmstate: join(home, "snapshots", name, "vmstate"),
71
- mem: join(home, "snapshots", name, "mem"),
72
- /** Copy of the box's overlay at the moment of the snapshot; the base image is referenced by version in meta.json. */
73
- overlay: join(home, "snapshots", name, "overlay.ext4")
74
- })
50
+ /** Boxes and snapshots are filed by owner: machines/<owner>/<name>. The owner is part of a box's identity. */
51
+ ownerMachines: (owner) => join(home, "machines", owner),
52
+ ownerSnapshots: (owner) => join(home, "snapshots", owner),
53
+ machine: (owner, name) => {
54
+ const dir = join(home, "machines", owner, name);
55
+ return {
56
+ dir,
57
+ meta: join(dir, "meta.json"),
58
+ /** The box's writable layer, stacked on the read-only base image inside the guest. */
59
+ overlay: join(dir, "overlay.ext4"),
60
+ /** Boxes made before overlays: a full copy of the base image. Kept bootable, never created anymore. */
61
+ disk: join(dir, "disk.ext4"),
62
+ /**
63
+ * The box's memory as of its last snapshot (a hard link to that snapshot's `mem`, never modified).
64
+ * The next snapshot is a diff of the pages dirtied since, merged onto this. Absent after a boot.
65
+ */
66
+ memBase: join(dir, "mem.base"),
67
+ sock: join(dir, "fc.sock"),
68
+ /** Firecracker's vsock endpoint: the host connects here to reach kvbox-agent inside the box (core/agent.ts). */
69
+ vsock: join(dir, "vsock.sock"),
70
+ log: join(dir, "fc.log")
71
+ };
72
+ },
73
+ snapshot: (owner, name) => {
74
+ const dir = join(home, "snapshots", owner, name);
75
+ return {
76
+ dir,
77
+ meta: join(dir, "meta.json"),
78
+ vmstate: join(dir, "vmstate"),
79
+ mem: join(dir, "mem"),
80
+ /** Copy of the box's overlay at the moment of the snapshot; the base image is referenced by version in meta.json. */
81
+ overlay: join(dir, "overlay.ext4")
82
+ };
83
+ }
75
84
  };
76
85
  }
77
86
 
@@ -91,6 +100,36 @@ function isSizeName(value) {
91
100
  return value in SIZES;
92
101
  }
93
102
 
103
+ // src/core/refs.ts
104
+ import { userInfo } from "os";
105
+ function shown(ref) {
106
+ return ref.display ?? ref.name;
107
+ }
108
+ var LOCAL_OWNER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
109
+ var OWNER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}(@[A-Za-z0-9.-]{1,128})?$/;
110
+ function localOwner() {
111
+ try {
112
+ const name = userInfo().username;
113
+ if (LOCAL_OWNER_RE.test(name)) return name;
114
+ } catch {
115
+ }
116
+ const env = process.env["USER"];
117
+ return env !== void 0 && LOCAL_OWNER_RE.test(env) ? env : "kvbox";
118
+ }
119
+ function parseRef(text, caller) {
120
+ const slash = text.indexOf("/");
121
+ if (slash === -1) return { owner: caller, name: text, display: text };
122
+ const owner = text.slice(0, slash);
123
+ const name = text.slice(slash + 1);
124
+ if (!OWNER_RE.test(owner) || name.length === 0 || name.includes("/")) {
125
+ throw new KvboxError(`invalid box reference "${text}": use <name> or <owner>/<name>`);
126
+ }
127
+ return { owner, name, display: owner === caller ? name : text };
128
+ }
129
+ function refKey(ref) {
130
+ return `${ref.owner}/${ref.name}`;
131
+ }
132
+
94
133
  // src/core/config.ts
95
134
  var ConfigSchema = z.object({
96
135
  kernel: z.string(),
@@ -101,14 +140,17 @@ var ConfigSchema = z.object({
101
140
  bridge: z.string().default("kvbox0"),
102
141
  subnet: z.string().default("172.30.0.0/24"),
103
142
  gateway: z.string().default("172.30.0.1"),
104
- defaultSize: z.enum(SIZE_NAMES).default(DEFAULT_SIZE)
143
+ defaultSize: z.enum(SIZE_NAMES).default(DEFAULT_SIZE),
144
+ /** Who boxes made over the local socket belong to: the Unix user running the server unless set here. No `@`, no `/`. */
145
+ owner: z.string().regex(LOCAL_OWNER_RE, "owner: letters, digits, '.', '_', '-'; no '@' or '/'").default(localOwner)
105
146
  });
106
147
  function defaultConfig(p = paths()) {
107
148
  return ConfigSchema.parse({
108
149
  kernel: p.kernel,
109
150
  rootfs: p.rootfs,
110
151
  firecracker: p.firecracker,
111
- snapshotEditor: p.snapshotEditor
152
+ snapshotEditor: p.snapshotEditor,
153
+ owner: localOwner()
112
154
  });
113
155
  }
114
156
  async function loadConfig(p = paths()) {
@@ -219,7 +261,7 @@ function macFor(ip) {
219
261
  }
220
262
 
221
263
  // src/listen.ts
222
- import { rm as rm3, writeFile as writeFile4 } from "fs/promises";
264
+ import { rm as rm3, writeFile as writeFile5 } from "fs/promises";
223
265
  import { createAdaptorServer } from "@hono/node-server";
224
266
 
225
267
  // src/core/state.ts
@@ -227,6 +269,8 @@ import { mkdir, readFile as readFile2, readdir, writeFile as writeFile2 } from "
227
269
  import { z as z2 } from "zod";
228
270
  var MachineMetaSchema = z2.object({
229
271
  name: z2.string(),
272
+ /** Part of the box's identity: machines/<owner>/<name>. See refs.ts. */
273
+ owner: z2.string(),
230
274
  ip: z2.string(),
231
275
  mac: z2.string(),
232
276
  pid: z2.number().int().nullable(),
@@ -246,6 +290,8 @@ var MachineMetaSchema = z2.object({
246
290
  });
247
291
  var SnapshotMetaSchema = z2.object({
248
292
  name: z2.string(),
293
+ owner: z2.string(),
294
+ /** The box it was taken from, as owner/name. */
249
295
  source: z2.string(),
250
296
  base: z2.string(),
251
297
  vcpu: z2.number().int(),
@@ -271,25 +317,36 @@ async function listDirs(dir) {
271
317
  throw err;
272
318
  }
273
319
  }
274
- async function readMachineMeta(name, p = paths()) {
275
- return readJson(p.machine(name).meta, MachineMetaSchema);
320
+ async function readMachineMeta(ref, p = paths()) {
321
+ return readJson(p.machine(ref.owner, ref.name).meta, MachineMetaSchema);
276
322
  }
277
323
  async function writeMachineMeta(meta, p = paths()) {
278
- await mkdir(p.machine(meta.name).dir, { recursive: true });
279
- await writeFile2(p.machine(meta.name).meta, JSON.stringify(meta, null, 2) + "\n");
324
+ const m = p.machine(meta.owner, meta.name);
325
+ await mkdir(m.dir, { recursive: true });
326
+ await writeFile2(m.meta, JSON.stringify(meta, null, 2) + "\n");
280
327
  }
281
- async function listMachineNames(p = paths()) {
282
- return listDirs(p.machines);
328
+ async function listMachineRefs(p = paths()) {
329
+ return listRefs(p.machines);
283
330
  }
284
- async function readSnapshotMeta(name, p = paths()) {
285
- return readJson(p.snapshot(name).meta, SnapshotMetaSchema);
331
+ async function readSnapshotMeta(ref, p = paths()) {
332
+ return readJson(p.snapshot(ref.owner, ref.name).meta, SnapshotMetaSchema);
286
333
  }
287
334
  async function writeSnapshotMeta(meta, p = paths()) {
288
- await mkdir(p.snapshot(meta.name).dir, { recursive: true });
289
- await writeFile2(p.snapshot(meta.name).meta, JSON.stringify(meta, null, 2) + "\n");
335
+ const s = p.snapshot(meta.owner, meta.name);
336
+ await mkdir(s.dir, { recursive: true });
337
+ await writeFile2(s.meta, JSON.stringify(meta, null, 2) + "\n");
338
+ }
339
+ async function listSnapshotRefs(p = paths()) {
340
+ return listRefs(p.snapshots);
290
341
  }
291
- async function listSnapshotNames(p = paths()) {
292
- return listDirs(p.snapshots);
342
+ async function listRefs(root) {
343
+ const out = [];
344
+ for (const owner of await listDirs(root)) {
345
+ for (const name of await listDirs(`${root}/${owner}`)) {
346
+ out.push({ owner, name });
347
+ }
348
+ }
349
+ return out;
293
350
  }
294
351
  function pidAlive(pid) {
295
352
  if (pid === null || pid <= 0) return false;
@@ -301,6 +358,62 @@ function pidAlive(pid) {
301
358
  }
302
359
  }
303
360
 
361
+ // src/core/migrate.ts
362
+ import { existsSync as existsSync2 } from "fs";
363
+ import { mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rename, writeFile as writeFile3 } from "fs/promises";
364
+ import { join as join2 } from "path";
365
+ async function migrateOwnerLayout(owner, p = paths()) {
366
+ const done = { boxes: [], snapshots: [], conflicts: [] };
367
+ for (const name of await oldDepthEntries(p.machines)) {
368
+ const from = join2(p.machines, name);
369
+ const to = p.machine(owner, name).dir;
370
+ if (existsSync2(to)) {
371
+ done.conflicts.push(`machines/${name}`);
372
+ continue;
373
+ }
374
+ await patchJson(join2(from, "meta.json"), (meta) => ({
375
+ ...meta,
376
+ owner,
377
+ // a restored box names its snapshot: that moved under the same owner
378
+ source: typeof meta["source"] === "string" && meta["source"].startsWith("snapshot:") && !meta["source"].includes("/") ? `snapshot:${owner}/${meta["source"].slice("snapshot:".length)}` : meta["source"]
379
+ }));
380
+ await mkdir2(p.ownerMachines(owner), { recursive: true, mode: 448 });
381
+ await rename(from, to);
382
+ done.boxes.push(name);
383
+ }
384
+ for (const name of await oldDepthEntries(p.snapshots)) {
385
+ const from = join2(p.snapshots, name);
386
+ const to = p.snapshot(owner, name).dir;
387
+ if (existsSync2(to)) {
388
+ done.conflicts.push(`snapshots/${name}`);
389
+ continue;
390
+ }
391
+ await patchJson(join2(from, "meta.json"), (meta) => ({
392
+ ...meta,
393
+ owner,
394
+ source: typeof meta["source"] === "string" && !meta["source"].includes("/") ? `${owner}/${meta["source"]}` : meta["source"]
395
+ }));
396
+ await mkdir2(p.ownerSnapshots(owner), { recursive: true, mode: 448 });
397
+ await rename(from, to);
398
+ done.snapshots.push(name);
399
+ }
400
+ return done;
401
+ }
402
+ async function oldDepthEntries(root) {
403
+ let entries;
404
+ try {
405
+ entries = await readdir2(root, { withFileTypes: true });
406
+ } catch (err) {
407
+ if (err.code === "ENOENT") return [];
408
+ throw err;
409
+ }
410
+ return entries.filter((e) => e.isDirectory() && existsSync2(join2(root, e.name, "meta.json"))).map((e) => e.name).sort();
411
+ }
412
+ async function patchJson(file, patch) {
413
+ const raw = JSON.parse(await readFile3(file, "utf8"));
414
+ await writeFile3(file, JSON.stringify(patch(raw), null, 2) + "\n");
415
+ }
416
+
304
417
  // src/app.ts
305
418
  import { Hono as Hono4 } from "hono";
306
419
 
@@ -317,12 +430,21 @@ function versionOf(srcFileUrl, injected) {
317
430
  }
318
431
 
319
432
  // src/version.ts
320
- var VERSION = versionOf(import.meta.url, true ? "0.0.15" : void 0);
433
+ var VERSION = versionOf(import.meta.url, true ? "0.0.16" : void 0);
434
+
435
+ // src/caller.ts
436
+ function resolveCaller() {
437
+ return async (c, next) => {
438
+ const cfg = await loadConfig();
439
+ c.set("owner", cfg.owner);
440
+ await next();
441
+ };
442
+ }
321
443
 
322
444
  // src/core/machines.ts
323
445
  import { spawn } from "child_process";
324
- import { closeSync, existsSync as existsSync2, openSync, statSync } from "fs";
325
- import { link, mkdir as mkdir2, readFile as readFile3, realpath, rm, truncate, writeFile as writeFile3 } from "fs/promises";
446
+ import { closeSync, existsSync as existsSync3, openSync, statSync } from "fs";
447
+ import { link, mkdir as mkdir3, readFile as readFile4, realpath, rm, truncate, writeFile as writeFile4 } from "fs/promises";
326
448
  import { basename } from "path";
327
449
  import { setTimeout as sleep } from "timers/promises";
328
450
 
@@ -385,9 +507,9 @@ function hostResources() {
385
507
  }
386
508
  async function runningReservations(p = paths()) {
387
509
  const out = [];
388
- for (const name of await listMachineNames(p)) {
510
+ for (const ref of await listMachineRefs(p)) {
389
511
  try {
390
- const meta = await readMachineMeta(name, p);
512
+ const meta = await readMachineMeta(ref, p);
391
513
  if (pidAlive(meta.pid)) out.push({ name: meta.name, vcpu: meta.vcpu, memMib: meta.memMib });
392
514
  } catch {
393
515
  }
@@ -570,13 +692,14 @@ function diskStats(disk) {
570
692
  }
571
693
  }
572
694
  function ownDisk(meta, p) {
573
- const m = p.machine(meta.name);
695
+ const m = p.machine(meta.owner, meta.name);
574
696
  return meta.base === void 0 ? m.disk : m.overlay;
575
697
  }
576
698
  function toMachine(meta, p = paths()) {
577
699
  const alive = pidAlive(meta.pid);
578
700
  return {
579
701
  name: meta.name,
702
+ owner: meta.owner,
580
703
  ip: meta.ip,
581
704
  mac: meta.mac,
582
705
  pid: alive ? meta.pid : null,
@@ -591,17 +714,17 @@ function toMachine(meta, p = paths()) {
591
714
  }
592
715
  async function readAllMeta(p) {
593
716
  const metas = [];
594
- for (const name of await listMachineNames(p)) {
717
+ for (const ref of await listMachineRefs(p)) {
595
718
  try {
596
- metas.push(await readMachineMeta(name, p));
719
+ metas.push(await readMachineMeta(ref, p));
597
720
  } catch (err) {
598
- console.error(`warning: skipping machines/${name}: ${err.message}`);
721
+ console.error(`warning: skipping machines/${refKey(ref)}: ${err.message}`);
599
722
  }
600
723
  }
601
724
  return metas;
602
725
  }
603
- async function listMachines(p = paths()) {
604
- return (await readAllMeta(p)).map((m) => toMachine(m, p));
726
+ async function listMachines(owner, p = paths()) {
727
+ return (await readAllMeta(p)).filter((m) => owner === null || m.owner === owner).map((m) => toMachine(m, p));
605
728
  }
606
729
  function pickName(requested, taken) {
607
730
  if (requested !== void 0) {
@@ -636,7 +759,7 @@ async function currentBase(cfg, p) {
636
759
  function baseFileFor(meta, p) {
637
760
  if (meta.base === void 0) return void 0;
638
761
  const file = p.baseImage(meta.base);
639
- if (!existsSync2(file)) throw new KvboxError(`box ${meta.name} runs on base image ${meta.base}, which is gone from ${p.assets}`);
762
+ if (!existsSync3(file)) throw new KvboxError(`box ${meta.name} runs on base image ${meta.base}, which is gone from ${p.assets}`);
640
763
  return file;
641
764
  }
642
765
  var overlayCapable = /* @__PURE__ */ new Map();
@@ -650,7 +773,7 @@ async function assertOverlayCapable(baseFile, version) {
650
773
  if (!ok) throw new KvboxError(`base image ${version} is too old for this server (no /sbin/overlay-init); rerun install-server.sh to get a newer one`);
651
774
  }
652
775
  async function createOverlay(file, gib) {
653
- await writeFile3(file, "", { mode: 384 });
776
+ await writeFile4(file, "", { mode: 384 });
654
777
  await truncate(file, gib * 1024 ** 3);
655
778
  await run(["mkfs.ext4", "-q", "-F", "-m", "0", "-L", "kvbox-box", "-E", "lazy_itable_init=1,lazy_journal_init=1", file]);
656
779
  }
@@ -664,19 +787,19 @@ async function waitFor(pred, timeoutMs, stepMs = 50) {
664
787
  }
665
788
  async function tailOf(file, lines = 5) {
666
789
  try {
667
- return (await readFile3(file, "utf8")).trim().split("\n").slice(-lines).join("\n");
790
+ return (await readFile4(file, "utf8")).trim().split("\n").slice(-lines).join("\n");
668
791
  } catch {
669
792
  return "";
670
793
  }
671
794
  }
672
- async function spawnFirecracker(name, cfg, p) {
673
- const m = p.machine(name);
795
+ async function spawnFirecracker(ref, cfg, p) {
796
+ const m = p.machine(ref.owner, ref.name);
674
797
  await rm(m.sock, { force: true });
675
798
  await rm(m.vsock, { force: true });
676
799
  const logFd = openSync(m.log, "a");
677
800
  let child;
678
801
  try {
679
- child = spawn(cfg.firecracker, ["--api-sock", m.sock, "--id", name, "--level", "Error"], {
802
+ child = spawn(cfg.firecracker, ["--api-sock", m.sock, "--id", ref.name, "--level", "Error"], {
680
803
  cwd: m.dir,
681
804
  detached: true,
682
805
  stdio: ["ignore", logFd, logFd]
@@ -687,7 +810,7 @@ async function spawnFirecracker(name, cfg, p) {
687
810
  child.unref();
688
811
  const pid = child.pid;
689
812
  if (pid === void 0) throw new KvboxError("could not start firecracker");
690
- if (!await waitFor(() => existsSync2(m.sock), 3e3)) {
813
+ if (!await waitFor(() => existsSync3(m.sock), 3e3)) {
691
814
  killQuietly(pid);
692
815
  throw new KvboxError(`firecracker did not open its API socket; last log lines:
693
816
  ${await tailOf(m.log)}`);
@@ -701,9 +824,9 @@ function killQuietly(pid) {
701
824
  }
702
825
  }
703
826
  async function launch(spec, tap, cfg, pubkeyB64, p) {
704
- const m = p.machine(spec.name);
827
+ const m = p.machine(spec.owner, spec.name);
705
828
  const baseFile = baseFileFor(spec, p);
706
- const pid = await spawnFirecracker(spec.name, cfg, p);
829
+ const pid = await spawnFirecracker(spec, cfg, p);
707
830
  try {
708
831
  const fc = new FirecrackerClient(m.sock);
709
832
  try {
@@ -727,10 +850,10 @@ async function launch(spec, tap, cfg, pubkeyB64, p) {
727
850
  throw err;
728
851
  }
729
852
  }
730
- async function loadSnapshot(spec, snapshotName, tap, cfg, p) {
731
- const m = p.machine(spec.name);
732
- const s = p.snapshot(snapshotName);
733
- const pid = await spawnFirecracker(spec.name, cfg, p);
853
+ async function loadSnapshot(spec, snapshot, tap, cfg, p) {
854
+ const m = p.machine(spec.owner, spec.name);
855
+ const s = p.snapshot(snapshot.owner, snapshot.name);
856
+ const pid = await spawnFirecracker(spec, cfg, p);
734
857
  try {
735
858
  const fc = new FirecrackerClient(m.sock);
736
859
  try {
@@ -744,7 +867,7 @@ async function loadSnapshot(spec, snapshotName, tap, cfg, p) {
744
867
  resume: false
745
868
  });
746
869
  } catch (err) {
747
- if (/Unknown Vsock Device/.test(err.message)) throw new KvboxError(tooOldToRestore(snapshotName));
870
+ if (/Unknown Vsock Device/.test(err.message)) throw new KvboxError(tooOldToRestore(shown(snapshot)));
748
871
  throw err;
749
872
  }
750
873
  if (await namesOverlayAbsolutely(s.vmstate)) await fc.patchDrivePath("overlay", m.overlay);
@@ -764,23 +887,23 @@ async function currentBaseVersion(p = paths()) {
764
887
  return (await currentBase(await loadConfig(p), p)).version;
765
888
  }
766
889
  async function namesOverlayAbsolutely(vmstate) {
767
- return (await readFile3(vmstate)).includes(`/${OVERLAY_REL}`);
890
+ return (await readFile4(vmstate)).includes(`/${OVERLAY_REL}`);
768
891
  }
769
892
  async function readPubkeyB64(p) {
770
893
  try {
771
- return Buffer.from((await readFile3(`${p.sshKey}.pub`, "utf8")).trim()).toString("base64");
894
+ return Buffer.from((await readFile4(`${p.sshKey}.pub`, "utf8")).trim()).toString("base64");
772
895
  } catch {
773
896
  throw new KvboxError(`SSH key missing at ${p.sshKey}.pub; run install-server.sh`);
774
897
  }
775
898
  }
776
899
  function assertAssets(cfg) {
777
900
  for (const [what, file] of [["kernel", cfg.kernel], ["base image", cfg.rootfs], ["firecracker", cfg.firecracker]]) {
778
- if (!existsSync2(file)) throw new KvboxError(`${what} missing at ${file}; run install-server.sh`);
901
+ if (!existsSync3(file)) throw new KvboxError(`${what} missing at ${file}; run install-server.sh`);
779
902
  }
780
903
  }
781
- async function createMachine(req, p = paths()) {
904
+ async function createMachine(req, owner, p = paths()) {
782
905
  const cfg = await loadConfig(p);
783
- if (req.from !== void 0) return restoreMachine(req.from, req, cfg, p);
906
+ if (req.from !== void 0) return restoreMachine(req.from, owner, req, cfg, p);
784
907
  const sizeName = req.size ?? cfg.defaultSize;
785
908
  if (!isSizeName(sizeName)) throw new KvboxError(`unknown size "${sizeName}"; use one of ${Object.keys(SIZES).join(", ")}`);
786
909
  const size = SIZES[sizeName];
@@ -788,20 +911,20 @@ async function createMachine(req, p = paths()) {
788
911
  await assertRoomFor(size.vcpu, size.memMib, req.force ?? false, p);
789
912
  const pubkeyB64 = await readPubkeyB64(p);
790
913
  const existing = await readAllMeta(p);
791
- const name = pickName(req.name, new Set(existing.map((m2) => m2.name)));
914
+ const name = pickName(req.name, new Set(existing.filter((m2) => m2.owner === owner).map((m2) => m2.name)));
792
915
  const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
793
916
  const mac = macFor(ip);
794
- const m = p.machine(name);
917
+ const m = p.machine(owner, name);
795
918
  const base = await currentBase(cfg, p);
796
919
  await assertOverlayCapable(base.file, base.version);
797
920
  await ensureHostNetwork(cfg);
798
- await mkdir2(m.dir, { recursive: true, mode: 448 });
921
+ await mkdir3(m.dir, { recursive: true, mode: 448 });
799
922
  let tapCreated = false;
800
923
  try {
801
924
  await createOverlay(m.overlay, size.diskGib);
802
925
  const tap = await createTap(name, cfg);
803
926
  tapCreated = true;
804
- const spec = { name, ip, mac, vcpu: size.vcpu, memMib: size.memMib, base: base.version };
927
+ const spec = { name, owner, ip, mac, vcpu: size.vcpu, memMib: size.memMib, base: base.version };
805
928
  const pid = await launch(spec, tap, cfg, pubkeyB64, p);
806
929
  const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: "base" };
807
930
  await writeMachineMeta(meta, p);
@@ -812,35 +935,36 @@ async function createMachine(req, p = paths()) {
812
935
  throw err;
813
936
  }
814
937
  }
815
- async function readSnap(name, p) {
938
+ async function readSnap(ref, p) {
816
939
  try {
817
- return await readSnapshotMeta(name, p);
940
+ return await readSnapshotMeta(ref, p);
818
941
  } catch (err) {
819
- if (err.code === "ENOENT") throw new NotFoundError(`snapshot ${name}`);
942
+ if (err.code === "ENOENT") throw new NotFoundError(`snapshot ${shown(ref)}`);
820
943
  throw err;
821
944
  }
822
945
  }
823
946
  function prefixLenOf(subnet) {
824
947
  return Number(subnet.split("/")[1]);
825
948
  }
826
- async function restoreMachine(from, req, cfg, p) {
949
+ async function restoreMachine(fromText, owner, req, cfg, p) {
950
+ const from = parseRef(fromText, owner);
827
951
  const snap = await readSnap(from, p);
828
952
  if (req.size !== void 0) {
829
- throw new KvboxError(`--size cannot be combined with --from: a restored box has the snapshot's size (${from}: ${snap.vcpu} vCPU, ${snap.memMib} MiB)`);
953
+ throw new KvboxError(`--size cannot be combined with --from: a restored box has the snapshot's size (${shown(from)}: ${snap.vcpu} vCPU, ${snap.memMib} MiB)`);
830
954
  }
831
955
  assertAssets(cfg);
832
- if (!existsSync2(p.baseImage(snap.base))) {
833
- throw new KvboxError(`snapshot ${from} stacks on base image ${snap.base}, which is gone from ${p.assets}`);
956
+ if (!existsSync3(p.baseImage(snap.base))) {
957
+ throw new KvboxError(`snapshot ${shown(from)} stacks on base image ${snap.base}, which is gone from ${p.assets}`);
834
958
  }
835
959
  await assertRoomFor(snap.vcpu, snap.memMib, req.force ?? false, p);
836
960
  const existing = await readAllMeta(p);
837
- const name = pickName(req.name, new Set(existing.map((m2) => m2.name)));
961
+ const name = pickName(req.name, new Set(existing.filter((m2) => m2.owner === owner).map((m2) => m2.name)));
838
962
  const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
839
963
  const mac = macFor(ip);
840
- const m = p.machine(name);
841
- const s = p.snapshot(from);
964
+ const m = p.machine(owner, name);
965
+ const s = p.snapshot(from.owner, from.name);
842
966
  await ensureHostNetwork(cfg);
843
- await mkdir2(m.dir, { recursive: true, mode: 448 });
967
+ await mkdir3(m.dir, { recursive: true, mode: 448 });
844
968
  let tapCreated = false;
845
969
  let pid;
846
970
  try {
@@ -848,7 +972,7 @@ async function restoreMachine(from, req, cfg, p) {
848
972
  await link(s.mem, m.memBase);
849
973
  const tap = await createTap(name, cfg, { detached: true });
850
974
  tapCreated = true;
851
- const spec = { name, ip, mac, vcpu: snap.vcpu, memMib: snap.memMib, base: snap.base };
975
+ const spec = { name, owner, ip, mac, vcpu: snap.vcpu, memMib: snap.memMib, base: snap.base };
852
976
  pid = await loadSnapshot(spec, from, tap, cfg, p);
853
977
  const fc = new FirecrackerClient(m.sock);
854
978
  try {
@@ -856,9 +980,9 @@ async function restoreMachine(from, req, cfg, p) {
856
980
  } finally {
857
981
  await fc.close();
858
982
  }
859
- await introduce(spec, m.vsock, cfg, from);
983
+ await introduce(spec, m.vsock, cfg, shown(from));
860
984
  await attachTap(name, cfg);
861
- const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: `snapshot:${from}`, memSnapshots: 1 };
985
+ const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: `snapshot:${refKey(from)}`, memSnapshots: 1 };
862
986
  await writeMachineMeta(meta, p);
863
987
  return toMachine(meta, p);
864
988
  } catch (err) {
@@ -893,7 +1017,7 @@ async function introduce(spec, vsock, cfg, from) {
893
1017
  async function shutdown(meta, cfg, p, graceMs) {
894
1018
  const pid = meta.pid;
895
1019
  if (pidAlive(pid)) {
896
- const fc = new FirecrackerClient(p.machine(meta.name).sock);
1020
+ const fc = new FirecrackerClient(p.machine(meta.owner, meta.name).sock);
897
1021
  try {
898
1022
  await fc.sendCtrlAltDel();
899
1023
  } catch {
@@ -910,34 +1034,35 @@ async function shutdown(meta, cfg, p, graceMs) {
910
1034
  }
911
1035
  }
912
1036
  await deleteTap(meta.name, cfg);
913
- await rm(p.machine(meta.name).sock, { force: true });
914
- await rm(p.machine(meta.name).vsock, { force: true });
1037
+ const m = p.machine(meta.owner, meta.name);
1038
+ await rm(m.sock, { force: true });
1039
+ await rm(m.vsock, { force: true });
915
1040
  }
916
- async function stopMachine(name, p = paths()) {
917
- const meta = await readMeta(name, p);
1041
+ async function stopMachine(ref, p = paths()) {
1042
+ const meta = await readMeta(ref, p);
918
1043
  const cfg = await loadConfig(p);
919
- if (!pidAlive(meta.pid)) throw new KvboxError(`box ${name} is already stopped`);
1044
+ if (!pidAlive(meta.pid)) throw new KvboxError(`box ${shown(ref)} is already stopped`);
920
1045
  await shutdown(meta, cfg, p, 1e4);
921
- await rm(p.machine(name).memBase, { force: true });
1046
+ await rm(p.machine(ref.owner, ref.name).memBase, { force: true });
922
1047
  const stopped = { ...meta, pid: null, memSnapshots: void 0 };
923
1048
  await writeMachineMeta(stopped, p);
924
1049
  return toMachine(stopped, p);
925
1050
  }
926
- async function startMachine(name, p = paths()) {
927
- const meta = await readMeta(name, p);
1051
+ async function startMachine(ref, p = paths()) {
1052
+ const meta = await readMeta(ref, p);
928
1053
  const cfg = await loadConfig(p);
929
- if (pidAlive(meta.pid)) throw new KvboxError(`box ${name} is already running`);
1054
+ if (pidAlive(meta.pid)) throw new KvboxError(`box ${shown(ref)} is already running`);
930
1055
  assertAssets(cfg);
931
1056
  const pubkeyB64 = await readPubkeyB64(p);
932
1057
  await ensureHostNetwork(cfg);
933
- const tap = await createTap(name, cfg);
1058
+ const tap = await createTap(ref.name, cfg);
934
1059
  try {
935
1060
  const pid = await launch(meta, tap, cfg, pubkeyB64, p);
936
1061
  const started = { ...meta, pid };
937
1062
  await writeMachineMeta(started, p);
938
1063
  return toMachine(started, p);
939
1064
  } catch (err) {
940
- await deleteTap(name, cfg).catch(() => void 0);
1065
+ await deleteTap(ref.name, cfg).catch(() => void 0);
941
1066
  throw err;
942
1067
  }
943
1068
  }
@@ -945,18 +1070,18 @@ async function applyAtNextBoot(meta, changes, cfg, p, whileStopped) {
945
1070
  const wasRunning = pidAlive(meta.pid);
946
1071
  if (wasRunning) {
947
1072
  await shutdown(meta, cfg, p, 1e4);
948
- await rm(p.machine(meta.name).memBase, { force: true });
1073
+ await rm(p.machine(meta.owner, meta.name).memBase, { force: true });
949
1074
  }
950
1075
  const updated = { ...meta, ...changes, pid: null, memSnapshots: void 0 };
951
1076
  await writeMachineMeta(updated, p);
952
1077
  if (whileStopped) await whileStopped();
953
- return wasRunning ? startMachine(meta.name, p) : toMachine(updated, p);
1078
+ return wasRunning ? startMachine(meta, p) : toMachine(updated, p);
954
1079
  }
955
- async function updateMachine(name, p = paths()) {
956
- const meta = await readMeta(name, p);
1080
+ async function updateMachine(ref, p = paths()) {
1081
+ const meta = await readMeta(ref, p);
957
1082
  const cfg = await loadConfig(p);
958
1083
  if (meta.base === void 0) {
959
- throw new KvboxError(`box ${name} was made before overlays and runs on its own disk copy; it cannot be moved to another base image`);
1084
+ 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`);
960
1085
  }
961
1086
  assertAssets(cfg);
962
1087
  const base = await currentBase(cfg, p);
@@ -964,8 +1089,8 @@ async function updateMachine(name, p = paths()) {
964
1089
  await assertOverlayCapable(base.file, base.version);
965
1090
  return applyAtNextBoot(meta, { base: base.version }, cfg, p);
966
1091
  }
967
- async function rescaleMachine(name, req, p = paths()) {
968
- const meta = await readMeta(name, p);
1092
+ async function rescaleMachine(ref, req, p = paths()) {
1093
+ const meta = await readMeta(ref, p);
969
1094
  const cfg = await loadConfig(p);
970
1095
  const disk = ownDisk(meta, p);
971
1096
  const currentDiskGib = Math.round(statSync(disk).size / 1024 ** 3);
@@ -976,7 +1101,7 @@ async function rescaleMachine(name, req, p = paths()) {
976
1101
  diskGib: req.diskGib ?? preset?.diskGib ?? currentDiskGib
977
1102
  };
978
1103
  if (target.diskGib < currentDiskGib) {
979
- throw new KvboxError(`box ${name} has a ${currentDiskGib} GB disk and a disk cannot shrink; ${target.diskGib} GB is smaller`);
1104
+ throw new KvboxError(`box ${shown(ref)} has a ${currentDiskGib} GB disk and a disk cannot shrink; ${target.diskGib} GB is smaller`);
980
1105
  }
981
1106
  const changes = {};
982
1107
  if (target.vcpu !== meta.vcpu) changes.vcpu = target.vcpu;
@@ -990,19 +1115,19 @@ async function growDisk(file, gib) {
990
1115
  await run(["e2fsck", "-f", "-p", file], { okExitCodes: [1] });
991
1116
  await run(["resize2fs", file]);
992
1117
  }
993
- async function readMeta(name, p) {
1118
+ async function readMeta(ref, p) {
994
1119
  try {
995
- return await readMachineMeta(name, p);
1120
+ return await readMachineMeta(ref, p);
996
1121
  } catch (err) {
997
- if (err.code === "ENOENT") throw new NotFoundError(`box ${name}`);
1122
+ if (err.code === "ENOENT") throw new NotFoundError(`box ${shown(ref)}`);
998
1123
  throw err;
999
1124
  }
1000
1125
  }
1001
- async function destroyMachine(name, p = paths()) {
1002
- const meta = await readMeta(name, p);
1126
+ async function destroyMachine(ref, p = paths()) {
1127
+ const meta = await readMeta(ref, p);
1003
1128
  const cfg = await loadConfig(p);
1004
1129
  await shutdown(meta, cfg, p, 2e3);
1005
- await rm(p.machine(name).dir, { recursive: true, force: true });
1130
+ await rm(p.machine(ref.owner, ref.name).dir, { recursive: true, force: true });
1006
1131
  }
1007
1132
 
1008
1133
  // src/routes/capacity.ts
@@ -1021,6 +1146,8 @@ import { z as z3 } from "zod";
1021
1146
  var SizeSchema = z3.enum(SIZE_NAMES);
1022
1147
  var MachineSchema = z3.object({
1023
1148
  name: z3.string(),
1149
+ /** Who the box belongs to; names are unique per owner. A bare name is local to the host, `user@control-plane` later. */
1150
+ owner: z3.string(),
1024
1151
  ip: z3.string(),
1025
1152
  mac: z3.string(),
1026
1153
  pid: z3.number().int().nullable(),
@@ -1039,6 +1166,8 @@ var MachineSchema = z3.object({
1039
1166
  });
1040
1167
  var CreateMachineRequest = z3.object({
1041
1168
  name: z3.string().optional(),
1169
+ /** Ignored over the local socket (the caller is the host owner); the control plane sets it over HTTPS. */
1170
+ owner: z3.string().optional(),
1042
1171
  size: SizeSchema.optional(),
1043
1172
  from: z3.string().optional(),
1044
1173
  force: z3.boolean().optional()
@@ -1051,8 +1180,9 @@ var RescaleMachineRequest = z3.object({
1051
1180
  });
1052
1181
  var SnapshotSchema = z3.object({
1053
1182
  name: z3.string(),
1183
+ owner: z3.string(),
1054
1184
  source: z3.string(),
1055
- // the box it was taken from
1185
+ // the box it was taken from, as owner/name
1056
1186
  base: z3.string(),
1057
1187
  // base image version the overlay stacks on; a restore needs the same one
1058
1188
  vcpu: z3.number().int(),
@@ -1077,7 +1207,9 @@ var HealthSchema = z3.object({
1077
1207
  version: z3.string(),
1078
1208
  pid: z3.number().int(),
1079
1209
  /** Base image version new boxes get (null when the host has no usable base image yet); absent from older servers. */
1080
- base: z3.string().nullable().optional()
1210
+ base: z3.string().nullable().optional(),
1211
+ /** Owner that boxes made over the local socket get (the Unix user running the server); absent from older servers. */
1212
+ owner: z3.string().optional()
1081
1213
  });
1082
1214
  var ApiErrorSchema = z3.object({
1083
1215
  error: z3.string()
@@ -1086,20 +1218,20 @@ var ApiErrorSchema = z3.object({
1086
1218
  // src/routes/machines.ts
1087
1219
  function machineRoutes() {
1088
1220
  const r = new Hono2();
1089
- r.get("/", async (c) => c.json(await listMachines()));
1221
+ r.get("/", async (c) => c.json(await listMachines(c.get("owner"))));
1090
1222
  r.post("/", async (c) => {
1091
1223
  const body = CreateMachineRequest.parse(await c.req.json().catch(() => ({})));
1092
- return c.json(await createMachine(body), 201);
1224
+ return c.json(await createMachine(body, c.get("owner")), 201);
1093
1225
  });
1094
- r.post("/:name/stop", async (c) => c.json(await stopMachine(c.req.param("name"))));
1095
- r.post("/:name/start", async (c) => c.json(await startMachine(c.req.param("name"))));
1096
- r.post("/:name/update", async (c) => c.json(await updateMachine(c.req.param("name"))));
1226
+ r.post("/:name/stop", async (c) => c.json(await stopMachine(parseRef(c.req.param("name"), c.get("owner")))));
1227
+ r.post("/:name/start", async (c) => c.json(await startMachine(parseRef(c.req.param("name"), c.get("owner")))));
1228
+ r.post("/:name/update", async (c) => c.json(await updateMachine(parseRef(c.req.param("name"), c.get("owner")))));
1097
1229
  r.post("/:name/rescale", async (c) => {
1098
1230
  const body = RescaleMachineRequest.parse(await c.req.json().catch(() => ({})));
1099
- return c.json(await rescaleMachine(c.req.param("name"), body));
1231
+ return c.json(await rescaleMachine(parseRef(c.req.param("name"), c.get("owner")), body));
1100
1232
  });
1101
1233
  r.delete("/:name", async (c) => {
1102
- await destroyMachine(c.req.param("name"));
1234
+ await destroyMachine(parseRef(c.req.param("name"), c.get("owner")));
1103
1235
  return c.body(null, 204);
1104
1236
  });
1105
1237
  return r;
@@ -1109,12 +1241,13 @@ function machineRoutes() {
1109
1241
  import { Hono as Hono3 } from "hono";
1110
1242
 
1111
1243
  // src/core/snapshots.ts
1112
- import { existsSync as existsSync3 } from "fs";
1113
- import { link as link2, mkdir as mkdir3, readdir as readdir2, rename, rm as rm2, stat } from "fs/promises";
1244
+ import { existsSync as existsSync4 } from "fs";
1245
+ import { link as link2, mkdir as mkdir4, readdir as readdir3, rename as rename2, rm as rm2, stat } from "fs/promises";
1114
1246
  var NAME_RE2 = /^[a-z0-9][a-z0-9._-]{0,63}$/;
1115
1247
  function toSnapshot(meta) {
1116
1248
  return {
1117
1249
  name: meta.name,
1250
+ owner: meta.owner,
1118
1251
  source: meta.source,
1119
1252
  base: meta.base,
1120
1253
  vcpu: meta.vcpu,
@@ -1123,54 +1256,55 @@ function toSnapshot(meta) {
1123
1256
  createdAt: meta.createdAt
1124
1257
  };
1125
1258
  }
1126
- async function listSnapshots(p = paths()) {
1259
+ async function listSnapshots(owner, p = paths()) {
1127
1260
  const out = [];
1128
- for (const name of await listSnapshotNames(p)) {
1261
+ for (const ref of await listSnapshotRefs(p)) {
1262
+ if (owner !== null && ref.owner !== owner) continue;
1129
1263
  try {
1130
- out.push(toSnapshot(await readSnapshotMeta(name, p)));
1264
+ out.push(toSnapshot(await readSnapshotMeta(ref, p)));
1131
1265
  } catch (err) {
1132
- console.error(`warning: skipping snapshots/${name}: ${err.message}`);
1266
+ console.error(`warning: skipping snapshots/${refKey(ref)}: ${err.message}`);
1133
1267
  }
1134
1268
  }
1135
1269
  return out;
1136
1270
  }
1137
1271
  async function usedBytes(dir) {
1138
1272
  let total = 0;
1139
- for (const entry of await readdir2(dir)) {
1273
+ for (const entry of await readdir3(dir)) {
1140
1274
  total += (await stat(`${dir}/${entry}`)).blocks * 512;
1141
1275
  }
1142
1276
  return total;
1143
1277
  }
1144
- async function readBox(name, p) {
1278
+ async function readBox(ref, p) {
1145
1279
  try {
1146
- return await readMachineMeta(name, p);
1280
+ return await readMachineMeta(ref, p);
1147
1281
  } catch (err) {
1148
- if (err.code === "ENOENT") throw new NotFoundError(`box ${name}`);
1282
+ if (err.code === "ENOENT") throw new NotFoundError(`box ${shown(ref)}`);
1149
1283
  throw err;
1150
1284
  }
1151
1285
  }
1152
- async function saveSnapshot(req, p = paths()) {
1286
+ async function saveSnapshot(req, owner, p = paths()) {
1153
1287
  if (!NAME_RE2.test(req.name)) {
1154
1288
  throw new KvboxError(`invalid snapshot name "${req.name}": 1-64 chars, lowercase letters, digits, '.', '_' and '-', starting with a letter or digit`);
1155
1289
  }
1156
- const box = await readBox(req.box, p);
1290
+ const box = await readBox(parseRef(req.box, owner), p);
1157
1291
  if (box.base === void 0) {
1158
1292
  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)`);
1159
1293
  }
1160
1294
  if (!pidAlive(box.pid)) {
1161
1295
  throw new KvboxError(`box ${req.box} is not running; a snapshot freezes a running box (kvbox start ${req.box} first)`);
1162
1296
  }
1163
- const s = p.snapshot(req.name);
1164
- if (existsSync3(s.dir)) throw new KvboxError(`snapshot ${req.name} already exists`);
1297
+ const s = p.snapshot(owner, req.name);
1298
+ if (existsSync4(s.dir)) throw new KvboxError(`snapshot ${req.name} already exists`);
1165
1299
  const cfg = await loadConfig(p);
1166
- const m = p.machine(box.name);
1167
- await mkdir3(s.dir, { recursive: true, mode: 448 });
1300
+ const m = p.machine(box.owner, box.name);
1301
+ await mkdir4(s.dir, { recursive: true, mode: 448 });
1168
1302
  const fc = new FirecrackerClient(m.sock);
1169
1303
  let lineageTouched = false;
1170
1304
  try {
1171
1305
  const tracked = (await fc.machineConfigInfo()).track_dirty_pages;
1172
1306
  const since = box.memSnapshots ?? 0;
1173
- const mode = tracked && (since === 0 || existsSync3(m.memBase)) ? "Diff" : "Full";
1307
+ const mode = tracked && (since === 0 || existsSync4(m.memBase)) ? "Diff" : "Full";
1174
1308
  let failure;
1175
1309
  await fc.pause();
1176
1310
  try {
@@ -1197,7 +1331,8 @@ async function saveSnapshot(req, p = paths()) {
1197
1331
  }
1198
1332
  const meta = {
1199
1333
  name: req.name,
1200
- source: box.name,
1334
+ owner,
1335
+ source: refKey(box),
1201
1336
  base: box.base,
1202
1337
  vcpu: box.vcpu,
1203
1338
  memMib: box.memMib,
@@ -1215,27 +1350,28 @@ async function saveSnapshot(req, p = paths()) {
1215
1350
  }
1216
1351
  }
1217
1352
  async function mergeOntoPrevious(previous, diffFile, editor) {
1218
- if (!existsSync3(editor)) throw new KvboxError(`snapshot-editor missing at ${editor}; rerun install-server.sh`);
1353
+ if (!existsSync4(editor)) throw new KvboxError(`snapshot-editor missing at ${editor}; rerun install-server.sh`);
1219
1354
  const merged = `${diffFile}.merged`;
1220
1355
  await run(["cp", "--sparse=always", "--reflink=auto", previous, merged]);
1221
1356
  await run([editor, "edit-memory", "rebase", "--memory-path", merged, "--diff-path", diffFile]);
1222
- await rename(merged, diffFile);
1357
+ await rename2(merged, diffFile);
1223
1358
  }
1224
- async function deleteSnapshot(name, p = paths()) {
1225
- if (!existsSync3(p.snapshot(name).dir)) throw new NotFoundError(`snapshot ${name}`);
1226
- await rm2(p.snapshot(name).dir, { recursive: true, force: true });
1359
+ async function deleteSnapshot(ref, p = paths()) {
1360
+ const dir = p.snapshot(ref.owner, ref.name).dir;
1361
+ if (!existsSync4(dir)) throw new NotFoundError(`snapshot ${shown(ref)}`);
1362
+ await rm2(dir, { recursive: true, force: true });
1227
1363
  }
1228
1364
 
1229
1365
  // src/routes/snapshots.ts
1230
1366
  function snapshotRoutes() {
1231
1367
  const r = new Hono3();
1232
- r.get("/", async (c) => c.json(await listSnapshots()));
1368
+ r.get("/", async (c) => c.json(await listSnapshots(c.get("owner"))));
1233
1369
  r.post("/", async (c) => {
1234
1370
  const body = CreateSnapshotRequest.parse(await c.req.json());
1235
- return c.json(await saveSnapshot(body), 201);
1371
+ return c.json(await saveSnapshot(body, c.get("owner")), 201);
1236
1372
  });
1237
1373
  r.delete("/:name", async (c) => {
1238
- await deleteSnapshot(c.req.param("name"));
1374
+ await deleteSnapshot(parseRef(c.req.param("name"), c.get("owner")));
1239
1375
  return c.body(null, 204);
1240
1376
  });
1241
1377
  return r;
@@ -1244,9 +1380,16 @@ function snapshotRoutes() {
1244
1380
  // src/app.ts
1245
1381
  function createApp() {
1246
1382
  const app = new Hono4();
1383
+ app.use(resolveCaller());
1247
1384
  app.get(
1248
1385
  "/health",
1249
- async (c) => c.json({ ok: true, version: VERSION, pid: process.pid, base: await currentBaseVersion().catch(() => null) })
1386
+ async (c) => c.json({
1387
+ ok: true,
1388
+ version: VERSION,
1389
+ pid: process.pid,
1390
+ base: await currentBaseVersion().catch(() => null),
1391
+ owner: (await loadConfig()).owner
1392
+ })
1250
1393
  );
1251
1394
  app.route("/machines", machineRoutes());
1252
1395
  app.route("/snapshots", snapshotRoutes());
@@ -1266,7 +1409,12 @@ function createApp() {
1266
1409
  // src/listen.ts
1267
1410
  async function listen(opts = {}) {
1268
1411
  const p = await ensureLayout(paths());
1269
- await loadConfig(p);
1412
+ const cfg = await loadConfig(p);
1413
+ const moved = await migrateOwnerLayout(cfg.owner, p);
1414
+ if (moved.boxes.length + moved.snapshots.length > 0) {
1415
+ 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}/`);
1416
+ }
1417
+ for (const c of moved.conflicts) console.error(`warning: ${c} left in place: ${cfg.owner} already has one with that name`);
1270
1418
  const socketPath = opts.socketPath ?? p.serverSock;
1271
1419
  if (Buffer.byteLength(socketPath) > 100) {
1272
1420
  throw new KvboxError(`socket path too long for a Unix socket (${socketPath}); set KVBOX_HOME to a shorter path`);
@@ -1277,7 +1425,7 @@ async function listen(opts = {}) {
1277
1425
  server.once("error", reject);
1278
1426
  server.listen(socketPath, () => resolve());
1279
1427
  });
1280
- await writeFile4(p.serverPid, `${process.pid}
1428
+ await writeFile5(p.serverPid, `${process.pid}
1281
1429
  `);
1282
1430
  const shutdown2 = async () => {
1283
1431
  server.close();