@ghalex/kvbox-server 0.0.14 → 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.
@@ -13,12 +13,6 @@ var KvboxError = class extends Error {
13
13
  }
14
14
  exitCode;
15
15
  };
16
- var NotImplementedError = class extends KvboxError {
17
- constructor(what) {
18
- super(`${what}: not implemented yet`, 2);
19
- this.name = "NotImplementedError";
20
- }
21
- };
22
16
  var NotFoundError = class extends KvboxError {
23
17
  constructor(what) {
24
18
  super(`${what}: not found`, 1);
@@ -53,31 +47,40 @@ function paths(home = kvboxHome()) {
53
47
  baseImage: (version) => join(home, "assets", `rootfs-${version}.ext4`),
54
48
  machines: join(home, "machines"),
55
49
  snapshots: join(home, "snapshots"),
56
- machine: (name) => ({
57
- dir: join(home, "machines", name),
58
- meta: join(home, "machines", name, "meta.json"),
59
- /** The box's writable layer, stacked on the read-only base image inside the guest. */
60
- overlay: join(home, "machines", name, "overlay.ext4"),
61
- /** Boxes made before overlays: a full copy of the base image. Kept bootable, never created anymore. */
62
- disk: join(home, "machines", name, "disk.ext4"),
63
- /**
64
- * The box's memory as of its last snapshot (a hard link to that snapshot's `mem`, never modified).
65
- * The next snapshot is a diff of the pages dirtied since, merged onto this. Absent after a boot.
66
- */
67
- memBase: join(home, "machines", name, "mem.base"),
68
- sock: join(home, "machines", name, "fc.sock"),
69
- /** Firecracker's vsock endpoint: the host connects here to reach kvbox-agent inside the box (core/agent.ts). */
70
- vsock: join(home, "machines", name, "vsock.sock"),
71
- log: join(home, "machines", name, "fc.log")
72
- }),
73
- snapshot: (name) => ({
74
- dir: join(home, "snapshots", name),
75
- meta: join(home, "snapshots", name, "meta.json"),
76
- vmstate: join(home, "snapshots", name, "vmstate"),
77
- mem: join(home, "snapshots", name, "mem"),
78
- /** Copy of the box's overlay at the moment of the snapshot; the base image is referenced by version in meta.json. */
79
- overlay: join(home, "snapshots", name, "overlay.ext4")
80
- })
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
+ }
81
84
  };
82
85
  }
83
86
 
@@ -97,6 +100,36 @@ function isSizeName(value) {
97
100
  return value in SIZES;
98
101
  }
99
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
+
100
133
  // src/core/config.ts
101
134
  var ConfigSchema = z.object({
102
135
  kernel: z.string(),
@@ -107,14 +140,17 @@ var ConfigSchema = z.object({
107
140
  bridge: z.string().default("kvbox0"),
108
141
  subnet: z.string().default("172.30.0.0/24"),
109
142
  gateway: z.string().default("172.30.0.1"),
110
- 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)
111
146
  });
112
147
  function defaultConfig(p = paths()) {
113
148
  return ConfigSchema.parse({
114
149
  kernel: p.kernel,
115
150
  rootfs: p.rootfs,
116
151
  firecracker: p.firecracker,
117
- snapshotEditor: p.snapshotEditor
152
+ snapshotEditor: p.snapshotEditor,
153
+ owner: localOwner()
118
154
  });
119
155
  }
120
156
  async function loadConfig(p = paths()) {
@@ -225,7 +261,7 @@ function macFor(ip) {
225
261
  }
226
262
 
227
263
  // src/listen.ts
228
- import { rm as rm3, writeFile as writeFile4 } from "fs/promises";
264
+ import { rm as rm3, writeFile as writeFile5 } from "fs/promises";
229
265
  import { createAdaptorServer } from "@hono/node-server";
230
266
 
231
267
  // src/core/state.ts
@@ -233,6 +269,8 @@ import { mkdir, readFile as readFile2, readdir, writeFile as writeFile2 } from "
233
269
  import { z as z2 } from "zod";
234
270
  var MachineMetaSchema = z2.object({
235
271
  name: z2.string(),
272
+ /** Part of the box's identity: machines/<owner>/<name>. See refs.ts. */
273
+ owner: z2.string(),
236
274
  ip: z2.string(),
237
275
  mac: z2.string(),
238
276
  pid: z2.number().int().nullable(),
@@ -252,6 +290,8 @@ var MachineMetaSchema = z2.object({
252
290
  });
253
291
  var SnapshotMetaSchema = z2.object({
254
292
  name: z2.string(),
293
+ owner: z2.string(),
294
+ /** The box it was taken from, as owner/name. */
255
295
  source: z2.string(),
256
296
  base: z2.string(),
257
297
  vcpu: z2.number().int(),
@@ -277,25 +317,36 @@ async function listDirs(dir) {
277
317
  throw err;
278
318
  }
279
319
  }
280
- async function readMachineMeta(name, p = paths()) {
281
- 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);
282
322
  }
283
323
  async function writeMachineMeta(meta, p = paths()) {
284
- await mkdir(p.machine(meta.name).dir, { recursive: true });
285
- 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");
286
327
  }
287
- async function listMachineNames(p = paths()) {
288
- return listDirs(p.machines);
328
+ async function listMachineRefs(p = paths()) {
329
+ return listRefs(p.machines);
289
330
  }
290
- async function readSnapshotMeta(name, p = paths()) {
291
- 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);
292
333
  }
293
334
  async function writeSnapshotMeta(meta, p = paths()) {
294
- await mkdir(p.snapshot(meta.name).dir, { recursive: true });
295
- 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);
296
341
  }
297
- async function listSnapshotNames(p = paths()) {
298
- 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;
299
350
  }
300
351
  function pidAlive(pid) {
301
352
  if (pid === null || pid <= 0) return false;
@@ -307,6 +358,62 @@ function pidAlive(pid) {
307
358
  }
308
359
  }
309
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
+
310
417
  // src/app.ts
311
418
  import { Hono as Hono4 } from "hono";
312
419
 
@@ -323,12 +430,21 @@ function versionOf(srcFileUrl, injected) {
323
430
  }
324
431
 
325
432
  // src/version.ts
326
- var VERSION = versionOf(import.meta.url, true ? "0.0.14" : 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
+ }
327
443
 
328
444
  // src/core/machines.ts
329
445
  import { spawn } from "child_process";
330
- import { closeSync, existsSync as existsSync2, openSync, statSync } from "fs";
331
- 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";
332
448
  import { basename } from "path";
333
449
  import { setTimeout as sleep } from "timers/promises";
334
450
 
@@ -382,6 +498,66 @@ function setIdentity(udsPath, id, timeoutMs) {
382
498
  return agentCall(udsPath, { op: "identity", ...id }, timeoutMs);
383
499
  }
384
500
 
501
+ // src/core/capacity.ts
502
+ import { cpus, totalmem } from "os";
503
+ var MEM_HEADROOM_MIB = 2048;
504
+ var VCPU_OVERSUBSCRIPTION = 2;
505
+ function hostResources() {
506
+ return { memMib: Math.floor(totalmem() / 1024 ** 2), cores: cpus().length };
507
+ }
508
+ async function runningReservations(p = paths()) {
509
+ const out = [];
510
+ for (const ref of await listMachineRefs(p)) {
511
+ try {
512
+ const meta = await readMachineMeta(ref, p);
513
+ if (pidAlive(meta.pid)) out.push({ name: meta.name, vcpu: meta.vcpu, memMib: meta.memMib });
514
+ } catch {
515
+ }
516
+ }
517
+ return out;
518
+ }
519
+ async function capacity(p = paths()) {
520
+ const host = hostResources();
521
+ const running = await runningReservations(p);
522
+ return {
523
+ hostMemMib: host.memMib,
524
+ hostCores: host.cores,
525
+ reservedMemMib: running.reduce((n, r) => n + r.memMib, 0),
526
+ reservedVcpu: running.reduce((n, r) => n + r.vcpu, 0),
527
+ running: running.length
528
+ };
529
+ }
530
+ function gb(mib) {
531
+ const g = mib / 1024;
532
+ return `${Number.isInteger(g) ? g : g.toFixed(1)} GB`;
533
+ }
534
+ function describeRunning(running) {
535
+ if (running.length === 0) return "no box is running";
536
+ const list = running.map((r) => `${r.name} ${r.vcpu} vCPU/${gb(r.memMib)}`).join(", ");
537
+ const vcpu = running.reduce((n, r) => n + r.vcpu, 0);
538
+ const mem = running.reduce((n, r) => n + r.memMib, 0);
539
+ return `${running.length} running ${running.length === 1 ? "box reserves" : "boxes reserve"} ${vcpu} vCPU and ${gb(mem)} (${list})`;
540
+ }
541
+ function roomFor(host, running, want) {
542
+ const reservedMem = running.reduce((n, r) => n + r.memMib, 0);
543
+ const reservedVcpu = running.reduce((n, r) => n + r.vcpu, 0);
544
+ const memLimit = host.memMib - MEM_HEADROOM_MIB;
545
+ const vcpuLimit = host.cores * VCPU_OVERSUBSCRIPTION;
546
+ const hint = "stop or remove a box (kvbox stop, kvbox rm), pick a smaller --size, or pass --force to start it anyway";
547
+ if (reservedMem + want.memMib > memLimit) {
548
+ return `not enough memory for a ${gb(want.memMib)} box: ${describeRunning(running)}; this host has ${gb(host.memMib)} and keeps ${gb(MEM_HEADROOM_MIB)} for itself, so boxes may reserve ${gb(memLimit)} in total. ` + hint;
549
+ }
550
+ if (reservedVcpu + want.vcpu > vcpuLimit) {
551
+ return `too many vCPUs for a ${want.vcpu} vCPU box: ${describeRunning(running)}; this host has ${host.cores} cores and allows ${vcpuLimit} vCPUs in total (${VCPU_OVERSUBSCRIPTION}x). ` + hint;
552
+ }
553
+ return void 0;
554
+ }
555
+ async function assertRoomFor(vcpu, memMib, force, p = paths()) {
556
+ if (force) return;
557
+ const reason = roomFor(hostResources(), await runningReservations(p), { vcpu, memMib });
558
+ if (reason !== void 0) throw new KvboxError(reason);
559
+ }
560
+
385
561
  // src/core/firecracker.ts
386
562
  import { Agent, request } from "undici";
387
563
  var FirecrackerClient = class {
@@ -516,13 +692,14 @@ function diskStats(disk) {
516
692
  }
517
693
  }
518
694
  function ownDisk(meta, p) {
519
- const m = p.machine(meta.name);
695
+ const m = p.machine(meta.owner, meta.name);
520
696
  return meta.base === void 0 ? m.disk : m.overlay;
521
697
  }
522
698
  function toMachine(meta, p = paths()) {
523
699
  const alive = pidAlive(meta.pid);
524
700
  return {
525
701
  name: meta.name,
702
+ owner: meta.owner,
526
703
  ip: meta.ip,
527
704
  mac: meta.mac,
528
705
  pid: alive ? meta.pid : null,
@@ -537,17 +714,17 @@ function toMachine(meta, p = paths()) {
537
714
  }
538
715
  async function readAllMeta(p) {
539
716
  const metas = [];
540
- for (const name of await listMachineNames(p)) {
717
+ for (const ref of await listMachineRefs(p)) {
541
718
  try {
542
- metas.push(await readMachineMeta(name, p));
719
+ metas.push(await readMachineMeta(ref, p));
543
720
  } catch (err) {
544
- console.error(`warning: skipping machines/${name}: ${err.message}`);
721
+ console.error(`warning: skipping machines/${refKey(ref)}: ${err.message}`);
545
722
  }
546
723
  }
547
724
  return metas;
548
725
  }
549
- async function listMachines(p = paths()) {
550
- 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));
551
728
  }
552
729
  function pickName(requested, taken) {
553
730
  if (requested !== void 0) {
@@ -582,7 +759,7 @@ async function currentBase(cfg, p) {
582
759
  function baseFileFor(meta, p) {
583
760
  if (meta.base === void 0) return void 0;
584
761
  const file = p.baseImage(meta.base);
585
- 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}`);
586
763
  return file;
587
764
  }
588
765
  var overlayCapable = /* @__PURE__ */ new Map();
@@ -596,7 +773,7 @@ async function assertOverlayCapable(baseFile, version) {
596
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`);
597
774
  }
598
775
  async function createOverlay(file, gib) {
599
- await writeFile3(file, "", { mode: 384 });
776
+ await writeFile4(file, "", { mode: 384 });
600
777
  await truncate(file, gib * 1024 ** 3);
601
778
  await run(["mkfs.ext4", "-q", "-F", "-m", "0", "-L", "kvbox-box", "-E", "lazy_itable_init=1,lazy_journal_init=1", file]);
602
779
  }
@@ -610,19 +787,19 @@ async function waitFor(pred, timeoutMs, stepMs = 50) {
610
787
  }
611
788
  async function tailOf(file, lines = 5) {
612
789
  try {
613
- 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");
614
791
  } catch {
615
792
  return "";
616
793
  }
617
794
  }
618
- async function spawnFirecracker(name, cfg, p) {
619
- const m = p.machine(name);
795
+ async function spawnFirecracker(ref, cfg, p) {
796
+ const m = p.machine(ref.owner, ref.name);
620
797
  await rm(m.sock, { force: true });
621
798
  await rm(m.vsock, { force: true });
622
799
  const logFd = openSync(m.log, "a");
623
800
  let child;
624
801
  try {
625
- 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"], {
626
803
  cwd: m.dir,
627
804
  detached: true,
628
805
  stdio: ["ignore", logFd, logFd]
@@ -633,7 +810,7 @@ async function spawnFirecracker(name, cfg, p) {
633
810
  child.unref();
634
811
  const pid = child.pid;
635
812
  if (pid === void 0) throw new KvboxError("could not start firecracker");
636
- if (!await waitFor(() => existsSync2(m.sock), 3e3)) {
813
+ if (!await waitFor(() => existsSync3(m.sock), 3e3)) {
637
814
  killQuietly(pid);
638
815
  throw new KvboxError(`firecracker did not open its API socket; last log lines:
639
816
  ${await tailOf(m.log)}`);
@@ -647,9 +824,9 @@ function killQuietly(pid) {
647
824
  }
648
825
  }
649
826
  async function launch(spec, tap, cfg, pubkeyB64, p) {
650
- const m = p.machine(spec.name);
827
+ const m = p.machine(spec.owner, spec.name);
651
828
  const baseFile = baseFileFor(spec, p);
652
- const pid = await spawnFirecracker(spec.name, cfg, p);
829
+ const pid = await spawnFirecracker(spec, cfg, p);
653
830
  try {
654
831
  const fc = new FirecrackerClient(m.sock);
655
832
  try {
@@ -673,10 +850,10 @@ async function launch(spec, tap, cfg, pubkeyB64, p) {
673
850
  throw err;
674
851
  }
675
852
  }
676
- async function loadSnapshot(spec, snapshotName, tap, cfg, p) {
677
- const m = p.machine(spec.name);
678
- const s = p.snapshot(snapshotName);
679
- 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);
680
857
  try {
681
858
  const fc = new FirecrackerClient(m.sock);
682
859
  try {
@@ -690,7 +867,7 @@ async function loadSnapshot(spec, snapshotName, tap, cfg, p) {
690
867
  resume: false
691
868
  });
692
869
  } catch (err) {
693
- 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)));
694
871
  throw err;
695
872
  }
696
873
  if (await namesOverlayAbsolutely(s.vmstate)) await fc.patchDrivePath("overlay", m.overlay);
@@ -710,43 +887,44 @@ async function currentBaseVersion(p = paths()) {
710
887
  return (await currentBase(await loadConfig(p), p)).version;
711
888
  }
712
889
  async function namesOverlayAbsolutely(vmstate) {
713
- return (await readFile3(vmstate)).includes(`/${OVERLAY_REL}`);
890
+ return (await readFile4(vmstate)).includes(`/${OVERLAY_REL}`);
714
891
  }
715
892
  async function readPubkeyB64(p) {
716
893
  try {
717
- 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");
718
895
  } catch {
719
896
  throw new KvboxError(`SSH key missing at ${p.sshKey}.pub; run install-server.sh`);
720
897
  }
721
898
  }
722
899
  function assertAssets(cfg) {
723
900
  for (const [what, file] of [["kernel", cfg.kernel], ["base image", cfg.rootfs], ["firecracker", cfg.firecracker]]) {
724
- 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`);
725
902
  }
726
903
  }
727
- async function createMachine(req, p = paths()) {
904
+ async function createMachine(req, owner, p = paths()) {
728
905
  const cfg = await loadConfig(p);
729
- 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);
730
907
  const sizeName = req.size ?? cfg.defaultSize;
731
908
  if (!isSizeName(sizeName)) throw new KvboxError(`unknown size "${sizeName}"; use one of ${Object.keys(SIZES).join(", ")}`);
732
909
  const size = SIZES[sizeName];
733
910
  assertAssets(cfg);
911
+ await assertRoomFor(size.vcpu, size.memMib, req.force ?? false, p);
734
912
  const pubkeyB64 = await readPubkeyB64(p);
735
913
  const existing = await readAllMeta(p);
736
- 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)));
737
915
  const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
738
916
  const mac = macFor(ip);
739
- const m = p.machine(name);
917
+ const m = p.machine(owner, name);
740
918
  const base = await currentBase(cfg, p);
741
919
  await assertOverlayCapable(base.file, base.version);
742
920
  await ensureHostNetwork(cfg);
743
- await mkdir2(m.dir, { recursive: true, mode: 448 });
921
+ await mkdir3(m.dir, { recursive: true, mode: 448 });
744
922
  let tapCreated = false;
745
923
  try {
746
924
  await createOverlay(m.overlay, size.diskGib);
747
925
  const tap = await createTap(name, cfg);
748
926
  tapCreated = true;
749
- 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 };
750
928
  const pid = await launch(spec, tap, cfg, pubkeyB64, p);
751
929
  const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: "base" };
752
930
  await writeMachineMeta(meta, p);
@@ -757,34 +935,36 @@ async function createMachine(req, p = paths()) {
757
935
  throw err;
758
936
  }
759
937
  }
760
- async function readSnap(name, p) {
938
+ async function readSnap(ref, p) {
761
939
  try {
762
- return await readSnapshotMeta(name, p);
940
+ return await readSnapshotMeta(ref, p);
763
941
  } catch (err) {
764
- if (err.code === "ENOENT") throw new NotFoundError(`snapshot ${name}`);
942
+ if (err.code === "ENOENT") throw new NotFoundError(`snapshot ${shown(ref)}`);
765
943
  throw err;
766
944
  }
767
945
  }
768
946
  function prefixLenOf(subnet) {
769
947
  return Number(subnet.split("/")[1]);
770
948
  }
771
- async function restoreMachine(from, req, cfg, p) {
949
+ async function restoreMachine(fromText, owner, req, cfg, p) {
950
+ const from = parseRef(fromText, owner);
772
951
  const snap = await readSnap(from, p);
773
952
  if (req.size !== void 0) {
774
- 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)`);
775
954
  }
776
955
  assertAssets(cfg);
777
- if (!existsSync2(p.baseImage(snap.base))) {
778
- 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}`);
779
958
  }
959
+ await assertRoomFor(snap.vcpu, snap.memMib, req.force ?? false, p);
780
960
  const existing = await readAllMeta(p);
781
- 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)));
782
962
  const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
783
963
  const mac = macFor(ip);
784
- const m = p.machine(name);
785
- const s = p.snapshot(from);
964
+ const m = p.machine(owner, name);
965
+ const s = p.snapshot(from.owner, from.name);
786
966
  await ensureHostNetwork(cfg);
787
- await mkdir2(m.dir, { recursive: true, mode: 448 });
967
+ await mkdir3(m.dir, { recursive: true, mode: 448 });
788
968
  let tapCreated = false;
789
969
  let pid;
790
970
  try {
@@ -792,7 +972,7 @@ async function restoreMachine(from, req, cfg, p) {
792
972
  await link(s.mem, m.memBase);
793
973
  const tap = await createTap(name, cfg, { detached: true });
794
974
  tapCreated = true;
795
- 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 };
796
976
  pid = await loadSnapshot(spec, from, tap, cfg, p);
797
977
  const fc = new FirecrackerClient(m.sock);
798
978
  try {
@@ -800,9 +980,9 @@ async function restoreMachine(from, req, cfg, p) {
800
980
  } finally {
801
981
  await fc.close();
802
982
  }
803
- await introduce(spec, m.vsock, cfg, from);
983
+ await introduce(spec, m.vsock, cfg, shown(from));
804
984
  await attachTap(name, cfg);
805
- 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 };
806
986
  await writeMachineMeta(meta, p);
807
987
  return toMachine(meta, p);
808
988
  } catch (err) {
@@ -837,7 +1017,7 @@ async function introduce(spec, vsock, cfg, from) {
837
1017
  async function shutdown(meta, cfg, p, graceMs) {
838
1018
  const pid = meta.pid;
839
1019
  if (pidAlive(pid)) {
840
- const fc = new FirecrackerClient(p.machine(meta.name).sock);
1020
+ const fc = new FirecrackerClient(p.machine(meta.owner, meta.name).sock);
841
1021
  try {
842
1022
  await fc.sendCtrlAltDel();
843
1023
  } catch {
@@ -854,34 +1034,35 @@ async function shutdown(meta, cfg, p, graceMs) {
854
1034
  }
855
1035
  }
856
1036
  await deleteTap(meta.name, cfg);
857
- await rm(p.machine(meta.name).sock, { force: true });
858
- 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 });
859
1040
  }
860
- async function stopMachine(name, p = paths()) {
861
- const meta = await readMeta(name, p);
1041
+ async function stopMachine(ref, p = paths()) {
1042
+ const meta = await readMeta(ref, p);
862
1043
  const cfg = await loadConfig(p);
863
- 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`);
864
1045
  await shutdown(meta, cfg, p, 1e4);
865
- await rm(p.machine(name).memBase, { force: true });
1046
+ await rm(p.machine(ref.owner, ref.name).memBase, { force: true });
866
1047
  const stopped = { ...meta, pid: null, memSnapshots: void 0 };
867
1048
  await writeMachineMeta(stopped, p);
868
1049
  return toMachine(stopped, p);
869
1050
  }
870
- async function startMachine(name, p = paths()) {
871
- const meta = await readMeta(name, p);
1051
+ async function startMachine(ref, p = paths()) {
1052
+ const meta = await readMeta(ref, p);
872
1053
  const cfg = await loadConfig(p);
873
- 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`);
874
1055
  assertAssets(cfg);
875
1056
  const pubkeyB64 = await readPubkeyB64(p);
876
1057
  await ensureHostNetwork(cfg);
877
- const tap = await createTap(name, cfg);
1058
+ const tap = await createTap(ref.name, cfg);
878
1059
  try {
879
1060
  const pid = await launch(meta, tap, cfg, pubkeyB64, p);
880
1061
  const started = { ...meta, pid };
881
1062
  await writeMachineMeta(started, p);
882
1063
  return toMachine(started, p);
883
1064
  } catch (err) {
884
- await deleteTap(name, cfg).catch(() => void 0);
1065
+ await deleteTap(ref.name, cfg).catch(() => void 0);
885
1066
  throw err;
886
1067
  }
887
1068
  }
@@ -889,18 +1070,18 @@ async function applyAtNextBoot(meta, changes, cfg, p, whileStopped) {
889
1070
  const wasRunning = pidAlive(meta.pid);
890
1071
  if (wasRunning) {
891
1072
  await shutdown(meta, cfg, p, 1e4);
892
- await rm(p.machine(meta.name).memBase, { force: true });
1073
+ await rm(p.machine(meta.owner, meta.name).memBase, { force: true });
893
1074
  }
894
1075
  const updated = { ...meta, ...changes, pid: null, memSnapshots: void 0 };
895
1076
  await writeMachineMeta(updated, p);
896
1077
  if (whileStopped) await whileStopped();
897
- return wasRunning ? startMachine(meta.name, p) : toMachine(updated, p);
1078
+ return wasRunning ? startMachine(meta, p) : toMachine(updated, p);
898
1079
  }
899
- async function updateMachine(name, p = paths()) {
900
- const meta = await readMeta(name, p);
1080
+ async function updateMachine(ref, p = paths()) {
1081
+ const meta = await readMeta(ref, p);
901
1082
  const cfg = await loadConfig(p);
902
1083
  if (meta.base === void 0) {
903
- 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`);
904
1085
  }
905
1086
  assertAssets(cfg);
906
1087
  const base = await currentBase(cfg, p);
@@ -908,8 +1089,8 @@ async function updateMachine(name, p = paths()) {
908
1089
  await assertOverlayCapable(base.file, base.version);
909
1090
  return applyAtNextBoot(meta, { base: base.version }, cfg, p);
910
1091
  }
911
- async function rescaleMachine(name, req, p = paths()) {
912
- const meta = await readMeta(name, p);
1092
+ async function rescaleMachine(ref, req, p = paths()) {
1093
+ const meta = await readMeta(ref, p);
913
1094
  const cfg = await loadConfig(p);
914
1095
  const disk = ownDisk(meta, p);
915
1096
  const currentDiskGib = Math.round(statSync(disk).size / 1024 ** 3);
@@ -920,7 +1101,7 @@ async function rescaleMachine(name, req, p = paths()) {
920
1101
  diskGib: req.diskGib ?? preset?.diskGib ?? currentDiskGib
921
1102
  };
922
1103
  if (target.diskGib < currentDiskGib) {
923
- 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`);
924
1105
  }
925
1106
  const changes = {};
926
1107
  if (target.vcpu !== meta.vcpu) changes.vcpu = target.vcpu;
@@ -934,30 +1115,23 @@ async function growDisk(file, gib) {
934
1115
  await run(["e2fsck", "-f", "-p", file], { okExitCodes: [1] });
935
1116
  await run(["resize2fs", file]);
936
1117
  }
937
- async function readMeta(name, p) {
1118
+ async function readMeta(ref, p) {
938
1119
  try {
939
- return await readMachineMeta(name, p);
1120
+ return await readMachineMeta(ref, p);
940
1121
  } catch (err) {
941
- if (err.code === "ENOENT") throw new NotFoundError(`box ${name}`);
1122
+ if (err.code === "ENOENT") throw new NotFoundError(`box ${shown(ref)}`);
942
1123
  throw err;
943
1124
  }
944
1125
  }
945
- async function destroyMachine(name, p = paths()) {
946
- const meta = await readMeta(name, p);
1126
+ async function destroyMachine(ref, p = paths()) {
1127
+ const meta = await readMeta(ref, p);
947
1128
  const cfg = await loadConfig(p);
948
1129
  await shutdown(meta, cfg, p, 2e3);
949
- await rm(p.machine(name).dir, { recursive: true, force: true });
1130
+ await rm(p.machine(ref.owner, ref.name).dir, { recursive: true, force: true });
950
1131
  }
951
1132
 
952
1133
  // src/routes/capacity.ts
953
1134
  import { Hono } from "hono";
954
-
955
- // src/core/capacity.ts
956
- async function capacity() {
957
- throw new NotImplementedError("capacity.capacity");
958
- }
959
-
960
- // src/routes/capacity.ts
961
1135
  function capacityRoutes() {
962
1136
  const r = new Hono();
963
1137
  r.get("/", async (c) => c.json(await capacity()));
@@ -972,6 +1146,8 @@ import { z as z3 } from "zod";
972
1146
  var SizeSchema = z3.enum(SIZE_NAMES);
973
1147
  var MachineSchema = z3.object({
974
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(),
975
1151
  ip: z3.string(),
976
1152
  mac: z3.string(),
977
1153
  pid: z3.number().int().nullable(),
@@ -990,6 +1166,8 @@ var MachineSchema = z3.object({
990
1166
  });
991
1167
  var CreateMachineRequest = z3.object({
992
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(),
993
1171
  size: SizeSchema.optional(),
994
1172
  from: z3.string().optional(),
995
1173
  force: z3.boolean().optional()
@@ -1002,8 +1180,9 @@ var RescaleMachineRequest = z3.object({
1002
1180
  });
1003
1181
  var SnapshotSchema = z3.object({
1004
1182
  name: z3.string(),
1183
+ owner: z3.string(),
1005
1184
  source: z3.string(),
1006
- // the box it was taken from
1185
+ // the box it was taken from, as owner/name
1007
1186
  base: z3.string(),
1008
1187
  // base image version the overlay stacks on; a restore needs the same one
1009
1188
  vcpu: z3.number().int(),
@@ -1028,7 +1207,9 @@ var HealthSchema = z3.object({
1028
1207
  version: z3.string(),
1029
1208
  pid: z3.number().int(),
1030
1209
  /** Base image version new boxes get (null when the host has no usable base image yet); absent from older servers. */
1031
- 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()
1032
1213
  });
1033
1214
  var ApiErrorSchema = z3.object({
1034
1215
  error: z3.string()
@@ -1037,20 +1218,20 @@ var ApiErrorSchema = z3.object({
1037
1218
  // src/routes/machines.ts
1038
1219
  function machineRoutes() {
1039
1220
  const r = new Hono2();
1040
- r.get("/", async (c) => c.json(await listMachines()));
1221
+ r.get("/", async (c) => c.json(await listMachines(c.get("owner"))));
1041
1222
  r.post("/", async (c) => {
1042
1223
  const body = CreateMachineRequest.parse(await c.req.json().catch(() => ({})));
1043
- return c.json(await createMachine(body), 201);
1224
+ return c.json(await createMachine(body, c.get("owner")), 201);
1044
1225
  });
1045
- r.post("/:name/stop", async (c) => c.json(await stopMachine(c.req.param("name"))));
1046
- r.post("/:name/start", async (c) => c.json(await startMachine(c.req.param("name"))));
1047
- 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")))));
1048
1229
  r.post("/:name/rescale", async (c) => {
1049
1230
  const body = RescaleMachineRequest.parse(await c.req.json().catch(() => ({})));
1050
- 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));
1051
1232
  });
1052
1233
  r.delete("/:name", async (c) => {
1053
- await destroyMachine(c.req.param("name"));
1234
+ await destroyMachine(parseRef(c.req.param("name"), c.get("owner")));
1054
1235
  return c.body(null, 204);
1055
1236
  });
1056
1237
  return r;
@@ -1060,12 +1241,13 @@ function machineRoutes() {
1060
1241
  import { Hono as Hono3 } from "hono";
1061
1242
 
1062
1243
  // src/core/snapshots.ts
1063
- import { existsSync as existsSync3 } from "fs";
1064
- 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";
1065
1246
  var NAME_RE2 = /^[a-z0-9][a-z0-9._-]{0,63}$/;
1066
1247
  function toSnapshot(meta) {
1067
1248
  return {
1068
1249
  name: meta.name,
1250
+ owner: meta.owner,
1069
1251
  source: meta.source,
1070
1252
  base: meta.base,
1071
1253
  vcpu: meta.vcpu,
@@ -1074,54 +1256,55 @@ function toSnapshot(meta) {
1074
1256
  createdAt: meta.createdAt
1075
1257
  };
1076
1258
  }
1077
- async function listSnapshots(p = paths()) {
1259
+ async function listSnapshots(owner, p = paths()) {
1078
1260
  const out = [];
1079
- for (const name of await listSnapshotNames(p)) {
1261
+ for (const ref of await listSnapshotRefs(p)) {
1262
+ if (owner !== null && ref.owner !== owner) continue;
1080
1263
  try {
1081
- out.push(toSnapshot(await readSnapshotMeta(name, p)));
1264
+ out.push(toSnapshot(await readSnapshotMeta(ref, p)));
1082
1265
  } catch (err) {
1083
- console.error(`warning: skipping snapshots/${name}: ${err.message}`);
1266
+ console.error(`warning: skipping snapshots/${refKey(ref)}: ${err.message}`);
1084
1267
  }
1085
1268
  }
1086
1269
  return out;
1087
1270
  }
1088
1271
  async function usedBytes(dir) {
1089
1272
  let total = 0;
1090
- for (const entry of await readdir2(dir)) {
1273
+ for (const entry of await readdir3(dir)) {
1091
1274
  total += (await stat(`${dir}/${entry}`)).blocks * 512;
1092
1275
  }
1093
1276
  return total;
1094
1277
  }
1095
- async function readBox(name, p) {
1278
+ async function readBox(ref, p) {
1096
1279
  try {
1097
- return await readMachineMeta(name, p);
1280
+ return await readMachineMeta(ref, p);
1098
1281
  } catch (err) {
1099
- if (err.code === "ENOENT") throw new NotFoundError(`box ${name}`);
1282
+ if (err.code === "ENOENT") throw new NotFoundError(`box ${shown(ref)}`);
1100
1283
  throw err;
1101
1284
  }
1102
1285
  }
1103
- async function saveSnapshot(req, p = paths()) {
1286
+ async function saveSnapshot(req, owner, p = paths()) {
1104
1287
  if (!NAME_RE2.test(req.name)) {
1105
1288
  throw new KvboxError(`invalid snapshot name "${req.name}": 1-64 chars, lowercase letters, digits, '.', '_' and '-', starting with a letter or digit`);
1106
1289
  }
1107
- const box = await readBox(req.box, p);
1290
+ const box = await readBox(parseRef(req.box, owner), p);
1108
1291
  if (box.base === void 0) {
1109
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)`);
1110
1293
  }
1111
1294
  if (!pidAlive(box.pid)) {
1112
1295
  throw new KvboxError(`box ${req.box} is not running; a snapshot freezes a running box (kvbox start ${req.box} first)`);
1113
1296
  }
1114
- const s = p.snapshot(req.name);
1115
- 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`);
1116
1299
  const cfg = await loadConfig(p);
1117
- const m = p.machine(box.name);
1118
- 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 });
1119
1302
  const fc = new FirecrackerClient(m.sock);
1120
1303
  let lineageTouched = false;
1121
1304
  try {
1122
1305
  const tracked = (await fc.machineConfigInfo()).track_dirty_pages;
1123
1306
  const since = box.memSnapshots ?? 0;
1124
- const mode = tracked && (since === 0 || existsSync3(m.memBase)) ? "Diff" : "Full";
1307
+ const mode = tracked && (since === 0 || existsSync4(m.memBase)) ? "Diff" : "Full";
1125
1308
  let failure;
1126
1309
  await fc.pause();
1127
1310
  try {
@@ -1148,7 +1331,8 @@ async function saveSnapshot(req, p = paths()) {
1148
1331
  }
1149
1332
  const meta = {
1150
1333
  name: req.name,
1151
- source: box.name,
1334
+ owner,
1335
+ source: refKey(box),
1152
1336
  base: box.base,
1153
1337
  vcpu: box.vcpu,
1154
1338
  memMib: box.memMib,
@@ -1166,27 +1350,28 @@ async function saveSnapshot(req, p = paths()) {
1166
1350
  }
1167
1351
  }
1168
1352
  async function mergeOntoPrevious(previous, diffFile, editor) {
1169
- 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`);
1170
1354
  const merged = `${diffFile}.merged`;
1171
1355
  await run(["cp", "--sparse=always", "--reflink=auto", previous, merged]);
1172
1356
  await run([editor, "edit-memory", "rebase", "--memory-path", merged, "--diff-path", diffFile]);
1173
- await rename(merged, diffFile);
1357
+ await rename2(merged, diffFile);
1174
1358
  }
1175
- async function deleteSnapshot(name, p = paths()) {
1176
- if (!existsSync3(p.snapshot(name).dir)) throw new NotFoundError(`snapshot ${name}`);
1177
- 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 });
1178
1363
  }
1179
1364
 
1180
1365
  // src/routes/snapshots.ts
1181
1366
  function snapshotRoutes() {
1182
1367
  const r = new Hono3();
1183
- r.get("/", async (c) => c.json(await listSnapshots()));
1368
+ r.get("/", async (c) => c.json(await listSnapshots(c.get("owner"))));
1184
1369
  r.post("/", async (c) => {
1185
1370
  const body = CreateSnapshotRequest.parse(await c.req.json());
1186
- return c.json(await saveSnapshot(body), 201);
1371
+ return c.json(await saveSnapshot(body, c.get("owner")), 201);
1187
1372
  });
1188
1373
  r.delete("/:name", async (c) => {
1189
- await deleteSnapshot(c.req.param("name"));
1374
+ await deleteSnapshot(parseRef(c.req.param("name"), c.get("owner")));
1190
1375
  return c.body(null, 204);
1191
1376
  });
1192
1377
  return r;
@@ -1195,9 +1380,16 @@ function snapshotRoutes() {
1195
1380
  // src/app.ts
1196
1381
  function createApp() {
1197
1382
  const app = new Hono4();
1383
+ app.use(resolveCaller());
1198
1384
  app.get(
1199
1385
  "/health",
1200
- 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
+ })
1201
1393
  );
1202
1394
  app.route("/machines", machineRoutes());
1203
1395
  app.route("/snapshots", snapshotRoutes());
@@ -1217,7 +1409,12 @@ function createApp() {
1217
1409
  // src/listen.ts
1218
1410
  async function listen(opts = {}) {
1219
1411
  const p = await ensureLayout(paths());
1220
- 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`);
1221
1418
  const socketPath = opts.socketPath ?? p.serverSock;
1222
1419
  if (Buffer.byteLength(socketPath) > 100) {
1223
1420
  throw new KvboxError(`socket path too long for a Unix socket (${socketPath}); set KVBOX_HOME to a shorter path`);
@@ -1228,7 +1425,7 @@ async function listen(opts = {}) {
1228
1425
  server.once("error", reject);
1229
1426
  server.listen(socketPath, () => resolve());
1230
1427
  });
1231
- await writeFile4(p.serverPid, `${process.pid}
1428
+ await writeFile5(p.serverPid, `${process.pid}
1232
1429
  `);
1233
1430
  const shutdown2 = async () => {
1234
1431
  server.close();