@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.
- package/dist/kvbox-server.js +545 -183
- package/dist/kvbox-server.js.map +1 -1
- package/package.json +2 -2
package/dist/kvbox-server.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
279
|
-
await
|
|
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
|
|
282
|
-
return
|
|
337
|
+
async function listMachineRefs(p = paths()) {
|
|
338
|
+
return listRefs(p.machines);
|
|
283
339
|
}
|
|
284
|
-
async function readSnapshotMeta(
|
|
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
|
-
|
|
289
|
-
await
|
|
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
|
|
292
|
-
|
|
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/
|
|
305
|
-
import {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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/
|
|
320
|
-
|
|
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
|
|
325
|
-
import { link, mkdir as
|
|
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
|
|
572
|
+
for (const ref of await listMachineRefs(p)) {
|
|
389
573
|
try {
|
|
390
|
-
const meta = await readMachineMeta(
|
|
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
|
|
800
|
+
for (const ref of await listMachineRefs(p)) {
|
|
595
801
|
try {
|
|
596
|
-
metas.push(await readMachineMeta(
|
|
802
|
+
metas.push(await readMachineMeta(ref, p));
|
|
597
803
|
} catch (err) {
|
|
598
|
-
console.error(`warning: skipping machines/${
|
|
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 (!
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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(() =>
|
|
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
|
|
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,
|
|
731
|
-
const m = p.machine(spec.name);
|
|
732
|
-
const s = p.snapshot(
|
|
733
|
-
const pid = await spawnFirecracker(spec
|
|
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(
|
|
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
|
|
976
|
+
return (await readFile4(vmstate)).includes(`/${OVERLAY_REL}`);
|
|
768
977
|
}
|
|
769
978
|
async function readPubkeyB64(p) {
|
|
770
979
|
try {
|
|
771
|
-
return Buffer.from((await
|
|
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 (!
|
|
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
|
|
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(
|
|
1025
|
+
async function readSnap(ref, p) {
|
|
816
1026
|
try {
|
|
817
|
-
return await readSnapshotMeta(
|
|
1027
|
+
return await readSnapshotMeta(ref, p);
|
|
818
1028
|
} catch (err) {
|
|
819
|
-
if (err.code === "ENOENT") throw new NotFoundError(`snapshot ${
|
|
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(
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
914
|
-
await rm(
|
|
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(
|
|
917
|
-
const meta = await readMeta(
|
|
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 ${
|
|
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(
|
|
927
|
-
const meta = await readMeta(
|
|
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 ${
|
|
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
|
-
|
|
1168
|
+
recordState(updated, "stopped", by, p);
|
|
1169
|
+
return wasRunning ? startMachine(meta, p) : toMachine(updated, p);
|
|
954
1170
|
}
|
|
955
|
-
async function updateMachine(
|
|
956
|
-
const meta = await readMeta(
|
|
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 ${
|
|
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(
|
|
968
|
-
const meta = await readMeta(
|
|
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 ${
|
|
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(
|
|
1209
|
+
async function readMeta(ref, p) {
|
|
994
1210
|
try {
|
|
995
|
-
return await readMachineMeta(
|
|
1211
|
+
return await readMachineMeta(ref, p);
|
|
996
1212
|
} catch (err) {
|
|
997
|
-
if (err.code === "ENOENT") throw new NotFoundError(`box ${
|
|
1213
|
+
if (err.code === "ENOENT") throw new NotFoundError(`box ${shown(ref)}`);
|
|
998
1214
|
throw err;
|
|
999
1215
|
}
|
|
1000
1216
|
}
|
|
1001
|
-
async function destroyMachine(
|
|
1002
|
-
const meta = await readMeta(
|
|
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
|
-
|
|
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
|
|
1113
|
-
import { link as link2, mkdir as
|
|
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
|
|
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(
|
|
1476
|
+
out.push(toSnapshot(await readSnapshotMeta(ref, p)));
|
|
1131
1477
|
} catch (err) {
|
|
1132
|
-
console.error(`warning: skipping snapshots/${
|
|
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
|
|
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(
|
|
1490
|
+
async function readBox(ref, p) {
|
|
1145
1491
|
try {
|
|
1146
|
-
return await readMachineMeta(
|
|
1492
|
+
return await readMachineMeta(ref, p);
|
|
1147
1493
|
} catch (err) {
|
|
1148
|
-
if (err.code === "ENOENT") throw new NotFoundError(`box ${
|
|
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 (
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
1569
|
+
await rename2(merged, diffFile);
|
|
1223
1570
|
}
|
|
1224
|
-
async function deleteSnapshot(
|
|
1225
|
-
|
|
1226
|
-
|
|
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({
|
|
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
|
|
1642
|
+
await writeFile5(p.serverPid, `${process.pid}
|
|
1281
1643
|
`);
|
|
1282
1644
|
const shutdown2 = async () => {
|
|
1283
1645
|
server.close();
|