@ghalex/kvbox-server 0.0.15 → 0.0.17

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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { spawnSync } from "child_process";
4
+ import { spawnSync } from "node:child_process";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // ../shared/src/errors.ts
@@ -21,8 +21,8 @@ var NotFoundError = class extends KvboxError {
21
21
  };
22
22
 
23
23
  // ../shared/src/paths.ts
24
- import { homedir } from "os";
25
- import { join } from "path";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
26
  function kvboxHome() {
27
27
  return process.env["KVBOX_HOME"] ?? join(homedir(), ".kvbox");
28
28
  }
@@ -30,6 +30,8 @@ function paths(home = kvboxHome()) {
30
30
  return {
31
31
  home,
32
32
  config: join(home, "config.json"),
33
+ /** The host's ledgers (SQLite via node:sqlite): usage events today. Boxes and snapshots stay files; see server core/db.ts. */
34
+ db: join(home, "kvbox.db"),
33
35
  serverSock: join(home, "server.sock"),
34
36
  serverPid: join(home, "server.pid"),
35
37
  serverLog: join(home, "server.log"),
@@ -47,36 +49,45 @@ function paths(home = kvboxHome()) {
47
49
  baseImage: (version) => join(home, "assets", `rootfs-${version}.ext4`),
48
50
  machines: join(home, "machines"),
49
51
  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
- })
52
+ /** Boxes and snapshots are filed by owner: machines/<owner>/<name>. The owner is part of a box's identity. */
53
+ ownerMachines: (owner) => join(home, "machines", owner),
54
+ ownerSnapshots: (owner) => join(home, "snapshots", owner),
55
+ machine: (owner, name) => {
56
+ const dir = join(home, "machines", owner, name);
57
+ return {
58
+ dir,
59
+ meta: join(dir, "meta.json"),
60
+ /** The box's writable layer, stacked on the read-only base image inside the guest. */
61
+ overlay: join(dir, "overlay.ext4"),
62
+ /** Boxes made before overlays: a full copy of the base image. Kept bootable, never created anymore. */
63
+ disk: join(dir, "disk.ext4"),
64
+ /**
65
+ * The box's memory as of its last snapshot (a hard link to that snapshot's `mem`, never modified).
66
+ * The next snapshot is a diff of the pages dirtied since, merged onto this. Absent after a boot.
67
+ */
68
+ memBase: join(dir, "mem.base"),
69
+ sock: join(dir, "fc.sock"),
70
+ /** Firecracker's vsock endpoint: the host connects here to reach kvbox-agent inside the box (core/agent.ts). */
71
+ vsock: join(dir, "vsock.sock"),
72
+ log: join(dir, "fc.log")
73
+ };
74
+ },
75
+ snapshot: (owner, name) => {
76
+ const dir = join(home, "snapshots", owner, name);
77
+ return {
78
+ dir,
79
+ meta: join(dir, "meta.json"),
80
+ vmstate: join(dir, "vmstate"),
81
+ mem: join(dir, "mem"),
82
+ /** Copy of the box's overlay at the moment of the snapshot; the base image is referenced by version in meta.json. */
83
+ overlay: join(dir, "overlay.ext4")
84
+ };
85
+ }
75
86
  };
76
87
  }
77
88
 
78
89
  // src/core/config.ts
79
- import { readFile, writeFile } from "fs/promises";
90
+ import { readFile, writeFile } from "node:fs/promises";
80
91
  import { z } from "zod";
81
92
 
82
93
  // ../shared/src/sizes.ts
@@ -91,7 +102,43 @@ function isSizeName(value) {
91
102
  return value in SIZES;
92
103
  }
93
104
 
105
+ // src/core/refs.ts
106
+ import { userInfo } from "node:os";
107
+ function shown(ref) {
108
+ return ref.display ?? ref.name;
109
+ }
110
+ var LOCAL_OWNER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
111
+ var OWNER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}(@[A-Za-z0-9.-]{1,128})?$/;
112
+ function localOwner() {
113
+ try {
114
+ const name = userInfo().username;
115
+ if (LOCAL_OWNER_RE.test(name)) return name;
116
+ } catch {
117
+ }
118
+ const env = process.env["USER"];
119
+ return env !== void 0 && LOCAL_OWNER_RE.test(env) ? env : "kvbox";
120
+ }
121
+ function parseRef(text, caller) {
122
+ const slash = text.indexOf("/");
123
+ if (slash === -1) return { owner: caller, name: text, display: text };
124
+ const owner = text.slice(0, slash);
125
+ const name = text.slice(slash + 1);
126
+ if (!OWNER_RE.test(owner) || name.length === 0 || name.includes("/")) {
127
+ throw new KvboxError(`invalid box reference "${text}": use <name> or <owner>/<name>`);
128
+ }
129
+ return { owner, name, display: owner === caller ? name : text };
130
+ }
131
+ function refKey(ref) {
132
+ return `${ref.owner}/${ref.name}`;
133
+ }
134
+
94
135
  // src/core/config.ts
136
+ var PricingSchema = z.object({
137
+ currency: z.string().min(1),
138
+ vcpuHour: z.number().nonnegative(),
139
+ ramGibHour: z.number().nonnegative(),
140
+ diskGibHour: z.number().nonnegative()
141
+ });
95
142
  var ConfigSchema = z.object({
96
143
  kernel: z.string(),
97
144
  rootfs: z.string(),
@@ -101,14 +148,18 @@ var ConfigSchema = z.object({
101
148
  bridge: z.string().default("kvbox0"),
102
149
  subnet: z.string().default("172.30.0.0/24"),
103
150
  gateway: z.string().default("172.30.0.1"),
104
- defaultSize: z.enum(SIZE_NAMES).default(DEFAULT_SIZE)
151
+ defaultSize: z.enum(SIZE_NAMES).default(DEFAULT_SIZE),
152
+ /** Who boxes made over the local socket belong to: the Unix user running the server unless set here. No `@`, no `/`. */
153
+ owner: z.string().regex(LOCAL_OWNER_RE, "owner: letters, digits, '.', '_', '-'; no '@' or '/'").default(localOwner),
154
+ pricing: PricingSchema.optional()
105
155
  });
106
156
  function defaultConfig(p = paths()) {
107
157
  return ConfigSchema.parse({
108
158
  kernel: p.kernel,
109
159
  rootfs: p.rootfs,
110
160
  firecracker: p.firecracker,
111
- snapshotEditor: p.snapshotEditor
161
+ snapshotEditor: p.snapshotEditor,
162
+ owner: localOwner()
112
163
  });
113
164
  }
114
165
  async function loadConfig(p = paths()) {
@@ -126,12 +177,12 @@ async function loadConfig(p = paths()) {
126
177
  }
127
178
 
128
179
  // src/core/network.ts
129
- import { existsSync } from "fs";
130
- import { fileURLToPath } from "url";
180
+ import { existsSync } from "node:fs";
181
+ import { fileURLToPath } from "node:url";
131
182
 
132
183
  // src/core/exec.ts
133
- import { execFile } from "child_process";
134
- import { promisify } from "util";
184
+ import { execFile } from "node:child_process";
185
+ import { promisify } from "node:util";
135
186
  var execFileAsync = promisify(execFile);
136
187
  async function run(argv, opts = {}) {
137
188
  const [cmd, ...args] = opts.sudo ? ["sudo", "-n", ...argv] : argv;
@@ -219,14 +270,16 @@ function macFor(ip) {
219
270
  }
220
271
 
221
272
  // src/listen.ts
222
- import { rm as rm3, writeFile as writeFile4 } from "fs/promises";
273
+ import { rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
223
274
  import { createAdaptorServer } from "@hono/node-server";
224
275
 
225
276
  // src/core/state.ts
226
- import { mkdir, readFile as readFile2, readdir, writeFile as writeFile2 } from "fs/promises";
277
+ import { mkdir, readFile as readFile2, readdir, writeFile as writeFile2 } from "node:fs/promises";
227
278
  import { z as z2 } from "zod";
228
279
  var MachineMetaSchema = z2.object({
229
280
  name: z2.string(),
281
+ /** Part of the box's identity: machines/<owner>/<name>. See refs.ts. */
282
+ owner: z2.string(),
230
283
  ip: z2.string(),
231
284
  mac: z2.string(),
232
285
  pid: z2.number().int().nullable(),
@@ -246,6 +299,8 @@ var MachineMetaSchema = z2.object({
246
299
  });
247
300
  var SnapshotMetaSchema = z2.object({
248
301
  name: z2.string(),
302
+ owner: z2.string(),
303
+ /** The box it was taken from, as owner/name. */
249
304
  source: z2.string(),
250
305
  base: z2.string(),
251
306
  vcpu: z2.number().int(),
@@ -271,25 +326,36 @@ async function listDirs(dir) {
271
326
  throw err;
272
327
  }
273
328
  }
274
- async function readMachineMeta(name, p = paths()) {
275
- return readJson(p.machine(name).meta, MachineMetaSchema);
329
+ async function readMachineMeta(ref, p = paths()) {
330
+ return readJson(p.machine(ref.owner, ref.name).meta, MachineMetaSchema);
276
331
  }
277
332
  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");
333
+ const m = p.machine(meta.owner, meta.name);
334
+ await mkdir(m.dir, { recursive: true });
335
+ await writeFile2(m.meta, JSON.stringify(meta, null, 2) + "\n");
280
336
  }
281
- async function listMachineNames(p = paths()) {
282
- return listDirs(p.machines);
337
+ async function listMachineRefs(p = paths()) {
338
+ return listRefs(p.machines);
283
339
  }
284
- async function readSnapshotMeta(name, p = paths()) {
285
- return readJson(p.snapshot(name).meta, SnapshotMetaSchema);
340
+ async function readSnapshotMeta(ref, p = paths()) {
341
+ return readJson(p.snapshot(ref.owner, ref.name).meta, SnapshotMetaSchema);
286
342
  }
287
343
  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");
344
+ const s = p.snapshot(meta.owner, meta.name);
345
+ await mkdir(s.dir, { recursive: true });
346
+ await writeFile2(s.meta, JSON.stringify(meta, null, 2) + "\n");
347
+ }
348
+ async function listSnapshotRefs(p = paths()) {
349
+ return listRefs(p.snapshots);
290
350
  }
291
- async function listSnapshotNames(p = paths()) {
292
- return listDirs(p.snapshots);
351
+ async function listRefs(root) {
352
+ const out = [];
353
+ for (const owner of await listDirs(root)) {
354
+ for (const name of await listDirs(`${root}/${owner}`)) {
355
+ out.push({ owner, name });
356
+ }
357
+ }
358
+ return out;
293
359
  }
294
360
  function pidAlive(pid) {
295
361
  if (pid === null || pid <= 0) return false;
@@ -301,33 +367,151 @@ function pidAlive(pid) {
301
367
  }
302
368
  }
303
369
 
304
- // src/app.ts
305
- import { Hono as Hono4 } from "hono";
306
-
307
- // ../shared/src/version.ts
308
- import { createRequire } from "module";
309
- function versionOf(srcFileUrl, injected) {
310
- if (typeof injected === "string") return injected;
370
+ // src/core/migrate.ts
371
+ import { existsSync as existsSync2 } from "node:fs";
372
+ import { mkdir as mkdir2, readdir as readdir2, readFile as readFile3, rename, writeFile as writeFile3 } from "node:fs/promises";
373
+ import { join as join2 } from "node:path";
374
+ async function migrateOwnerLayout(owner, p = paths()) {
375
+ const done = { boxes: [], snapshots: [], conflicts: [] };
376
+ for (const name of await oldDepthEntries(p.machines)) {
377
+ const from = join2(p.machines, name);
378
+ const to = p.machine(owner, name).dir;
379
+ if (existsSync2(to)) {
380
+ done.conflicts.push(`machines/${name}`);
381
+ continue;
382
+ }
383
+ await patchJson(join2(from, "meta.json"), (meta) => ({
384
+ ...meta,
385
+ owner,
386
+ // a restored box names its snapshot: that moved under the same owner
387
+ source: typeof meta["source"] === "string" && meta["source"].startsWith("snapshot:") && !meta["source"].includes("/") ? `snapshot:${owner}/${meta["source"].slice("snapshot:".length)}` : meta["source"]
388
+ }));
389
+ await mkdir2(p.ownerMachines(owner), { recursive: true, mode: 448 });
390
+ await rename(from, to);
391
+ done.boxes.push(name);
392
+ }
393
+ for (const name of await oldDepthEntries(p.snapshots)) {
394
+ const from = join2(p.snapshots, name);
395
+ const to = p.snapshot(owner, name).dir;
396
+ if (existsSync2(to)) {
397
+ done.conflicts.push(`snapshots/${name}`);
398
+ continue;
399
+ }
400
+ await patchJson(join2(from, "meta.json"), (meta) => ({
401
+ ...meta,
402
+ owner,
403
+ source: typeof meta["source"] === "string" && !meta["source"].includes("/") ? `${owner}/${meta["source"]}` : meta["source"]
404
+ }));
405
+ await mkdir2(p.ownerSnapshots(owner), { recursive: true, mode: 448 });
406
+ await rename(from, to);
407
+ done.snapshots.push(name);
408
+ }
409
+ return done;
410
+ }
411
+ async function oldDepthEntries(root) {
412
+ let entries;
311
413
  try {
312
- const require2 = createRequire(srcFileUrl);
313
- return require2("../package.json").version;
314
- } catch {
315
- return "0.0.0-dev";
414
+ entries = await readdir2(root, { withFileTypes: true });
415
+ } catch (err) {
416
+ if (err.code === "ENOENT") return [];
417
+ throw err;
316
418
  }
419
+ return entries.filter((e) => e.isDirectory() && existsSync2(join2(root, e.name, "meta.json"))).map((e) => e.name).sort();
420
+ }
421
+ async function patchJson(file, patch) {
422
+ const raw = JSON.parse(await readFile3(file, "utf8"));
423
+ await writeFile3(file, JSON.stringify(patch(raw), null, 2) + "\n");
317
424
  }
318
425
 
319
- // src/version.ts
320
- var VERSION = versionOf(import.meta.url, true ? "0.0.15" : void 0);
426
+ // src/core/db.ts
427
+ import { chmodSync, closeSync, mkdirSync, openSync } from "node:fs";
428
+ import { dirname } from "node:path";
429
+ import { DatabaseSync } from "node:sqlite";
430
+ var MIGRATIONS = [
431
+ `create table usage_events (
432
+ id integer primary key,
433
+ box_id text not null,
434
+ owner text not null,
435
+ name text not null,
436
+ at text not null,
437
+ state text not null,
438
+ vcpu integer not null,
439
+ mem_mib integer not null,
440
+ disk_gib integer not null,
441
+ by text not null
442
+ );
443
+ create index usage_events_box_at on usage_events (box_id, at);`
444
+ ];
445
+ var handles = /* @__PURE__ */ new Map();
446
+ function openDb(p = paths()) {
447
+ const have = handles.get(p.db);
448
+ if (have !== void 0) return have;
449
+ mkdirSync(dirname(p.db), { recursive: true, mode: 448 });
450
+ closeSync(openSync(p.db, "a", 384));
451
+ chmodSync(p.db, 384);
452
+ const db = new DatabaseSync(p.db);
453
+ db.exec("pragma journal_mode = wal");
454
+ db.exec("pragma busy_timeout = 5000");
455
+ migrate(db);
456
+ handles.set(p.db, db);
457
+ return db;
458
+ }
459
+ function migrate(db) {
460
+ db.exec("create table if not exists schema_version (version integer not null)");
461
+ const row = db.prepare("select version from schema_version").get();
462
+ let version = row?.version ?? 0;
463
+ if (row === void 0) db.prepare("insert into schema_version (version) values (0)").run();
464
+ while (version < MIGRATIONS.length) {
465
+ db.exec("begin");
466
+ try {
467
+ db.exec(MIGRATIONS[version]);
468
+ version += 1;
469
+ db.prepare("update schema_version set version = ?").run(version);
470
+ db.exec("commit");
471
+ } catch (err) {
472
+ db.exec("rollback");
473
+ throw err;
474
+ }
475
+ }
476
+ }
477
+ function fromRaw(r) {
478
+ return { boxId: r.box_id, owner: r.owner, name: r.name, at: r.at, state: r.state, vcpu: r.vcpu, memMib: r.mem_mib, diskGib: r.disk_gib, by: r.by };
479
+ }
480
+ function lastEvent(boxId, db) {
481
+ const raw = db.prepare("select * from usage_events where box_id = ? order by at desc, id desc limit 1").get(boxId);
482
+ return raw === void 0 ? void 0 : fromRaw(raw);
483
+ }
484
+ function insertRaw(e, db) {
485
+ db.prepare(
486
+ "insert into usage_events (box_id, owner, name, at, state, vcpu, mem_mib, disk_gib, by) values (?, ?, ?, ?, ?, ?, ?, ?, ?)"
487
+ ).run(e.boxId, e.owner, e.name, e.at, e.state, e.vcpu, e.memMib, e.diskGib, e.by);
488
+ }
489
+ function insertUsageEvent(e, p = paths()) {
490
+ const db = openDb(p);
491
+ if (e.state === "running") closeOpenRun(e.boxId, e.at, p);
492
+ insertRaw(e, db);
493
+ }
494
+ function closeOpenRun(boxId, at, p = paths()) {
495
+ const db = openDb(p);
496
+ const last = lastEvent(boxId, db);
497
+ if (last === void 0 || last.state !== "running") return false;
498
+ insertRaw({ ...last, at, state: "stopped", by: "server" }, db);
499
+ return true;
500
+ }
501
+ function usageEventsFor(boxId, since, p = paths()) {
502
+ const rows = openDb(p).prepare("select * from usage_events where box_id = ? and at >= ? order by at, id").all(boxId, since);
503
+ return rows.map(fromRaw);
504
+ }
321
505
 
322
506
  // src/core/machines.ts
323
- 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";
326
- import { basename } from "path";
327
- import { setTimeout as sleep } from "timers/promises";
507
+ import { spawn } from "node:child_process";
508
+ import { closeSync as closeSync2, existsSync as existsSync3, openSync as openSync2, statSync } from "node:fs";
509
+ import { link, mkdir as mkdir3, readFile as readFile4, realpath, rm, truncate, writeFile as writeFile4 } from "node:fs/promises";
510
+ import { basename } from "node:path";
511
+ import { setTimeout as sleep } from "node:timers/promises";
328
512
 
329
513
  // src/core/agent.ts
330
- import { connect } from "net";
514
+ import { connect } from "node:net";
331
515
  var AGENT_PORT = 52;
332
516
  async function agentCall(udsPath, req, timeoutMs = 5e3) {
333
517
  const reply = await new Promise((resolve, reject) => {
@@ -377,7 +561,7 @@ function setIdentity(udsPath, id, timeoutMs) {
377
561
  }
378
562
 
379
563
  // src/core/capacity.ts
380
- import { cpus, totalmem } from "os";
564
+ import { cpus, totalmem } from "node:os";
381
565
  var MEM_HEADROOM_MIB = 2048;
382
566
  var VCPU_OVERSUBSCRIPTION = 2;
383
567
  function hostResources() {
@@ -385,9 +569,9 @@ function hostResources() {
385
569
  }
386
570
  async function runningReservations(p = paths()) {
387
571
  const out = [];
388
- for (const name of await listMachineNames(p)) {
572
+ for (const ref of await listMachineRefs(p)) {
389
573
  try {
390
- const meta = await readMachineMeta(name, p);
574
+ const meta = await readMachineMeta(ref, p);
391
575
  if (pidAlive(meta.pid)) out.push({ name: meta.name, vcpu: meta.vcpu, memMib: meta.memMib });
392
576
  } catch {
393
577
  }
@@ -570,13 +754,35 @@ function diskStats(disk) {
570
754
  }
571
755
  }
572
756
  function ownDisk(meta, p) {
573
- const m = p.machine(meta.name);
757
+ const m = p.machine(meta.owner, meta.name);
574
758
  return meta.base === void 0 ? m.disk : m.overlay;
575
759
  }
760
+ function diskGibOf(meta, p) {
761
+ try {
762
+ return Math.round(statSync(ownDisk(meta, p)).size / 1024 ** 3);
763
+ } catch {
764
+ return 0;
765
+ }
766
+ }
767
+ function recordState(meta, state, by, p, at = (/* @__PURE__ */ new Date()).toISOString()) {
768
+ try {
769
+ insertUsageEvent({ boxId: refKey(meta), owner: meta.owner, name: meta.name, at, state, by, vcpu: meta.vcpu, memMib: meta.memMib, diskGib: diskGibOf(meta, p) }, p);
770
+ } catch (err) {
771
+ console.error(`warning: usage ledger: ${err.message}`);
772
+ }
773
+ }
576
774
  function toMachine(meta, p = paths()) {
577
775
  const alive = pidAlive(meta.pid);
776
+ if (!alive && meta.pid !== null) {
777
+ try {
778
+ closeOpenRun(refKey(meta), (/* @__PURE__ */ new Date()).toISOString(), p);
779
+ } catch (err) {
780
+ console.error(`warning: usage ledger: ${err.message}`);
781
+ }
782
+ }
578
783
  return {
579
784
  name: meta.name,
785
+ owner: meta.owner,
580
786
  ip: meta.ip,
581
787
  mac: meta.mac,
582
788
  pid: alive ? meta.pid : null,
@@ -591,17 +797,20 @@ function toMachine(meta, p = paths()) {
591
797
  }
592
798
  async function readAllMeta(p) {
593
799
  const metas = [];
594
- for (const name of await listMachineNames(p)) {
800
+ for (const ref of await listMachineRefs(p)) {
595
801
  try {
596
- metas.push(await readMachineMeta(name, p));
802
+ metas.push(await readMachineMeta(ref, p));
597
803
  } catch (err) {
598
- console.error(`warning: skipping machines/${name}: ${err.message}`);
804
+ console.error(`warning: skipping machines/${refKey(ref)}: ${err.message}`);
599
805
  }
600
806
  }
601
807
  return metas;
602
808
  }
603
- async function listMachines(p = paths()) {
604
- return (await readAllMeta(p)).map((m) => toMachine(m, p));
809
+ async function listMachines(owner, p = paths()) {
810
+ return (await readAllMeta(p)).filter((m) => owner === null || m.owner === owner).map((m) => toMachine(m, p));
811
+ }
812
+ async function getMachine(ref, p = paths()) {
813
+ return toMachine(await readMeta(ref, p), p);
605
814
  }
606
815
  function pickName(requested, taken) {
607
816
  if (requested !== void 0) {
@@ -636,7 +845,7 @@ async function currentBase(cfg, p) {
636
845
  function baseFileFor(meta, p) {
637
846
  if (meta.base === void 0) return void 0;
638
847
  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}`);
848
+ if (!existsSync3(file)) throw new KvboxError(`box ${meta.name} runs on base image ${meta.base}, which is gone from ${p.assets}`);
640
849
  return file;
641
850
  }
642
851
  var overlayCapable = /* @__PURE__ */ new Map();
@@ -650,7 +859,7 @@ async function assertOverlayCapable(baseFile, version) {
650
859
  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
860
  }
652
861
  async function createOverlay(file, gib) {
653
- await writeFile3(file, "", { mode: 384 });
862
+ await writeFile4(file, "", { mode: 384 });
654
863
  await truncate(file, gib * 1024 ** 3);
655
864
  await run(["mkfs.ext4", "-q", "-F", "-m", "0", "-L", "kvbox-box", "-E", "lazy_itable_init=1,lazy_journal_init=1", file]);
656
865
  }
@@ -664,30 +873,30 @@ async function waitFor(pred, timeoutMs, stepMs = 50) {
664
873
  }
665
874
  async function tailOf(file, lines = 5) {
666
875
  try {
667
- return (await readFile3(file, "utf8")).trim().split("\n").slice(-lines).join("\n");
876
+ return (await readFile4(file, "utf8")).trim().split("\n").slice(-lines).join("\n");
668
877
  } catch {
669
878
  return "";
670
879
  }
671
880
  }
672
- async function spawnFirecracker(name, cfg, p) {
673
- const m = p.machine(name);
881
+ async function spawnFirecracker(ref, cfg, p) {
882
+ const m = p.machine(ref.owner, ref.name);
674
883
  await rm(m.sock, { force: true });
675
884
  await rm(m.vsock, { force: true });
676
- const logFd = openSync(m.log, "a");
885
+ const logFd = openSync2(m.log, "a");
677
886
  let child;
678
887
  try {
679
- child = spawn(cfg.firecracker, ["--api-sock", m.sock, "--id", name, "--level", "Error"], {
888
+ child = spawn(cfg.firecracker, ["--api-sock", m.sock, "--id", ref.name, "--level", "Error"], {
680
889
  cwd: m.dir,
681
890
  detached: true,
682
891
  stdio: ["ignore", logFd, logFd]
683
892
  });
684
893
  } finally {
685
- closeSync(logFd);
894
+ closeSync2(logFd);
686
895
  }
687
896
  child.unref();
688
897
  const pid = child.pid;
689
898
  if (pid === void 0) throw new KvboxError("could not start firecracker");
690
- if (!await waitFor(() => existsSync2(m.sock), 3e3)) {
899
+ if (!await waitFor(() => existsSync3(m.sock), 3e3)) {
691
900
  killQuietly(pid);
692
901
  throw new KvboxError(`firecracker did not open its API socket; last log lines:
693
902
  ${await tailOf(m.log)}`);
@@ -701,9 +910,9 @@ function killQuietly(pid) {
701
910
  }
702
911
  }
703
912
  async function launch(spec, tap, cfg, pubkeyB64, p) {
704
- const m = p.machine(spec.name);
913
+ const m = p.machine(spec.owner, spec.name);
705
914
  const baseFile = baseFileFor(spec, p);
706
- const pid = await spawnFirecracker(spec.name, cfg, p);
915
+ const pid = await spawnFirecracker(spec, cfg, p);
707
916
  try {
708
917
  const fc = new FirecrackerClient(m.sock);
709
918
  try {
@@ -727,10 +936,10 @@ async function launch(spec, tap, cfg, pubkeyB64, p) {
727
936
  throw err;
728
937
  }
729
938
  }
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);
939
+ async function loadSnapshot(spec, snapshot, tap, cfg, p) {
940
+ const m = p.machine(spec.owner, spec.name);
941
+ const s = p.snapshot(snapshot.owner, snapshot.name);
942
+ const pid = await spawnFirecracker(spec, cfg, p);
734
943
  try {
735
944
  const fc = new FirecrackerClient(m.sock);
736
945
  try {
@@ -744,7 +953,7 @@ async function loadSnapshot(spec, snapshotName, tap, cfg, p) {
744
953
  resume: false
745
954
  });
746
955
  } catch (err) {
747
- if (/Unknown Vsock Device/.test(err.message)) throw new KvboxError(tooOldToRestore(snapshotName));
956
+ if (/Unknown Vsock Device/.test(err.message)) throw new KvboxError(tooOldToRestore(shown(snapshot)));
748
957
  throw err;
749
958
  }
750
959
  if (await namesOverlayAbsolutely(s.vmstate)) await fc.patchDrivePath("overlay", m.overlay);
@@ -764,23 +973,23 @@ async function currentBaseVersion(p = paths()) {
764
973
  return (await currentBase(await loadConfig(p), p)).version;
765
974
  }
766
975
  async function namesOverlayAbsolutely(vmstate) {
767
- return (await readFile3(vmstate)).includes(`/${OVERLAY_REL}`);
976
+ return (await readFile4(vmstate)).includes(`/${OVERLAY_REL}`);
768
977
  }
769
978
  async function readPubkeyB64(p) {
770
979
  try {
771
- return Buffer.from((await readFile3(`${p.sshKey}.pub`, "utf8")).trim()).toString("base64");
980
+ return Buffer.from((await readFile4(`${p.sshKey}.pub`, "utf8")).trim()).toString("base64");
772
981
  } catch {
773
982
  throw new KvboxError(`SSH key missing at ${p.sshKey}.pub; run install-server.sh`);
774
983
  }
775
984
  }
776
985
  function assertAssets(cfg) {
777
986
  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`);
987
+ if (!existsSync3(file)) throw new KvboxError(`${what} missing at ${file}; run install-server.sh`);
779
988
  }
780
989
  }
781
- async function createMachine(req, p = paths()) {
990
+ async function createMachine(req, owner, p = paths()) {
782
991
  const cfg = await loadConfig(p);
783
- if (req.from !== void 0) return restoreMachine(req.from, req, cfg, p);
992
+ if (req.from !== void 0) return restoreMachine(req.from, owner, req, cfg, p);
784
993
  const sizeName = req.size ?? cfg.defaultSize;
785
994
  if (!isSizeName(sizeName)) throw new KvboxError(`unknown size "${sizeName}"; use one of ${Object.keys(SIZES).join(", ")}`);
786
995
  const size = SIZES[sizeName];
@@ -788,23 +997,24 @@ async function createMachine(req, p = paths()) {
788
997
  await assertRoomFor(size.vcpu, size.memMib, req.force ?? false, p);
789
998
  const pubkeyB64 = await readPubkeyB64(p);
790
999
  const existing = await readAllMeta(p);
791
- const name = pickName(req.name, new Set(existing.map((m2) => m2.name)));
1000
+ const name = pickName(req.name, new Set(existing.filter((m2) => m2.owner === owner).map((m2) => m2.name)));
792
1001
  const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
793
1002
  const mac = macFor(ip);
794
- const m = p.machine(name);
1003
+ const m = p.machine(owner, name);
795
1004
  const base = await currentBase(cfg, p);
796
1005
  await assertOverlayCapable(base.file, base.version);
797
1006
  await ensureHostNetwork(cfg);
798
- await mkdir2(m.dir, { recursive: true, mode: 448 });
1007
+ await mkdir3(m.dir, { recursive: true, mode: 448 });
799
1008
  let tapCreated = false;
800
1009
  try {
801
1010
  await createOverlay(m.overlay, size.diskGib);
802
1011
  const tap = await createTap(name, cfg);
803
1012
  tapCreated = true;
804
- const spec = { name, ip, mac, vcpu: size.vcpu, memMib: size.memMib, base: base.version };
1013
+ const spec = { name, owner, ip, mac, vcpu: size.vcpu, memMib: size.memMib, base: base.version };
805
1014
  const pid = await launch(spec, tap, cfg, pubkeyB64, p);
806
1015
  const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: "base" };
807
1016
  await writeMachineMeta(meta, p);
1017
+ recordState(meta, "running", "new", p, meta.createdAt);
808
1018
  return toMachine(meta, p);
809
1019
  } catch (err) {
810
1020
  if (tapCreated) await deleteTap(name, cfg).catch(() => void 0);
@@ -812,35 +1022,36 @@ async function createMachine(req, p = paths()) {
812
1022
  throw err;
813
1023
  }
814
1024
  }
815
- async function readSnap(name, p) {
1025
+ async function readSnap(ref, p) {
816
1026
  try {
817
- return await readSnapshotMeta(name, p);
1027
+ return await readSnapshotMeta(ref, p);
818
1028
  } catch (err) {
819
- if (err.code === "ENOENT") throw new NotFoundError(`snapshot ${name}`);
1029
+ if (err.code === "ENOENT") throw new NotFoundError(`snapshot ${shown(ref)}`);
820
1030
  throw err;
821
1031
  }
822
1032
  }
823
1033
  function prefixLenOf(subnet) {
824
1034
  return Number(subnet.split("/")[1]);
825
1035
  }
826
- async function restoreMachine(from, req, cfg, p) {
1036
+ async function restoreMachine(fromText, owner, req, cfg, p) {
1037
+ const from = parseRef(fromText, owner);
827
1038
  const snap = await readSnap(from, p);
828
1039
  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)`);
1040
+ 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
1041
  }
831
1042
  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}`);
1043
+ if (!existsSync3(p.baseImage(snap.base))) {
1044
+ throw new KvboxError(`snapshot ${shown(from)} stacks on base image ${snap.base}, which is gone from ${p.assets}`);
834
1045
  }
835
1046
  await assertRoomFor(snap.vcpu, snap.memMib, req.force ?? false, p);
836
1047
  const existing = await readAllMeta(p);
837
- const name = pickName(req.name, new Set(existing.map((m2) => m2.name)));
1048
+ const name = pickName(req.name, new Set(existing.filter((m2) => m2.owner === owner).map((m2) => m2.name)));
838
1049
  const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
839
1050
  const mac = macFor(ip);
840
- const m = p.machine(name);
841
- const s = p.snapshot(from);
1051
+ const m = p.machine(owner, name);
1052
+ const s = p.snapshot(from.owner, from.name);
842
1053
  await ensureHostNetwork(cfg);
843
- await mkdir2(m.dir, { recursive: true, mode: 448 });
1054
+ await mkdir3(m.dir, { recursive: true, mode: 448 });
844
1055
  let tapCreated = false;
845
1056
  let pid;
846
1057
  try {
@@ -848,7 +1059,7 @@ async function restoreMachine(from, req, cfg, p) {
848
1059
  await link(s.mem, m.memBase);
849
1060
  const tap = await createTap(name, cfg, { detached: true });
850
1061
  tapCreated = true;
851
- const spec = { name, ip, mac, vcpu: snap.vcpu, memMib: snap.memMib, base: snap.base };
1062
+ const spec = { name, owner, ip, mac, vcpu: snap.vcpu, memMib: snap.memMib, base: snap.base };
852
1063
  pid = await loadSnapshot(spec, from, tap, cfg, p);
853
1064
  const fc = new FirecrackerClient(m.sock);
854
1065
  try {
@@ -856,10 +1067,11 @@ async function restoreMachine(from, req, cfg, p) {
856
1067
  } finally {
857
1068
  await fc.close();
858
1069
  }
859
- await introduce(spec, m.vsock, cfg, from);
1070
+ await introduce(spec, m.vsock, cfg, shown(from));
860
1071
  await attachTap(name, cfg);
861
- const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: `snapshot:${from}`, memSnapshots: 1 };
1072
+ const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: `snapshot:${refKey(from)}`, memSnapshots: 1 };
862
1073
  await writeMachineMeta(meta, p);
1074
+ recordState(meta, "running", "new", p, meta.createdAt);
863
1075
  return toMachine(meta, p);
864
1076
  } catch (err) {
865
1077
  if (pid !== void 0) killQuietly(pid);
@@ -893,7 +1105,7 @@ async function introduce(spec, vsock, cfg, from) {
893
1105
  async function shutdown(meta, cfg, p, graceMs) {
894
1106
  const pid = meta.pid;
895
1107
  if (pidAlive(pid)) {
896
- const fc = new FirecrackerClient(p.machine(meta.name).sock);
1108
+ const fc = new FirecrackerClient(p.machine(meta.owner, meta.name).sock);
897
1109
  try {
898
1110
  await fc.sendCtrlAltDel();
899
1111
  } catch {
@@ -910,62 +1122,66 @@ async function shutdown(meta, cfg, p, graceMs) {
910
1122
  }
911
1123
  }
912
1124
  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 });
1125
+ const m = p.machine(meta.owner, meta.name);
1126
+ await rm(m.sock, { force: true });
1127
+ await rm(m.vsock, { force: true });
915
1128
  }
916
- async function stopMachine(name, p = paths()) {
917
- const meta = await readMeta(name, p);
1129
+ async function stopMachine(ref, p = paths()) {
1130
+ const meta = await readMeta(ref, p);
918
1131
  const cfg = await loadConfig(p);
919
- if (!pidAlive(meta.pid)) throw new KvboxError(`box ${name} is already stopped`);
1132
+ if (!pidAlive(meta.pid)) throw new KvboxError(`box ${shown(ref)} is already stopped`);
920
1133
  await shutdown(meta, cfg, p, 1e4);
921
- await rm(p.machine(name).memBase, { force: true });
1134
+ await rm(p.machine(ref.owner, ref.name).memBase, { force: true });
922
1135
  const stopped = { ...meta, pid: null, memSnapshots: void 0 };
923
1136
  await writeMachineMeta(stopped, p);
1137
+ recordState(stopped, "stopped", "stop", p);
924
1138
  return toMachine(stopped, p);
925
1139
  }
926
- async function startMachine(name, p = paths()) {
927
- const meta = await readMeta(name, p);
1140
+ async function startMachine(ref, p = paths()) {
1141
+ const meta = await readMeta(ref, p);
928
1142
  const cfg = await loadConfig(p);
929
- if (pidAlive(meta.pid)) throw new KvboxError(`box ${name} is already running`);
1143
+ if (pidAlive(meta.pid)) throw new KvboxError(`box ${shown(ref)} is already running`);
930
1144
  assertAssets(cfg);
931
1145
  const pubkeyB64 = await readPubkeyB64(p);
932
1146
  await ensureHostNetwork(cfg);
933
- const tap = await createTap(name, cfg);
1147
+ const tap = await createTap(ref.name, cfg);
934
1148
  try {
935
1149
  const pid = await launch(meta, tap, cfg, pubkeyB64, p);
936
1150
  const started = { ...meta, pid };
937
1151
  await writeMachineMeta(started, p);
1152
+ recordState(started, "running", "start", p);
938
1153
  return toMachine(started, p);
939
1154
  } catch (err) {
940
- await deleteTap(name, cfg).catch(() => void 0);
1155
+ await deleteTap(ref.name, cfg).catch(() => void 0);
941
1156
  throw err;
942
1157
  }
943
1158
  }
944
- async function applyAtNextBoot(meta, changes, cfg, p, whileStopped) {
1159
+ async function applyAtNextBoot(meta, changes, by, cfg, p, whileStopped) {
945
1160
  const wasRunning = pidAlive(meta.pid);
946
1161
  if (wasRunning) {
947
1162
  await shutdown(meta, cfg, p, 1e4);
948
- await rm(p.machine(meta.name).memBase, { force: true });
1163
+ await rm(p.machine(meta.owner, meta.name).memBase, { force: true });
949
1164
  }
950
1165
  const updated = { ...meta, ...changes, pid: null, memSnapshots: void 0 };
951
1166
  await writeMachineMeta(updated, p);
952
1167
  if (whileStopped) await whileStopped();
953
- return wasRunning ? startMachine(meta.name, p) : toMachine(updated, p);
1168
+ recordState(updated, "stopped", by, p);
1169
+ return wasRunning ? startMachine(meta, p) : toMachine(updated, p);
954
1170
  }
955
- async function updateMachine(name, p = paths()) {
956
- const meta = await readMeta(name, p);
1171
+ async function updateMachine(ref, p = paths()) {
1172
+ const meta = await readMeta(ref, p);
957
1173
  const cfg = await loadConfig(p);
958
1174
  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`);
1175
+ 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
1176
  }
961
1177
  assertAssets(cfg);
962
1178
  const base = await currentBase(cfg, p);
963
1179
  if (base.version === meta.base) return toMachine(meta, p);
964
1180
  await assertOverlayCapable(base.file, base.version);
965
- return applyAtNextBoot(meta, { base: base.version }, cfg, p);
1181
+ return applyAtNextBoot(meta, { base: base.version }, "update", cfg, p);
966
1182
  }
967
- async function rescaleMachine(name, req, p = paths()) {
968
- const meta = await readMeta(name, p);
1183
+ async function rescaleMachine(ref, req, p = paths()) {
1184
+ const meta = await readMeta(ref, p);
969
1185
  const cfg = await loadConfig(p);
970
1186
  const disk = ownDisk(meta, p);
971
1187
  const currentDiskGib = Math.round(statSync(disk).size / 1024 ** 3);
@@ -976,33 +1192,130 @@ async function rescaleMachine(name, req, p = paths()) {
976
1192
  diskGib: req.diskGib ?? preset?.diskGib ?? currentDiskGib
977
1193
  };
978
1194
  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`);
1195
+ throw new KvboxError(`box ${shown(ref)} has a ${currentDiskGib} GB disk and a disk cannot shrink; ${target.diskGib} GB is smaller`);
980
1196
  }
981
1197
  const changes = {};
982
1198
  if (target.vcpu !== meta.vcpu) changes.vcpu = target.vcpu;
983
1199
  if (target.memMib !== meta.memMib) changes.memMib = target.memMib;
984
1200
  const growTo = target.diskGib > currentDiskGib ? target.diskGib : void 0;
985
1201
  if (Object.keys(changes).length === 0 && growTo === void 0) return toMachine(meta, p);
986
- return applyAtNextBoot(meta, changes, cfg, p, growTo === void 0 ? void 0 : () => growDisk(disk, growTo));
1202
+ return applyAtNextBoot(meta, changes, "rescale", cfg, p, growTo === void 0 ? void 0 : () => growDisk(disk, growTo));
987
1203
  }
988
1204
  async function growDisk(file, gib) {
989
1205
  await truncate(file, gib * 1024 ** 3);
990
1206
  await run(["e2fsck", "-f", "-p", file], { okExitCodes: [1] });
991
1207
  await run(["resize2fs", file]);
992
1208
  }
993
- async function readMeta(name, p) {
1209
+ async function readMeta(ref, p) {
994
1210
  try {
995
- return await readMachineMeta(name, p);
1211
+ return await readMachineMeta(ref, p);
996
1212
  } catch (err) {
997
- if (err.code === "ENOENT") throw new NotFoundError(`box ${name}`);
1213
+ if (err.code === "ENOENT") throw new NotFoundError(`box ${shown(ref)}`);
998
1214
  throw err;
999
1215
  }
1000
1216
  }
1001
- async function destroyMachine(name, p = paths()) {
1002
- const meta = await readMeta(name, p);
1217
+ async function destroyMachine(ref, p = paths()) {
1218
+ const meta = await readMeta(ref, p);
1003
1219
  const cfg = await loadConfig(p);
1004
1220
  await shutdown(meta, cfg, p, 2e3);
1005
- await rm(p.machine(name).dir, { recursive: true, force: true });
1221
+ recordState(meta, "stopped", "rm", p);
1222
+ await rm(p.machine(ref.owner, ref.name).dir, { recursive: true, force: true });
1223
+ }
1224
+
1225
+ // src/core/usage.ts
1226
+ var HOUR_MS = 36e5;
1227
+ var round = (n, places) => Math.round(n * 10 ** places) / 10 ** places;
1228
+ function computeUsage(events, w) {
1229
+ const sorted = [...events].sort((a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0);
1230
+ const from = Date.parse(w.from);
1231
+ const to = Date.parse(w.to);
1232
+ const ms = { running: 0, stopped: 0 };
1233
+ let vcpuHours = 0;
1234
+ let ramGibHours = 0;
1235
+ let diskGibHours = 0;
1236
+ sorted.forEach((e, i) => {
1237
+ const start = Math.max(Date.parse(e.at), from);
1238
+ const next = sorted[i + 1];
1239
+ const end = Math.min(next === void 0 ? to : Date.parse(next.at), to);
1240
+ if (end <= start) return;
1241
+ const h = (end - start) / HOUR_MS;
1242
+ ms[e.state] += end - start;
1243
+ if (e.state === "running") {
1244
+ vcpuHours += h * e.vcpu;
1245
+ ramGibHours += h * e.memMib / 1024;
1246
+ }
1247
+ diskGibHours += h * e.diskGib;
1248
+ });
1249
+ const totals = {
1250
+ seconds: { running: Math.round(ms.running / 1e3), stopped: Math.round(ms.stopped / 1e3) },
1251
+ vcpuHours: round(vcpuHours, 3),
1252
+ ramGibHours: round(ramGibHours, 3),
1253
+ diskGibHours: round(diskGibHours, 3),
1254
+ estimatedStops: sorted.filter((e) => e.state === "stopped" && e.by === "server" && e.at >= w.from).length,
1255
+ historyFrom: sorted[0]?.at ?? null
1256
+ };
1257
+ if (w.pricing !== void 0) {
1258
+ const amount = vcpuHours * w.pricing.vcpuHour + ramGibHours * w.pricing.ramGibHour + diskGibHours * w.pricing.diskGibHour;
1259
+ totals.cost = { amount: round(amount, 4), currency: w.pricing.currency };
1260
+ }
1261
+ return totals;
1262
+ }
1263
+ var GIB = 1024 ** 3;
1264
+ async function machineUsage(ref, since, p = paths()) {
1265
+ const box = await getMachine(ref, p);
1266
+ const cfg = await loadConfig(p);
1267
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1268
+ const boxId = refKey(ref);
1269
+ let events = usageEventsFor(boxId, box.createdAt, p);
1270
+ if (events.length === 0) {
1271
+ insertUsageEvent(
1272
+ { boxId, owner: box.owner, name: box.name, at: now, state: box.status, by: "upgrade", vcpu: box.vcpu, memMib: box.memMib, diskGib: Math.round(box.diskBytes / GIB) },
1273
+ p
1274
+ );
1275
+ events = usageEventsFor(boxId, box.createdAt, p);
1276
+ }
1277
+ const window = { from: since ?? box.createdAt, to: now };
1278
+ return { name: box.name, owner: box.owner, status: box.status, window, ...computeUsage(events, { ...window, pricing: cfg.pricing }) };
1279
+ }
1280
+ async function seedUsageLedger(p = paths()) {
1281
+ let seeded = 0;
1282
+ for (const box of await listMachines(null, p)) {
1283
+ const boxId = `${box.owner}/${box.name}`;
1284
+ if (usageEventsFor(boxId, box.createdAt, p).length > 0) continue;
1285
+ insertUsageEvent(
1286
+ { boxId, owner: box.owner, name: box.name, at: (/* @__PURE__ */ new Date()).toISOString(), state: box.status, by: "upgrade", vcpu: box.vcpu, memMib: box.memMib, diskGib: Math.round(box.diskBytes / GIB) },
1287
+ p
1288
+ );
1289
+ seeded += 1;
1290
+ }
1291
+ return seeded;
1292
+ }
1293
+
1294
+ // src/app.ts
1295
+ import { Hono as Hono4 } from "hono";
1296
+
1297
+ // ../shared/src/version.ts
1298
+ import { createRequire } from "node:module";
1299
+ function versionOf(srcFileUrl, injected) {
1300
+ if (typeof injected === "string") return injected;
1301
+ try {
1302
+ const require2 = createRequire(srcFileUrl);
1303
+ return require2("../package.json").version;
1304
+ } catch {
1305
+ return "0.0.0-dev";
1306
+ }
1307
+ }
1308
+
1309
+ // src/version.ts
1310
+ var VERSION = versionOf(import.meta.url, true ? "0.0.17" : void 0);
1311
+
1312
+ // src/caller.ts
1313
+ function resolveCaller() {
1314
+ return async (c, next) => {
1315
+ const cfg = await loadConfig();
1316
+ c.set("owner", cfg.owner);
1317
+ await next();
1318
+ };
1006
1319
  }
1007
1320
 
1008
1321
  // src/routes/capacity.ts
@@ -1021,6 +1334,8 @@ import { z as z3 } from "zod";
1021
1334
  var SizeSchema = z3.enum(SIZE_NAMES);
1022
1335
  var MachineSchema = z3.object({
1023
1336
  name: z3.string(),
1337
+ /** Who the box belongs to; names are unique per owner. A bare name is local to the host, `user@control-plane` later. */
1338
+ owner: z3.string(),
1024
1339
  ip: z3.string(),
1025
1340
  mac: z3.string(),
1026
1341
  pid: z3.number().int().nullable(),
@@ -1039,6 +1354,8 @@ var MachineSchema = z3.object({
1039
1354
  });
1040
1355
  var CreateMachineRequest = z3.object({
1041
1356
  name: z3.string().optional(),
1357
+ /** Ignored over the local socket (the caller is the host owner); the control plane sets it over HTTPS. */
1358
+ owner: z3.string().optional(),
1042
1359
  size: SizeSchema.optional(),
1043
1360
  from: z3.string().optional(),
1044
1361
  force: z3.boolean().optional()
@@ -1051,8 +1368,9 @@ var RescaleMachineRequest = z3.object({
1051
1368
  });
1052
1369
  var SnapshotSchema = z3.object({
1053
1370
  name: z3.string(),
1371
+ owner: z3.string(),
1054
1372
  source: z3.string(),
1055
- // the box it was taken from
1373
+ // the box it was taken from, as owner/name
1056
1374
  base: z3.string(),
1057
1375
  // base image version the overlay stacks on; a restore needs the same one
1058
1376
  vcpu: z3.number().int(),
@@ -1077,7 +1395,23 @@ var HealthSchema = z3.object({
1077
1395
  version: z3.string(),
1078
1396
  pid: z3.number().int(),
1079
1397
  /** 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()
1398
+ base: z3.string().nullable().optional(),
1399
+ /** Owner that boxes made over the local socket get (the Unix user running the server); absent from older servers. */
1400
+ owner: z3.string().optional()
1401
+ });
1402
+ var MachineUsageSchema = z3.object({
1403
+ name: z3.string(),
1404
+ owner: z3.string(),
1405
+ status: z3.enum(["running", "stopped"]),
1406
+ window: z3.object({ from: z3.string(), to: z3.string() }),
1407
+ seconds: z3.object({ running: z3.number().int(), stopped: z3.number().int() }),
1408
+ vcpuHours: z3.number(),
1409
+ ramGibHours: z3.number(),
1410
+ diskGibHours: z3.number(),
1411
+ cost: z3.object({ amount: z3.number(), currency: z3.string() }).optional(),
1412
+ /** Stops the server did not witness (Firecracker died, host rebooted) and dated to when it noticed. */
1413
+ estimatedStops: z3.number().int(),
1414
+ historyFrom: z3.string().nullable()
1081
1415
  });
1082
1416
  var ApiErrorSchema = z3.object({
1083
1417
  error: z3.string()
@@ -1086,20 +1420,30 @@ var ApiErrorSchema = z3.object({
1086
1420
  // src/routes/machines.ts
1087
1421
  function machineRoutes() {
1088
1422
  const r = new Hono2();
1089
- r.get("/", async (c) => c.json(await listMachines()));
1423
+ r.get("/", async (c) => c.json(await listMachines(c.get("owner"))));
1090
1424
  r.post("/", async (c) => {
1091
1425
  const body = CreateMachineRequest.parse(await c.req.json().catch(() => ({})));
1092
- return c.json(await createMachine(body), 201);
1426
+ return c.json(await createMachine(body, c.get("owner")), 201);
1093
1427
  });
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"))));
1428
+ r.post("/:name/stop", async (c) => c.json(await stopMachine(parseRef(c.req.param("name"), c.get("owner")))));
1429
+ r.post("/:name/start", async (c) => c.json(await startMachine(parseRef(c.req.param("name"), c.get("owner")))));
1430
+ r.post("/:name/update", async (c) => c.json(await updateMachine(parseRef(c.req.param("name"), c.get("owner")))));
1097
1431
  r.post("/:name/rescale", async (c) => {
1098
1432
  const body = RescaleMachineRequest.parse(await c.req.json().catch(() => ({})));
1099
- return c.json(await rescaleMachine(c.req.param("name"), body));
1433
+ return c.json(await rescaleMachine(parseRef(c.req.param("name"), c.get("owner")), body));
1434
+ });
1435
+ r.get("/:name/usage", async (c) => {
1436
+ const since = c.req.query("since");
1437
+ let from;
1438
+ if (since !== void 0) {
1439
+ const t = Date.parse(since);
1440
+ if (Number.isNaN(t)) throw new KvboxError(`since: "${since}" is not a date; write it like 2026-10-19 or 2026-10-19T08:00:00Z`);
1441
+ from = new Date(t).toISOString();
1442
+ }
1443
+ return c.json(await machineUsage(parseRef(c.req.param("name"), c.get("owner")), from));
1100
1444
  });
1101
1445
  r.delete("/:name", async (c) => {
1102
- await destroyMachine(c.req.param("name"));
1446
+ await destroyMachine(parseRef(c.req.param("name"), c.get("owner")));
1103
1447
  return c.body(null, 204);
1104
1448
  });
1105
1449
  return r;
@@ -1109,12 +1453,13 @@ function machineRoutes() {
1109
1453
  import { Hono as Hono3 } from "hono";
1110
1454
 
1111
1455
  // 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";
1456
+ import { existsSync as existsSync4 } from "node:fs";
1457
+ import { link as link2, mkdir as mkdir4, readdir as readdir3, rename as rename2, rm as rm2, stat } from "node:fs/promises";
1114
1458
  var NAME_RE2 = /^[a-z0-9][a-z0-9._-]{0,63}$/;
1115
1459
  function toSnapshot(meta) {
1116
1460
  return {
1117
1461
  name: meta.name,
1462
+ owner: meta.owner,
1118
1463
  source: meta.source,
1119
1464
  base: meta.base,
1120
1465
  vcpu: meta.vcpu,
@@ -1123,54 +1468,55 @@ function toSnapshot(meta) {
1123
1468
  createdAt: meta.createdAt
1124
1469
  };
1125
1470
  }
1126
- async function listSnapshots(p = paths()) {
1471
+ async function listSnapshots(owner, p = paths()) {
1127
1472
  const out = [];
1128
- for (const name of await listSnapshotNames(p)) {
1473
+ for (const ref of await listSnapshotRefs(p)) {
1474
+ if (owner !== null && ref.owner !== owner) continue;
1129
1475
  try {
1130
- out.push(toSnapshot(await readSnapshotMeta(name, p)));
1476
+ out.push(toSnapshot(await readSnapshotMeta(ref, p)));
1131
1477
  } catch (err) {
1132
- console.error(`warning: skipping snapshots/${name}: ${err.message}`);
1478
+ console.error(`warning: skipping snapshots/${refKey(ref)}: ${err.message}`);
1133
1479
  }
1134
1480
  }
1135
1481
  return out;
1136
1482
  }
1137
1483
  async function usedBytes(dir) {
1138
1484
  let total = 0;
1139
- for (const entry of await readdir2(dir)) {
1485
+ for (const entry of await readdir3(dir)) {
1140
1486
  total += (await stat(`${dir}/${entry}`)).blocks * 512;
1141
1487
  }
1142
1488
  return total;
1143
1489
  }
1144
- async function readBox(name, p) {
1490
+ async function readBox(ref, p) {
1145
1491
  try {
1146
- return await readMachineMeta(name, p);
1492
+ return await readMachineMeta(ref, p);
1147
1493
  } catch (err) {
1148
- if (err.code === "ENOENT") throw new NotFoundError(`box ${name}`);
1494
+ if (err.code === "ENOENT") throw new NotFoundError(`box ${shown(ref)}`);
1149
1495
  throw err;
1150
1496
  }
1151
1497
  }
1152
- async function saveSnapshot(req, p = paths()) {
1498
+ async function saveSnapshot(req, owner, p = paths()) {
1153
1499
  if (!NAME_RE2.test(req.name)) {
1154
1500
  throw new KvboxError(`invalid snapshot name "${req.name}": 1-64 chars, lowercase letters, digits, '.', '_' and '-', starting with a letter or digit`);
1155
1501
  }
1156
- const box = await readBox(req.box, p);
1502
+ const box = await readBox(parseRef(req.box, owner), p);
1157
1503
  if (box.base === void 0) {
1158
1504
  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
1505
  }
1160
1506
  if (!pidAlive(box.pid)) {
1161
1507
  throw new KvboxError(`box ${req.box} is not running; a snapshot freezes a running box (kvbox start ${req.box} first)`);
1162
1508
  }
1163
- const s = p.snapshot(req.name);
1164
- if (existsSync3(s.dir)) throw new KvboxError(`snapshot ${req.name} already exists`);
1509
+ const s = p.snapshot(owner, req.name);
1510
+ if (existsSync4(s.dir)) throw new KvboxError(`snapshot ${req.name} already exists`);
1165
1511
  const cfg = await loadConfig(p);
1166
- const m = p.machine(box.name);
1167
- await mkdir3(s.dir, { recursive: true, mode: 448 });
1512
+ const m = p.machine(box.owner, box.name);
1513
+ await mkdir4(s.dir, { recursive: true, mode: 448 });
1168
1514
  const fc = new FirecrackerClient(m.sock);
1169
1515
  let lineageTouched = false;
1170
1516
  try {
1171
1517
  const tracked = (await fc.machineConfigInfo()).track_dirty_pages;
1172
1518
  const since = box.memSnapshots ?? 0;
1173
- const mode = tracked && (since === 0 || existsSync3(m.memBase)) ? "Diff" : "Full";
1519
+ const mode = tracked && (since === 0 || existsSync4(m.memBase)) ? "Diff" : "Full";
1174
1520
  let failure;
1175
1521
  await fc.pause();
1176
1522
  try {
@@ -1197,7 +1543,8 @@ async function saveSnapshot(req, p = paths()) {
1197
1543
  }
1198
1544
  const meta = {
1199
1545
  name: req.name,
1200
- source: box.name,
1546
+ owner,
1547
+ source: refKey(box),
1201
1548
  base: box.base,
1202
1549
  vcpu: box.vcpu,
1203
1550
  memMib: box.memMib,
@@ -1215,27 +1562,28 @@ async function saveSnapshot(req, p = paths()) {
1215
1562
  }
1216
1563
  }
1217
1564
  async function mergeOntoPrevious(previous, diffFile, editor) {
1218
- if (!existsSync3(editor)) throw new KvboxError(`snapshot-editor missing at ${editor}; rerun install-server.sh`);
1565
+ if (!existsSync4(editor)) throw new KvboxError(`snapshot-editor missing at ${editor}; rerun install-server.sh`);
1219
1566
  const merged = `${diffFile}.merged`;
1220
1567
  await run(["cp", "--sparse=always", "--reflink=auto", previous, merged]);
1221
1568
  await run([editor, "edit-memory", "rebase", "--memory-path", merged, "--diff-path", diffFile]);
1222
- await rename(merged, diffFile);
1569
+ await rename2(merged, diffFile);
1223
1570
  }
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 });
1571
+ async function deleteSnapshot(ref, p = paths()) {
1572
+ const dir = p.snapshot(ref.owner, ref.name).dir;
1573
+ if (!existsSync4(dir)) throw new NotFoundError(`snapshot ${shown(ref)}`);
1574
+ await rm2(dir, { recursive: true, force: true });
1227
1575
  }
1228
1576
 
1229
1577
  // src/routes/snapshots.ts
1230
1578
  function snapshotRoutes() {
1231
1579
  const r = new Hono3();
1232
- r.get("/", async (c) => c.json(await listSnapshots()));
1580
+ r.get("/", async (c) => c.json(await listSnapshots(c.get("owner"))));
1233
1581
  r.post("/", async (c) => {
1234
1582
  const body = CreateSnapshotRequest.parse(await c.req.json());
1235
- return c.json(await saveSnapshot(body), 201);
1583
+ return c.json(await saveSnapshot(body, c.get("owner")), 201);
1236
1584
  });
1237
1585
  r.delete("/:name", async (c) => {
1238
- await deleteSnapshot(c.req.param("name"));
1586
+ await deleteSnapshot(parseRef(c.req.param("name"), c.get("owner")));
1239
1587
  return c.body(null, 204);
1240
1588
  });
1241
1589
  return r;
@@ -1244,9 +1592,16 @@ function snapshotRoutes() {
1244
1592
  // src/app.ts
1245
1593
  function createApp() {
1246
1594
  const app = new Hono4();
1595
+ app.use(resolveCaller());
1247
1596
  app.get(
1248
1597
  "/health",
1249
- async (c) => c.json({ ok: true, version: VERSION, pid: process.pid, base: await currentBaseVersion().catch(() => null) })
1598
+ async (c) => c.json({
1599
+ ok: true,
1600
+ version: VERSION,
1601
+ pid: process.pid,
1602
+ base: await currentBaseVersion().catch(() => null),
1603
+ owner: (await loadConfig()).owner
1604
+ })
1250
1605
  );
1251
1606
  app.route("/machines", machineRoutes());
1252
1607
  app.route("/snapshots", snapshotRoutes());
@@ -1266,7 +1621,14 @@ function createApp() {
1266
1621
  // src/listen.ts
1267
1622
  async function listen(opts = {}) {
1268
1623
  const p = await ensureLayout(paths());
1269
- await loadConfig(p);
1624
+ const cfg = await loadConfig(p);
1625
+ const moved = await migrateOwnerLayout(cfg.owner, p);
1626
+ if (moved.boxes.length + moved.snapshots.length > 0) {
1627
+ 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}/`);
1628
+ }
1629
+ for (const c of moved.conflicts) console.error(`warning: ${c} left in place: ${cfg.owner} already has one with that name`);
1630
+ const seeded = await seedUsageLedger(p);
1631
+ if (seeded > 0) console.error(`usage: started keeping time for ${seeded} box(es) made before this version; kvbox vm usage counts from now for them`);
1270
1632
  const socketPath = opts.socketPath ?? p.serverSock;
1271
1633
  if (Buffer.byteLength(socketPath) > 100) {
1272
1634
  throw new KvboxError(`socket path too long for a Unix socket (${socketPath}); set KVBOX_HOME to a shorter path`);
@@ -1277,7 +1639,7 @@ async function listen(opts = {}) {
1277
1639
  server.once("error", reject);
1278
1640
  server.listen(socketPath, () => resolve());
1279
1641
  });
1280
- await writeFile4(p.serverPid, `${process.pid}
1642
+ await writeFile5(p.serverPid, `${process.pid}
1281
1643
  `);
1282
1644
  const shutdown2 = async () => {
1283
1645
  server.close();