@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.
- package/dist/kvbox-server.js +360 -163
- package/dist/kvbox-server.js.map +1 -1
- package/package.json +1 -1
package/dist/kvbox-server.js
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
285
|
-
await
|
|
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
|
|
288
|
-
return
|
|
328
|
+
async function listMachineRefs(p = paths()) {
|
|
329
|
+
return listRefs(p.machines);
|
|
289
330
|
}
|
|
290
|
-
async function readSnapshotMeta(
|
|
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
|
-
|
|
295
|
-
await
|
|
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
|
|
298
|
-
|
|
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.
|
|
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
|
|
331
|
-
import { link, mkdir as
|
|
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
|
|
717
|
+
for (const ref of await listMachineRefs(p)) {
|
|
541
718
|
try {
|
|
542
|
-
metas.push(await readMachineMeta(
|
|
719
|
+
metas.push(await readMachineMeta(ref, p));
|
|
543
720
|
} catch (err) {
|
|
544
|
-
console.error(`warning: skipping machines/${
|
|
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 (!
|
|
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
|
|
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
|
|
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(
|
|
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(() =>
|
|
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
|
|
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,
|
|
677
|
-
const m = p.machine(spec.name);
|
|
678
|
-
const s = p.snapshot(
|
|
679
|
-
const pid = await spawnFirecracker(spec
|
|
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(
|
|
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
|
|
890
|
+
return (await readFile4(vmstate)).includes(`/${OVERLAY_REL}`);
|
|
714
891
|
}
|
|
715
892
|
async function readPubkeyB64(p) {
|
|
716
893
|
try {
|
|
717
|
-
return Buffer.from((await
|
|
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 (!
|
|
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
|
|
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(
|
|
938
|
+
async function readSnap(ref, p) {
|
|
761
939
|
try {
|
|
762
|
-
return await readSnapshotMeta(
|
|
940
|
+
return await readSnapshotMeta(ref, p);
|
|
763
941
|
} catch (err) {
|
|
764
|
-
if (err.code === "ENOENT") throw new NotFoundError(`snapshot ${
|
|
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(
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
858
|
-
await rm(
|
|
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(
|
|
861
|
-
const meta = await readMeta(
|
|
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 ${
|
|
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(
|
|
871
|
-
const meta = await readMeta(
|
|
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 ${
|
|
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
|
|
1078
|
+
return wasRunning ? startMachine(meta, p) : toMachine(updated, p);
|
|
898
1079
|
}
|
|
899
|
-
async function updateMachine(
|
|
900
|
-
const meta = await readMeta(
|
|
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 ${
|
|
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(
|
|
912
|
-
const meta = await readMeta(
|
|
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 ${
|
|
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(
|
|
1118
|
+
async function readMeta(ref, p) {
|
|
938
1119
|
try {
|
|
939
|
-
return await readMachineMeta(
|
|
1120
|
+
return await readMachineMeta(ref, p);
|
|
940
1121
|
} catch (err) {
|
|
941
|
-
if (err.code === "ENOENT") throw new NotFoundError(`box ${
|
|
1122
|
+
if (err.code === "ENOENT") throw new NotFoundError(`box ${shown(ref)}`);
|
|
942
1123
|
throw err;
|
|
943
1124
|
}
|
|
944
1125
|
}
|
|
945
|
-
async function destroyMachine(
|
|
946
|
-
const meta = await readMeta(
|
|
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
|
|
1064
|
-
import { link as link2, mkdir as
|
|
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
|
|
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(
|
|
1264
|
+
out.push(toSnapshot(await readSnapshotMeta(ref, p)));
|
|
1082
1265
|
} catch (err) {
|
|
1083
|
-
console.error(`warning: skipping snapshots/${
|
|
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
|
|
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(
|
|
1278
|
+
async function readBox(ref, p) {
|
|
1096
1279
|
try {
|
|
1097
|
-
return await readMachineMeta(
|
|
1280
|
+
return await readMachineMeta(ref, p);
|
|
1098
1281
|
} catch (err) {
|
|
1099
|
-
if (err.code === "ENOENT") throw new NotFoundError(`box ${
|
|
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 (
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
1357
|
+
await rename2(merged, diffFile);
|
|
1174
1358
|
}
|
|
1175
|
-
async function deleteSnapshot(
|
|
1176
|
-
|
|
1177
|
-
|
|
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({
|
|
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
|
|
1428
|
+
await writeFile5(p.serverPid, `${process.pid}
|
|
1232
1429
|
`);
|
|
1233
1430
|
const shutdown2 = async () => {
|
|
1234
1431
|
server.close();
|