@kvbox/server 0.5.17 → 0.5.19
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 +311 -190
- package/dist/kvbox-server.js.map +1 -1
- package/package.json +1 -1
package/dist/kvbox-server.js
CHANGED
|
@@ -76,6 +76,8 @@ function paths(home = kvboxHome()) {
|
|
|
76
76
|
* The next snapshot is a diff of the pages dirtied since, merged onto this. Absent after a boot.
|
|
77
77
|
*/
|
|
78
78
|
memBase: join(dir, "mem.base"),
|
|
79
|
+
/** `kvbox vm fork` (#79): the source's VM state, captured here and gone once the box runs. Its memory is `memBase`. */
|
|
80
|
+
forkVmstate: join(dir, "fork.vmstate"),
|
|
79
81
|
/** `kvbox hibernate`: the box's memory image and VM state while it is hibernated; gone again after `wake` or `stop`. */
|
|
80
82
|
hibernateDir: join(dir, "hibernate"),
|
|
81
83
|
hibernateVmstate: join(dir, "hibernate", "vmstate"),
|
|
@@ -214,8 +216,9 @@ function groupUsage(rows, window, pricing, live = /* @__PURE__ */ new Map()) {
|
|
|
214
216
|
const status = live.get(id) ?? (removed ? "removed" : last.state);
|
|
215
217
|
out.push({ id, name: last.name, owner: last.owner, status, ...totals });
|
|
216
218
|
}
|
|
217
|
-
return out.sort(
|
|
219
|
+
return out.sort(usageOrder);
|
|
218
220
|
}
|
|
221
|
+
var usageOrder = (a, b) => a.owner.localeCompare(b.owner) || a.name.localeCompare(b.name) || (a.historyFrom ?? "").localeCompare(b.historyFrom ?? "");
|
|
219
222
|
|
|
220
223
|
// src/core/refs.ts
|
|
221
224
|
import { userInfo } from "node:os";
|
|
@@ -257,6 +260,11 @@ function parseRef(text, caller) {
|
|
|
257
260
|
}
|
|
258
261
|
return { owner, name, display: owner === caller ? name : text };
|
|
259
262
|
}
|
|
263
|
+
function ownerFor(slug, caller) {
|
|
264
|
+
if (!LOCAL_OWNER_RE.test(slug)) throw new KvboxError(`invalid owner "${slug}": a bare slug, without '@' or '/'`);
|
|
265
|
+
const realm = realmOf(caller);
|
|
266
|
+
return realm === null ? slug : `${slug}@${realm}`;
|
|
267
|
+
}
|
|
260
268
|
function refKey(ref) {
|
|
261
269
|
return `${ref.owner}/${ref.name}`;
|
|
262
270
|
}
|
|
@@ -382,7 +390,7 @@ var MachineSchema = z3.object({
|
|
|
382
390
|
// base image version the box's overlay stacks on
|
|
383
391
|
createdAt: z3.string(),
|
|
384
392
|
source: z3.string(),
|
|
385
|
-
// "base" or "
|
|
393
|
+
// "base", "snapshot:<owner/name>" or "fork:<owner/name>" (#79)
|
|
386
394
|
status: BoxStatusSchema,
|
|
387
395
|
/**
|
|
388
396
|
* Public TCP port on the host that reaches the box's SSH while it runs (#49). Only boxes made
|
|
@@ -418,6 +426,20 @@ var CreateMachineRequest = z3.object({
|
|
|
418
426
|
/** Seconds without network traffic before the box is hibernated (#47); 0: never. Absent: the host's default. */
|
|
419
427
|
autoHibernateSec: z3.number().int().min(0).optional()
|
|
420
428
|
});
|
|
429
|
+
var ForkMachineRequest = z3.object({
|
|
430
|
+
name: z3.string().optional(),
|
|
431
|
+
/**
|
|
432
|
+
* The new box's slug, bare (`acme`): the control plane sends the target of `--org` here; the host
|
|
433
|
+
* qualifies it with the caller's realm. Absent: the source's owner. A CLI leaves it out.
|
|
434
|
+
*/
|
|
435
|
+
owner: z3.string().optional(),
|
|
436
|
+
/** A cold fork only: a warm one keeps the source's vCPU and memory. */
|
|
437
|
+
size: SizeSchema.optional(),
|
|
438
|
+
force: z3.boolean().optional(),
|
|
439
|
+
/** As in CreateMachineRequest: the control plane's CA line for the new box's owner. */
|
|
440
|
+
sshKeys: z3.array(z3.string()).optional(),
|
|
441
|
+
autoHibernateSec: z3.number().int().min(0).optional()
|
|
442
|
+
});
|
|
421
443
|
var SetSshKeysRequest = z3.object({
|
|
422
444
|
sshKeys: z3.array(z3.string())
|
|
423
445
|
});
|
|
@@ -533,7 +555,12 @@ var BoxUsageSchema = MachineUsageSchema.omit({ window: true, status: true }).ext
|
|
|
533
555
|
id: z3.string(),
|
|
534
556
|
status: UsageStatusSchema,
|
|
535
557
|
/** Through a control plane: whether the owner slug is a user's or an org's (what `kvbox ls` shows). */
|
|
536
|
-
ownerKind: z3.enum(["user", "org"]).optional()
|
|
558
|
+
ownerKind: z3.enum(["user", "org"]).optional(),
|
|
559
|
+
/**
|
|
560
|
+
* Through a control plane: the username that made the box (#60), absent when the box is gone from
|
|
561
|
+
* the index (removed), was found by reconcile, or its maker was deleted. The ledger does not record it.
|
|
562
|
+
*/
|
|
563
|
+
createdBy: z3.string().optional()
|
|
537
564
|
});
|
|
538
565
|
var UsageReportSchema = z3.object({
|
|
539
566
|
window: z3.object({ from: z3.string(), to: z3.string() }),
|
|
@@ -929,8 +956,8 @@ async function requireKvm(device = KVM_DEVICE, groupFile = "/etc/group") {
|
|
|
929
956
|
|
|
930
957
|
// src/core/machines.ts
|
|
931
958
|
import { spawn } from "node:child_process";
|
|
932
|
-
import { closeSync as closeSync2, existsSync as
|
|
933
|
-
import { link, mkdir as
|
|
959
|
+
import { closeSync as closeSync2, existsSync as existsSync3, openSync as openSync2, statSync } from "node:fs";
|
|
960
|
+
import { link as link2, mkdir as mkdir4, readFile as readFile5, realpath, rm as rm2, truncate, writeFile as writeFile3 } from "node:fs/promises";
|
|
934
961
|
import { basename } from "node:path";
|
|
935
962
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
936
963
|
|
|
@@ -1352,6 +1379,10 @@ function locked(fn) {
|
|
|
1352
1379
|
return (...args) => withBoxLock(args[0], () => fn(...args));
|
|
1353
1380
|
}
|
|
1354
1381
|
|
|
1382
|
+
// src/core/snapshots.ts
|
|
1383
|
+
import { existsSync } from "node:fs";
|
|
1384
|
+
import { link, mkdir as mkdir3, readdir as readdir2, rename as rename2, rm, stat as stat2 } from "node:fs/promises";
|
|
1385
|
+
|
|
1355
1386
|
// src/core/firecracker.ts
|
|
1356
1387
|
import { Agent, request as request2 } from "undici";
|
|
1357
1388
|
var FirecrackerClient = class {
|
|
@@ -1461,9 +1492,137 @@ var FirecrackerClient = class {
|
|
|
1461
1492
|
}
|
|
1462
1493
|
};
|
|
1463
1494
|
|
|
1495
|
+
// src/core/snapshots.ts
|
|
1496
|
+
var NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
1497
|
+
function toSnapshot(meta) {
|
|
1498
|
+
return {
|
|
1499
|
+
name: meta.name,
|
|
1500
|
+
owner: meta.owner,
|
|
1501
|
+
source: meta.source,
|
|
1502
|
+
base: meta.base,
|
|
1503
|
+
vcpu: meta.vcpu,
|
|
1504
|
+
memMib: meta.memMib,
|
|
1505
|
+
sizeBytes: meta.sizeBytes,
|
|
1506
|
+
createdAt: meta.createdAt
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
async function listSnapshots(scope, p = paths()) {
|
|
1510
|
+
const out = [];
|
|
1511
|
+
for (const ref of await listSnapshotRefs(p)) {
|
|
1512
|
+
if (!inScope(ref.owner, scope)) continue;
|
|
1513
|
+
try {
|
|
1514
|
+
out.push(toSnapshot(await readSnapshotMeta(ref, p)));
|
|
1515
|
+
} catch (err) {
|
|
1516
|
+
console.error(`warning: skipping snapshots/${refKey(ref)}: ${err.message}`);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
return out;
|
|
1520
|
+
}
|
|
1521
|
+
async function usedBytes(dir) {
|
|
1522
|
+
let total = 0;
|
|
1523
|
+
for (const entry of await readdir2(dir)) {
|
|
1524
|
+
total += (await stat2(`${dir}/${entry}`)).blocks * 512;
|
|
1525
|
+
}
|
|
1526
|
+
return total;
|
|
1527
|
+
}
|
|
1528
|
+
async function readBox(ref, p) {
|
|
1529
|
+
try {
|
|
1530
|
+
return await readMachineMeta(ref, p);
|
|
1531
|
+
} catch (err) {
|
|
1532
|
+
if (err.code === "ENOENT") throw new NotFoundError(`box ${shown(ref)}`);
|
|
1533
|
+
throw err;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
async function saveSnapshot(req, owner, p = paths()) {
|
|
1537
|
+
if (!NAME_RE.test(req.name)) {
|
|
1538
|
+
throw new KvboxError(`invalid snapshot name "${req.name}": 1-64 chars, lowercase letters, digits, '.', '_' and '-', starting with a letter or digit`);
|
|
1539
|
+
}
|
|
1540
|
+
const ref = parseRef(req.box, owner);
|
|
1541
|
+
return withBoxLock(ref, () => snapshotBox(ref, req, owner, p));
|
|
1542
|
+
}
|
|
1543
|
+
async function snapshotBox(ref, req, owner, p) {
|
|
1544
|
+
const box = await readBox(ref, p);
|
|
1545
|
+
if (!vmAlive(box)) {
|
|
1546
|
+
if (existsSync(p.machine(box.owner, box.name).hibernateVmstate)) {
|
|
1547
|
+
throw new KvboxError(`box ${req.box} is hibernated; a snapshot freezes a running box (kvbox wake ${req.box} first)`);
|
|
1548
|
+
}
|
|
1549
|
+
throw new KvboxError(`box ${req.box} is not running; a snapshot freezes a running box (kvbox start ${req.box} first)`);
|
|
1550
|
+
}
|
|
1551
|
+
const s = p.snapshot(owner, req.name);
|
|
1552
|
+
if (existsSync(s.dir)) throw new KvboxError(`snapshot ${req.name} already exists`);
|
|
1553
|
+
const cfg = await loadConfig(p);
|
|
1554
|
+
await mkdir3(s.dir, { recursive: true, mode: 448 });
|
|
1555
|
+
try {
|
|
1556
|
+
await captureBox(box, s, cfg, p);
|
|
1557
|
+
const meta = {
|
|
1558
|
+
name: req.name,
|
|
1559
|
+
owner,
|
|
1560
|
+
source: refKey(box),
|
|
1561
|
+
base: box.base,
|
|
1562
|
+
vcpu: box.vcpu,
|
|
1563
|
+
memMib: box.memMib,
|
|
1564
|
+
sizeBytes: await usedBytes(s.dir),
|
|
1565
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1566
|
+
};
|
|
1567
|
+
await writeSnapshotMeta(meta, p);
|
|
1568
|
+
return toSnapshot(meta);
|
|
1569
|
+
} catch (err) {
|
|
1570
|
+
await rm(s.dir, { recursive: true, force: true });
|
|
1571
|
+
throw err;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
async function captureBox(box, to, cfg, p) {
|
|
1575
|
+
const m = p.machine(box.owner, box.name);
|
|
1576
|
+
const fc = new FirecrackerClient(m.sock);
|
|
1577
|
+
let lineageTouched = false;
|
|
1578
|
+
try {
|
|
1579
|
+
const since = box.memSnapshots ?? 0;
|
|
1580
|
+
const mode = since === 0 || existsSync(m.memBase) ? "Diff" : "Full";
|
|
1581
|
+
let failure2;
|
|
1582
|
+
await fc.pause();
|
|
1583
|
+
try {
|
|
1584
|
+
await fc.snapshotCreate(to.vmstate, to.mem, mode);
|
|
1585
|
+
lineageTouched = true;
|
|
1586
|
+
await run(["cp", "--sparse=always", "--reflink=auto", m.overlay, to.overlay]);
|
|
1587
|
+
} catch (err) {
|
|
1588
|
+
failure2 = err;
|
|
1589
|
+
} finally {
|
|
1590
|
+
if (!box.paused) await fc.resume();
|
|
1591
|
+
}
|
|
1592
|
+
if (lineageTouched) {
|
|
1593
|
+
await writeMachineMeta({ ...box, memSnapshots: since + 1 }, p);
|
|
1594
|
+
}
|
|
1595
|
+
if (failure2 !== void 0) throw failure2;
|
|
1596
|
+
if (mode === "Full") {
|
|
1597
|
+
await run(["fallocate", "--dig-holes", to.mem]);
|
|
1598
|
+
} else if (since > 0) {
|
|
1599
|
+
await mergeOntoPrevious(m.memBase, to.mem, cfg.snapshotEditor ?? p.snapshotEditor);
|
|
1600
|
+
}
|
|
1601
|
+
await rm(m.memBase, { force: true });
|
|
1602
|
+
await link(to.mem, m.memBase);
|
|
1603
|
+
} catch (err) {
|
|
1604
|
+
if (lineageTouched) await rm(m.memBase, { force: true }).catch(() => void 0);
|
|
1605
|
+
throw err;
|
|
1606
|
+
} finally {
|
|
1607
|
+
await fc.close();
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
async function mergeOntoPrevious(previous, diffFile, editor) {
|
|
1611
|
+
if (!existsSync(editor)) throw new KvboxError(`snapshot-editor missing at ${editor}; rerun install-server.sh`);
|
|
1612
|
+
const merged = `${diffFile}.merged`;
|
|
1613
|
+
await run(["cp", "--sparse=always", "--reflink=auto", previous, merged]);
|
|
1614
|
+
await run([editor, "edit-memory", "rebase", "--memory-path", merged, "--diff-path", diffFile]);
|
|
1615
|
+
await rename2(merged, diffFile);
|
|
1616
|
+
}
|
|
1617
|
+
async function deleteSnapshot(ref, p = paths()) {
|
|
1618
|
+
const dir = p.snapshot(ref.owner, ref.name).dir;
|
|
1619
|
+
if (!existsSync(dir)) throw new NotFoundError(`snapshot ${shown(ref)}`);
|
|
1620
|
+
await rm(dir, { recursive: true, force: true });
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1464
1623
|
// src/core/network.ts
|
|
1465
1624
|
import { randomInt } from "node:crypto";
|
|
1466
|
-
import { existsSync } from "node:fs";
|
|
1625
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
1467
1626
|
import { fileURLToPath } from "node:url";
|
|
1468
1627
|
var TAP_PREFIX = "kv-";
|
|
1469
1628
|
var PRIVILEGED_SCRIPT = "/usr/local/lib/kvbox/net-setup.sh";
|
|
@@ -1473,15 +1632,15 @@ function tapName(box) {
|
|
|
1473
1632
|
function netSetupScript() {
|
|
1474
1633
|
const override = process.env["KVBOX_NET_SETUP"];
|
|
1475
1634
|
if (override) return override;
|
|
1476
|
-
if (
|
|
1635
|
+
if (existsSync2(PRIVILEGED_SCRIPT)) return PRIVILEGED_SCRIPT;
|
|
1477
1636
|
for (const rel of ["../scripts/net-setup.sh", "../../scripts/net-setup.sh"]) {
|
|
1478
1637
|
const candidate = fileURLToPath(new URL(rel, import.meta.url));
|
|
1479
|
-
if (
|
|
1638
|
+
if (existsSync2(candidate)) return candidate;
|
|
1480
1639
|
}
|
|
1481
1640
|
throw new KvboxError("net-setup.sh not found next to kvbox-server");
|
|
1482
1641
|
}
|
|
1483
1642
|
function bridgeExists(bridge) {
|
|
1484
|
-
return
|
|
1643
|
+
return existsSync2(`/sys/class/net/${bridge}`);
|
|
1485
1644
|
}
|
|
1486
1645
|
function netRun(cfg, args) {
|
|
1487
1646
|
return run([netSetupScript(), ...args], {
|
|
@@ -1576,7 +1735,7 @@ function macFor(ip) {
|
|
|
1576
1735
|
}
|
|
1577
1736
|
|
|
1578
1737
|
// src/core/machines.ts
|
|
1579
|
-
var
|
|
1738
|
+
var NAME_RE2 = /^[a-z0-9][a-z0-9-]{0,11}$/;
|
|
1580
1739
|
var AUTO_PREFIX = "box";
|
|
1581
1740
|
var GUEST_CID = 3;
|
|
1582
1741
|
var OVERLAY_REL = "overlay.ext4";
|
|
@@ -1633,7 +1792,7 @@ function recordState(meta, state, by, p, at = (/* @__PURE__ */ new Date()).toISO
|
|
|
1633
1792
|
}
|
|
1634
1793
|
}
|
|
1635
1794
|
function isHibernated(meta, p) {
|
|
1636
|
-
return !vmAlive(meta) &&
|
|
1795
|
+
return !vmAlive(meta) && existsSync3(p.machine(meta.owner, meta.name).hibernateVmstate);
|
|
1637
1796
|
}
|
|
1638
1797
|
function statusOf(meta, p = paths()) {
|
|
1639
1798
|
if (vmAlive(meta)) return meta.paused ? "standby" : "running";
|
|
@@ -1692,7 +1851,7 @@ async function getMachine(ref, p = paths()) {
|
|
|
1692
1851
|
}
|
|
1693
1852
|
function pickName(requested, taken) {
|
|
1694
1853
|
if (requested !== void 0) {
|
|
1695
|
-
if (!
|
|
1854
|
+
if (!NAME_RE2.test(requested)) {
|
|
1696
1855
|
throw new KvboxError(`invalid box name "${requested}": 1-12 chars, lowercase letters, digits and '-', starting with a letter or digit`);
|
|
1697
1856
|
}
|
|
1698
1857
|
if (taken.has(requested)) throw new KvboxError(`box ${requested} already exists`);
|
|
@@ -1739,7 +1898,7 @@ async function currentBase(cfg, p) {
|
|
|
1739
1898
|
}
|
|
1740
1899
|
function baseFileFor(meta, p) {
|
|
1741
1900
|
const file = p.baseImage(meta.base);
|
|
1742
|
-
if (!
|
|
1901
|
+
if (!existsSync3(file)) throw new KvboxError(`box ${meta.name} runs on base image ${meta.base}, which is gone from ${p.assets}`);
|
|
1743
1902
|
return file;
|
|
1744
1903
|
}
|
|
1745
1904
|
var overlayCapable = /* @__PURE__ */ new Map();
|
|
@@ -1774,8 +1933,8 @@ async function tailOf(file, lines = 5) {
|
|
|
1774
1933
|
}
|
|
1775
1934
|
async function spawnFirecracker(ref, cfg, p) {
|
|
1776
1935
|
const m = p.machine(ref.owner, ref.name);
|
|
1777
|
-
await
|
|
1778
|
-
await
|
|
1936
|
+
await rm2(m.sock, { force: true });
|
|
1937
|
+
await rm2(m.vsock, { force: true });
|
|
1779
1938
|
const logFd = openSync2(m.log, "a");
|
|
1780
1939
|
let child;
|
|
1781
1940
|
try {
|
|
@@ -1790,7 +1949,7 @@ async function spawnFirecracker(ref, cfg, p) {
|
|
|
1790
1949
|
child.unref();
|
|
1791
1950
|
const pid = child.pid;
|
|
1792
1951
|
if (pid === void 0) throw new KvboxError("could not start firecracker");
|
|
1793
|
-
if (!await waitFor(() =>
|
|
1952
|
+
if (!await waitFor(() => existsSync3(m.sock), 3e3)) {
|
|
1794
1953
|
killQuietly(pid);
|
|
1795
1954
|
throw new KvboxError(`firecracker did not open its API socket; last log lines:
|
|
1796
1955
|
${await tailOf(m.log)}`);
|
|
@@ -1861,7 +2020,7 @@ async function readHostPubkey(p) {
|
|
|
1861
2020
|
}
|
|
1862
2021
|
function assertAssets(cfg) {
|
|
1863
2022
|
for (const [what, file] of [["kernel", cfg.kernel], ["base image", cfg.rootfs], ["firecracker", cfg.firecracker]]) {
|
|
1864
|
-
if (!
|
|
2023
|
+
if (!existsSync3(file)) throw new KvboxError(`${what} missing at ${file}; run install-server.sh`);
|
|
1865
2024
|
}
|
|
1866
2025
|
}
|
|
1867
2026
|
async function createMachine(req, owner, p = paths()) {
|
|
@@ -1872,33 +2031,43 @@ async function createMachine(req, owner, p = paths()) {
|
|
|
1872
2031
|
const size = SIZES[sizeName];
|
|
1873
2032
|
assertAssets(cfg);
|
|
1874
2033
|
await assertRoomFor(size.vcpu, size.memMib, req.force ?? false, p);
|
|
1875
|
-
const
|
|
1876
|
-
|
|
1877
|
-
const keysB64 = authorizedKeysB64(await readHostPubkey(p), sshKeys ?? []);
|
|
2034
|
+
const sshKeys = isCloudBox(owner) && req.sshKeys !== void 0 && req.sshKeys.length > 0 ? req.sshKeys : void 0;
|
|
2035
|
+
authorizedKeysB64(await readHostPubkey(p), sshKeys ?? []);
|
|
1878
2036
|
const existing = await readAllMeta(p);
|
|
1879
|
-
const name = pickName(req.name, new Set(existing.filter((
|
|
2037
|
+
const name = pickName(req.name, new Set(existing.filter((m) => m.owner === owner).map((m) => m.name)));
|
|
1880
2038
|
const id = newBoxId(idsInUse(existing));
|
|
1881
|
-
const ip = nextFreeIp(cfg.subnet, [...existing.map((
|
|
1882
|
-
const mac = macFor(ip);
|
|
1883
|
-
const m = p.machine(owner, name);
|
|
2039
|
+
const ip = nextFreeIp(cfg.subnet, [...existing.map((m) => m.ip), cfg.gateway]);
|
|
1884
2040
|
const base = await currentBase(cfg, p);
|
|
1885
2041
|
await assertOverlayCapable(base.file, base.version);
|
|
2042
|
+
return bootInto(
|
|
2043
|
+
{ id, name, owner, ip, mac: macFor(ip), vcpu: size.vcpu, memMib: size.memMib, base: base.version, sshKeys },
|
|
2044
|
+
// The base image is shared read-only by every box; only the box's own writes land in its overlay.
|
|
2045
|
+
(m) => createOverlay(m.overlay, size.diskGib),
|
|
2046
|
+
{ source: "base", autoHibernateSec: autoHibernateFor(req, cfg) },
|
|
2047
|
+
cfg,
|
|
2048
|
+
p
|
|
2049
|
+
);
|
|
2050
|
+
}
|
|
2051
|
+
async function bootInto(box, prepare, origin, cfg, p) {
|
|
2052
|
+
const { id, ip, owner, name } = box;
|
|
2053
|
+
const keysB64 = authorizedKeysB64(await readHostPubkey(p), box.sshKeys ?? []);
|
|
2054
|
+
const m = p.machine(owner, name);
|
|
1886
2055
|
await ensureHostNetwork(cfg);
|
|
1887
|
-
await
|
|
2056
|
+
await mkdir4(m.dir, { recursive: true, mode: 448 });
|
|
1888
2057
|
let tapCreated = false;
|
|
1889
2058
|
let pid;
|
|
1890
2059
|
let sshPort;
|
|
1891
2060
|
let drawn;
|
|
1892
2061
|
try {
|
|
1893
|
-
drawn =
|
|
2062
|
+
drawn = isCloudBox(owner) ? await drawPorts(1, p) : void 0;
|
|
1894
2063
|
sshPort = drawn?.ports[0];
|
|
1895
|
-
await
|
|
2064
|
+
await prepare(m);
|
|
1896
2065
|
const tap = await createTap(id, cfg);
|
|
1897
2066
|
tapCreated = true;
|
|
1898
|
-
const spec = {
|
|
2067
|
+
const spec = { ...box, sshPort };
|
|
1899
2068
|
pid = await launch(spec, tap, cfg, keysB64, p);
|
|
1900
2069
|
await openPorts(spec, cfg);
|
|
1901
|
-
const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2070
|
+
const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), ...origin };
|
|
1902
2071
|
await writeMachineMeta(meta, p);
|
|
1903
2072
|
recordState(meta, "running", "new", p, meta.createdAt);
|
|
1904
2073
|
return toMachine(meta, p);
|
|
@@ -1906,7 +2075,7 @@ async function createMachine(req, owner, p = paths()) {
|
|
|
1906
2075
|
if (pid !== void 0) killQuietly(pid);
|
|
1907
2076
|
await closePorts({ id, ip, sshPort }, cfg);
|
|
1908
2077
|
if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
|
|
1909
|
-
await
|
|
2078
|
+
await rm2(m.dir, { recursive: true, force: true });
|
|
1910
2079
|
throw err;
|
|
1911
2080
|
} finally {
|
|
1912
2081
|
drawn?.release();
|
|
@@ -1930,44 +2099,56 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
|
|
|
1930
2099
|
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)`);
|
|
1931
2100
|
}
|
|
1932
2101
|
assertAssets(cfg);
|
|
1933
|
-
if (!
|
|
2102
|
+
if (!existsSync3(p.baseImage(snap.base))) {
|
|
1934
2103
|
throw new KvboxError(`snapshot ${shown(from)} stacks on base image ${snap.base}, which is gone from ${p.assets}`);
|
|
1935
2104
|
}
|
|
1936
2105
|
await assertRoomFor(snap.vcpu, snap.memMib, req.force ?? false, p);
|
|
1937
|
-
const
|
|
1938
|
-
const sshKeys = cloud && req.sshKeys !== void 0 && req.sshKeys.length > 0 ? req.sshKeys : void 0;
|
|
2106
|
+
const sshKeys = isCloudBox(owner) && req.sshKeys !== void 0 && req.sshKeys.length > 0 ? req.sshKeys : void 0;
|
|
1939
2107
|
authorizedKeysB64(await readHostPubkey(p), sshKeys ?? []);
|
|
1940
2108
|
const existing = await readAllMeta(p);
|
|
1941
|
-
const name = pickName(req.name, new Set(existing.filter((
|
|
2109
|
+
const name = pickName(req.name, new Set(existing.filter((m) => m.owner === owner).map((m) => m.name)));
|
|
1942
2110
|
const id = newBoxId(idsInUse(existing));
|
|
1943
|
-
const ip = nextFreeIp(cfg.subnet, [...existing.map((
|
|
1944
|
-
const mac = macFor(ip);
|
|
1945
|
-
const m = p.machine(owner, name);
|
|
2111
|
+
const ip = nextFreeIp(cfg.subnet, [...existing.map((m) => m.ip), cfg.gateway]);
|
|
1946
2112
|
const s = p.snapshot(from.owner, from.name);
|
|
2113
|
+
return restoreInto(
|
|
2114
|
+
{ id, name, owner, ip, mac: macFor(ip), vcpu: snap.vcpu, memMib: snap.memMib, base: snap.base, sshKeys },
|
|
2115
|
+
async (m) => {
|
|
2116
|
+
await run(["cp", "--sparse=always", "--reflink=auto", s.overlay, m.overlay]);
|
|
2117
|
+
await link2(s.mem, m.memBase);
|
|
2118
|
+
return { vmstate: s.vmstate, mem: s.mem, label: shown(from) };
|
|
2119
|
+
},
|
|
2120
|
+
{ source: `snapshot:${refKey(from)}`, autoHibernateSec: autoHibernateFor(req, cfg) },
|
|
2121
|
+
cfg,
|
|
2122
|
+
p
|
|
2123
|
+
);
|
|
2124
|
+
}
|
|
2125
|
+
async function restoreInto(box, prepare, origin, cfg, p, keysOrFail) {
|
|
2126
|
+
const { id, ip, owner, name } = box;
|
|
2127
|
+
const m = p.machine(owner, name);
|
|
1947
2128
|
await ensureHostNetwork(cfg);
|
|
1948
|
-
await
|
|
2129
|
+
await mkdir4(m.dir, { recursive: true, mode: 448 });
|
|
1949
2130
|
let tapCreated = false;
|
|
1950
2131
|
let pid;
|
|
1951
2132
|
let sshPort;
|
|
1952
2133
|
let drawn;
|
|
1953
2134
|
try {
|
|
1954
|
-
drawn =
|
|
2135
|
+
drawn = isCloudBox(owner) ? await drawPorts(1, p) : void 0;
|
|
1955
2136
|
sshPort = drawn?.ports[0];
|
|
1956
|
-
|
|
1957
|
-
await link(s.mem, m.memBase);
|
|
2137
|
+
const image = await prepare(m);
|
|
1958
2138
|
const tap = await createTap(id, cfg, { detached: true });
|
|
1959
2139
|
tapCreated = true;
|
|
1960
|
-
const spec = {
|
|
1961
|
-
pid = await loadSnapshot(spec,
|
|
2140
|
+
const spec = { ...box, sshPort };
|
|
2141
|
+
pid = await loadSnapshot(spec, image, tap, cfg, p);
|
|
1962
2142
|
await resumeVm(m.sock);
|
|
1963
2143
|
await introduce(spec, m.vsock, cfg, {
|
|
1964
2144
|
silent: `nothing answered on the restored box's control channel for 3 s; the restored box was discarded, try again`,
|
|
1965
2145
|
noAnswer: "the restored box was discarded. kvbox-agent inside it took the request but gave no answer; try again, and if it keeps happening look at the box's journal (kvbox exec <box> -- sudo journalctl -u kvbox-agent) on a box started from the same snapshot."
|
|
1966
2146
|
});
|
|
1967
|
-
const stale = await pushAuthorizedKeys(
|
|
2147
|
+
const stale = await pushAuthorizedKeys(box, m.vsock, p);
|
|
2148
|
+
if (stale && keysOrFail) throw new KvboxError(`${keysOrFail}: ${stale}`);
|
|
1968
2149
|
await attachTap(id, cfg);
|
|
1969
2150
|
await openPorts(spec, cfg);
|
|
1970
|
-
const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2151
|
+
const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), ...origin, memSnapshots: 1, sshKeysStale: stale ? true : void 0 };
|
|
1971
2152
|
await writeMachineMeta(meta, p);
|
|
1972
2153
|
recordState(meta, "running", "new", p, meta.createdAt);
|
|
1973
2154
|
return toMachine(meta, p);
|
|
@@ -1975,12 +2156,71 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
|
|
|
1975
2156
|
if (pid !== void 0) killQuietly(pid);
|
|
1976
2157
|
await closePorts({ id, ip, sshPort }, cfg);
|
|
1977
2158
|
if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
|
|
1978
|
-
await
|
|
2159
|
+
await rm2(m.dir, { recursive: true, force: true });
|
|
1979
2160
|
throw err;
|
|
1980
2161
|
} finally {
|
|
1981
2162
|
drawn?.release();
|
|
1982
2163
|
}
|
|
1983
2164
|
}
|
|
2165
|
+
var forkMachine = locked(async function forkMachine2(ref, owner, req, p = paths()) {
|
|
2166
|
+
const src = await readMeta(ref, p);
|
|
2167
|
+
const cfg = await loadConfig(p);
|
|
2168
|
+
const status = statusOf(src, p);
|
|
2169
|
+
if (status === "hibernated") throw new KvboxError(`${stateMessage(ref, status)}; a fork copies a running or a stopped box (kvbox wake ${shown(ref)} first)`);
|
|
2170
|
+
const warm = status !== "stopped";
|
|
2171
|
+
const srcDisk = p.machine(src.owner, src.name).overlay;
|
|
2172
|
+
let shape = { vcpu: src.vcpu, memMib: src.memMib };
|
|
2173
|
+
let growTo;
|
|
2174
|
+
if (req.size !== void 0) {
|
|
2175
|
+
if (warm) {
|
|
2176
|
+
throw new KvboxError(`--size cannot be combined with forking a running box: the copy keeps its memory, so its size (${shown(ref)}: ${src.vcpu} vCPU, ${src.memMib} MiB); kvbox vm rescale the copy afterwards`);
|
|
2177
|
+
}
|
|
2178
|
+
if (!isSizeName(req.size)) throw new KvboxError(`unknown size "${req.size}"; use one of ${Object.keys(SIZES).join(", ")}`);
|
|
2179
|
+
const preset = SIZES[req.size];
|
|
2180
|
+
const diskGib = Math.round(statSync(srcDisk).size / 1024 ** 3);
|
|
2181
|
+
if (preset.diskGib < diskGib) throw new KvboxError(`box ${shown(ref)} has a ${diskGib} GB disk and a disk cannot shrink; size ${req.size} has ${preset.diskGib} GB`);
|
|
2182
|
+
shape = { vcpu: preset.vcpu, memMib: preset.memMib };
|
|
2183
|
+
growTo = preset.diskGib > diskGib ? preset.diskGib : void 0;
|
|
2184
|
+
}
|
|
2185
|
+
assertAssets(cfg);
|
|
2186
|
+
baseFileFor(src, p);
|
|
2187
|
+
await assertRoomFor(shape.vcpu, shape.memMib, req.force ?? false, p);
|
|
2188
|
+
const sshKeys = isCloudBox(owner) && req.sshKeys !== void 0 && req.sshKeys.length > 0 ? req.sshKeys : void 0;
|
|
2189
|
+
authorizedKeysB64(await readHostPubkey(p), sshKeys ?? []);
|
|
2190
|
+
const existing = await readAllMeta(p);
|
|
2191
|
+
const name = pickName(req.name, new Set(existing.filter((m) => m.owner === owner).map((m) => m.name)));
|
|
2192
|
+
const id = newBoxId(idsInUse(existing));
|
|
2193
|
+
const ip = nextFreeIp(cfg.subnet, [...existing.map((m) => m.ip), cfg.gateway]);
|
|
2194
|
+
const box = { id, name, owner, ip, mac: macFor(ip), ...shape, base: src.base, sshKeys };
|
|
2195
|
+
const origin = { source: `fork:${refKey(src)}`, autoHibernateSec: (req.autoHibernateSec ?? src.autoHibernateSec) || void 0 };
|
|
2196
|
+
if (!warm) {
|
|
2197
|
+
return bootInto(
|
|
2198
|
+
box,
|
|
2199
|
+
async (m) => {
|
|
2200
|
+
await run(["cp", "--sparse=always", "--reflink=auto", srcDisk, m.overlay]);
|
|
2201
|
+
if (growTo !== void 0) await growDisk(m.overlay, growTo);
|
|
2202
|
+
},
|
|
2203
|
+
origin,
|
|
2204
|
+
cfg,
|
|
2205
|
+
p
|
|
2206
|
+
);
|
|
2207
|
+
}
|
|
2208
|
+
const machine = await restoreInto(
|
|
2209
|
+
box,
|
|
2210
|
+
async (m) => {
|
|
2211
|
+
await captureBox(src, { vmstate: m.forkVmstate, mem: m.memBase, overlay: m.overlay }, cfg, p);
|
|
2212
|
+
return { vmstate: m.forkVmstate, mem: m.memBase, label: `box ${shown(ref)}` };
|
|
2213
|
+
},
|
|
2214
|
+
origin,
|
|
2215
|
+
cfg,
|
|
2216
|
+
p,
|
|
2217
|
+
// The copy froze the source slug's CA line; left in place, that slug's members could open a box of
|
|
2218
|
+
// another slug. Such a fork is discarded rather than kept stale; a cold fork boots with the new line.
|
|
2219
|
+
owner === src.owner ? void 0 : `box ${shown(ref)} was not forked into ${owner}: the copy would still let ${src.owner} in (stop the box and fork it cold, which boots with ${owner}'s keys)`
|
|
2220
|
+
);
|
|
2221
|
+
await rm2(p.machine(owner, name).forkVmstate, { force: true });
|
|
2222
|
+
return machine;
|
|
2223
|
+
});
|
|
1984
2224
|
var AGENT_REPLY_MS = 2e4;
|
|
1985
2225
|
async function introduce(spec, vsock, cfg, failure2) {
|
|
1986
2226
|
const id = { name: spec.name, ip: spec.ip, prefixLen: prefixLenOf(cfg.subnet), gateway: cfg.gateway, mac: spec.mac, time: Math.floor(Date.now() / 1e3) };
|
|
@@ -2047,8 +2287,8 @@ async function cleanupProcess(meta, cfg, p) {
|
|
|
2047
2287
|
await closePorts(meta, cfg);
|
|
2048
2288
|
await deleteTap(meta.id, cfg);
|
|
2049
2289
|
const m = p.machine(meta.owner, meta.name);
|
|
2050
|
-
await
|
|
2051
|
-
await
|
|
2290
|
+
await rm2(m.sock, { force: true });
|
|
2291
|
+
await rm2(m.vsock, { force: true });
|
|
2052
2292
|
}
|
|
2053
2293
|
var stopMachine = locked(async function stopMachine2(ref, p = paths()) {
|
|
2054
2294
|
const meta = await readMeta(ref, p);
|
|
@@ -2056,8 +2296,8 @@ var stopMachine = locked(async function stopMachine2(ref, p = paths()) {
|
|
|
2056
2296
|
const m = p.machine(ref.owner, ref.name);
|
|
2057
2297
|
if (!vmAlive(meta) && !isHibernated(meta, p)) throw new KvboxError(`box ${shown(ref)} is already stopped`);
|
|
2058
2298
|
await shutdown(meta, cfg, p, 1e4);
|
|
2059
|
-
await
|
|
2060
|
-
await
|
|
2299
|
+
await rm2(m.hibernateDir, { recursive: true, force: true });
|
|
2300
|
+
await rm2(m.memBase, { force: true });
|
|
2061
2301
|
const stopped = { ...meta, pid: null, memSnapshots: void 0, paused: void 0 };
|
|
2062
2302
|
await writeMachineMeta(stopped, p);
|
|
2063
2303
|
recordState(stopped, "stopped", "stop", p);
|
|
@@ -2175,15 +2415,15 @@ var hibernateMachine = locked(async function hibernateMachine2(ref, p = paths(),
|
|
|
2175
2415
|
if (status === "hibernated") throw new KvboxError(`${stateMessage(ref, status)} already`);
|
|
2176
2416
|
if (status === "stopped") throw new KvboxError(`${stateMessage(ref, status)}; only a running box can be hibernated (there is no memory to keep)`);
|
|
2177
2417
|
const m = p.machine(ref.owner, ref.name);
|
|
2178
|
-
await
|
|
2179
|
-
await
|
|
2418
|
+
await rm2(m.hibernateDir, { recursive: true, force: true });
|
|
2419
|
+
await mkdir4(m.hibernateDir, { recursive: true, mode: 448 });
|
|
2180
2420
|
const fc = new FirecrackerClient(m.sock);
|
|
2181
2421
|
try {
|
|
2182
2422
|
try {
|
|
2183
2423
|
await fc.pause();
|
|
2184
2424
|
await fc.snapshotCreate(m.hibernateVmstate, m.hibernateMem, "Full");
|
|
2185
2425
|
} catch (err) {
|
|
2186
|
-
await
|
|
2426
|
+
await rm2(m.hibernateDir, { recursive: true, force: true });
|
|
2187
2427
|
if (!meta.paused) await fc.resume().catch(() => void 0);
|
|
2188
2428
|
throw err;
|
|
2189
2429
|
}
|
|
@@ -2194,7 +2434,7 @@ var hibernateMachine = locked(async function hibernateMachine2(ref, p = paths(),
|
|
|
2194
2434
|
await waitFor(() => !pidAlive(meta.pid), 5e3, 50);
|
|
2195
2435
|
await cleanupProcess(meta, cfg, p);
|
|
2196
2436
|
await run(["fallocate", "--dig-holes", m.hibernateMem]);
|
|
2197
|
-
await
|
|
2437
|
+
await rm2(m.memBase, { force: true });
|
|
2198
2438
|
const hibernated = { ...meta, pid: null, memSnapshots: void 0, paused: void 0 };
|
|
2199
2439
|
await writeMachineMeta(hibernated, p);
|
|
2200
2440
|
recordState(hibernated, "hibernated", by, p);
|
|
@@ -2207,7 +2447,7 @@ var wakeMachine = locked(async function wakeMachine2(ref, p = paths()) {
|
|
|
2207
2447
|
if (status === "running" || status === "standby") throw new KvboxError(`${stateMessage(ref, status)}, not hibernated`);
|
|
2208
2448
|
if (status === "stopped") throw new KvboxError(`${stateMessage(ref, status)}, not hibernated; kvbox start ${shown(ref)} boots it`);
|
|
2209
2449
|
assertAssets(cfg);
|
|
2210
|
-
if (!
|
|
2450
|
+
if (!existsSync3(p.baseImage(meta.base))) {
|
|
2211
2451
|
throw new KvboxError(`box ${shown(ref)} stacks on base image ${meta.base}, which is gone from ${p.assets}`);
|
|
2212
2452
|
}
|
|
2213
2453
|
const m = p.machine(ref.owner, ref.name);
|
|
@@ -2232,9 +2472,9 @@ var wakeMachine = locked(async function wakeMachine2(ref, p = paths()) {
|
|
|
2232
2472
|
await deleteTap(meta.id, cfg).catch(() => void 0);
|
|
2233
2473
|
throw err;
|
|
2234
2474
|
}
|
|
2235
|
-
await
|
|
2236
|
-
await
|
|
2237
|
-
await
|
|
2475
|
+
await rm2(m.memBase, { force: true });
|
|
2476
|
+
await link2(m.hibernateMem, m.memBase);
|
|
2477
|
+
await rm2(m.hibernateDir, { recursive: true, force: true });
|
|
2238
2478
|
const running = { ...meta, pid, bootId: currentBootId(), memSnapshots: 1, paused: void 0, sshKeysStale: stale ? true : void 0 };
|
|
2239
2479
|
await writeMachineMeta(running, p);
|
|
2240
2480
|
recordState(running, "running", "wake", p);
|
|
@@ -2244,7 +2484,7 @@ async function applyAtNextBoot(meta, changes, by, cfg, p, whileStopped) {
|
|
|
2244
2484
|
const wasRunning = vmAlive(meta);
|
|
2245
2485
|
if (wasRunning) {
|
|
2246
2486
|
await shutdown(meta, cfg, p, 1e4);
|
|
2247
|
-
await
|
|
2487
|
+
await rm2(p.machine(meta.owner, meta.name).memBase, { force: true });
|
|
2248
2488
|
}
|
|
2249
2489
|
const updated = { ...meta, ...changes, pid: null, memSnapshots: void 0, paused: void 0 };
|
|
2250
2490
|
await writeMachineMeta(updated, p);
|
|
@@ -2366,7 +2606,7 @@ var destroyMachine = locked(async function destroyMachine2(ref, p = paths()) {
|
|
|
2366
2606
|
const cfg = await loadConfig(p);
|
|
2367
2607
|
await shutdown(meta, cfg, p, 2e3);
|
|
2368
2608
|
recordState(meta, "stopped", "rm", p);
|
|
2369
|
-
await
|
|
2609
|
+
await rm2(p.machine(ref.owner, ref.name).dir, { recursive: true, force: true });
|
|
2370
2610
|
});
|
|
2371
2611
|
var RECOVERY_CONCURRENCY = 2;
|
|
2372
2612
|
async function settleDeadBoxes(p = paths()) {
|
|
@@ -2381,7 +2621,7 @@ async function settleDeadBoxes(p = paths()) {
|
|
|
2381
2621
|
} catch (err) {
|
|
2382
2622
|
console.error(`warning: usage ledger: ${err.message}`);
|
|
2383
2623
|
}
|
|
2384
|
-
await
|
|
2624
|
+
await rm2(p.machine(meta.owner, meta.name).memBase, { force: true });
|
|
2385
2625
|
if (meta.paused) {
|
|
2386
2626
|
await writeMachineMeta({ ...meta, pid: null, bootId: void 0, memSnapshots: void 0, paused: void 0 }, p);
|
|
2387
2627
|
leftStopped.push(refKey(meta));
|
|
@@ -2430,7 +2670,7 @@ async function bootRecovered(settled, p = paths()) {
|
|
|
2430
2670
|
}
|
|
2431
2671
|
|
|
2432
2672
|
// src/core/relay.ts
|
|
2433
|
-
import { chmod as chmod3, mkdir as
|
|
2673
|
+
import { chmod as chmod3, mkdir as mkdir5, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
|
|
2434
2674
|
|
|
2435
2675
|
// ../shared/src/relay.ts
|
|
2436
2676
|
import { z as z5 } from "zod";
|
|
@@ -2494,7 +2734,7 @@ async function useRelay(joinString, p = paths()) {
|
|
|
2494
2734
|
`${cfg.controlPlane.name} reaches this host at ${cfg.relay.publicIp}, not ${join2.publicIp}; run kvbox server leave first, then join again`
|
|
2495
2735
|
);
|
|
2496
2736
|
}
|
|
2497
|
-
await
|
|
2737
|
+
await mkdir5(p.relay, { recursive: true, mode: 448 });
|
|
2498
2738
|
await writeFile4(p.relayConf, relayConf(join2), { mode: 384 });
|
|
2499
2739
|
await chmod3(p.relayConf, 384);
|
|
2500
2740
|
await netSetup(cfg, ["relay", "up", p.relayConf, String(cfg.controlPlane?.port ?? DEFAULT_HTTPS_PORT)]);
|
|
@@ -2510,7 +2750,7 @@ async function dropRelay(p = paths()) {
|
|
|
2510
2750
|
throw new KvboxError(`${cfg.controlPlane.name} reaches this host through the relay (${relay.publicIp}); run kvbox server leave first`);
|
|
2511
2751
|
}
|
|
2512
2752
|
await netSetup(cfg, ["relay", "down"]);
|
|
2513
|
-
await
|
|
2753
|
+
await rm3(p.relayConf, { force: true });
|
|
2514
2754
|
const { relay: _gone, ...rest } = cfg;
|
|
2515
2755
|
await saveConfig(rest, p);
|
|
2516
2756
|
return relay;
|
|
@@ -2685,7 +2925,7 @@ function versionOf(srcFileUrl, injected) {
|
|
|
2685
2925
|
}
|
|
2686
2926
|
|
|
2687
2927
|
// src/version.ts
|
|
2688
|
-
var VERSION = versionOf(import.meta.url, true ? "0.5.
|
|
2928
|
+
var VERSION = versionOf(import.meta.url, true ? "0.5.19" : void 0);
|
|
2689
2929
|
|
|
2690
2930
|
// src/caller.ts
|
|
2691
2931
|
import { createHash as createHash2, timingSafeEqual } from "node:crypto";
|
|
@@ -3053,6 +3293,12 @@ function machineRoutes() {
|
|
|
3053
3293
|
const body = CreateMachineRequest.parse(await c.req.json().catch(() => ({})));
|
|
3054
3294
|
return c.json(await createMachine(body, c.get("owner")), 201);
|
|
3055
3295
|
});
|
|
3296
|
+
r.post("/:name/fork", async (c) => {
|
|
3297
|
+
const body = ForkMachineRequest.parse(await c.req.json().catch(() => ({})));
|
|
3298
|
+
const caller = c.get("owner");
|
|
3299
|
+
const into = body.owner === void 0 ? caller : ownerFor(body.owner, caller);
|
|
3300
|
+
return c.json(await forkMachine(parseRef(c.req.param("name"), caller), into, body), 201);
|
|
3301
|
+
});
|
|
3056
3302
|
r.post("/:name/stop", async (c) => c.json(await stopMachine(parseRef(c.req.param("name"), c.get("owner")))));
|
|
3057
3303
|
r.post("/:name/start", async (c) => c.json(await startMachine(parseRef(c.req.param("name"), c.get("owner")))));
|
|
3058
3304
|
r.post("/:name/pause", async (c) => c.json(await pauseMachine(parseRef(c.req.param("name"), c.get("owner")))));
|
|
@@ -3104,131 +3350,6 @@ function machineRoutes() {
|
|
|
3104
3350
|
|
|
3105
3351
|
// src/routes/snapshots.ts
|
|
3106
3352
|
import { Hono as Hono4 } from "hono";
|
|
3107
|
-
|
|
3108
|
-
// src/core/snapshots.ts
|
|
3109
|
-
import { existsSync as existsSync3 } from "node:fs";
|
|
3110
|
-
import { link as link2, mkdir as mkdir5, readdir as readdir2, rename as rename2, rm as rm3, stat as stat2 } from "node:fs/promises";
|
|
3111
|
-
var NAME_RE2 = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
3112
|
-
function toSnapshot(meta) {
|
|
3113
|
-
return {
|
|
3114
|
-
name: meta.name,
|
|
3115
|
-
owner: meta.owner,
|
|
3116
|
-
source: meta.source,
|
|
3117
|
-
base: meta.base,
|
|
3118
|
-
vcpu: meta.vcpu,
|
|
3119
|
-
memMib: meta.memMib,
|
|
3120
|
-
sizeBytes: meta.sizeBytes,
|
|
3121
|
-
createdAt: meta.createdAt
|
|
3122
|
-
};
|
|
3123
|
-
}
|
|
3124
|
-
async function listSnapshots(scope, p = paths()) {
|
|
3125
|
-
const out = [];
|
|
3126
|
-
for (const ref of await listSnapshotRefs(p)) {
|
|
3127
|
-
if (!inScope(ref.owner, scope)) continue;
|
|
3128
|
-
try {
|
|
3129
|
-
out.push(toSnapshot(await readSnapshotMeta(ref, p)));
|
|
3130
|
-
} catch (err) {
|
|
3131
|
-
console.error(`warning: skipping snapshots/${refKey(ref)}: ${err.message}`);
|
|
3132
|
-
}
|
|
3133
|
-
}
|
|
3134
|
-
return out;
|
|
3135
|
-
}
|
|
3136
|
-
async function usedBytes(dir) {
|
|
3137
|
-
let total = 0;
|
|
3138
|
-
for (const entry of await readdir2(dir)) {
|
|
3139
|
-
total += (await stat2(`${dir}/${entry}`)).blocks * 512;
|
|
3140
|
-
}
|
|
3141
|
-
return total;
|
|
3142
|
-
}
|
|
3143
|
-
async function readBox(ref, p) {
|
|
3144
|
-
try {
|
|
3145
|
-
return await readMachineMeta(ref, p);
|
|
3146
|
-
} catch (err) {
|
|
3147
|
-
if (err.code === "ENOENT") throw new NotFoundError(`box ${shown(ref)}`);
|
|
3148
|
-
throw err;
|
|
3149
|
-
}
|
|
3150
|
-
}
|
|
3151
|
-
async function saveSnapshot(req, owner, p = paths()) {
|
|
3152
|
-
if (!NAME_RE2.test(req.name)) {
|
|
3153
|
-
throw new KvboxError(`invalid snapshot name "${req.name}": 1-64 chars, lowercase letters, digits, '.', '_' and '-', starting with a letter or digit`);
|
|
3154
|
-
}
|
|
3155
|
-
const ref = parseRef(req.box, owner);
|
|
3156
|
-
return withBoxLock(ref, () => snapshotBox(ref, req, owner, p));
|
|
3157
|
-
}
|
|
3158
|
-
async function snapshotBox(ref, req, owner, p) {
|
|
3159
|
-
const box = await readBox(ref, p);
|
|
3160
|
-
if (!vmAlive(box)) {
|
|
3161
|
-
if (existsSync3(p.machine(box.owner, box.name).hibernateVmstate)) {
|
|
3162
|
-
throw new KvboxError(`box ${req.box} is hibernated; a snapshot freezes a running box (kvbox wake ${req.box} first)`);
|
|
3163
|
-
}
|
|
3164
|
-
throw new KvboxError(`box ${req.box} is not running; a snapshot freezes a running box (kvbox start ${req.box} first)`);
|
|
3165
|
-
}
|
|
3166
|
-
const s = p.snapshot(owner, req.name);
|
|
3167
|
-
if (existsSync3(s.dir)) throw new KvboxError(`snapshot ${req.name} already exists`);
|
|
3168
|
-
const cfg = await loadConfig(p);
|
|
3169
|
-
const m = p.machine(box.owner, box.name);
|
|
3170
|
-
await mkdir5(s.dir, { recursive: true, mode: 448 });
|
|
3171
|
-
const fc = new FirecrackerClient(m.sock);
|
|
3172
|
-
let lineageTouched = false;
|
|
3173
|
-
try {
|
|
3174
|
-
const since = box.memSnapshots ?? 0;
|
|
3175
|
-
const mode = since === 0 || existsSync3(m.memBase) ? "Diff" : "Full";
|
|
3176
|
-
let failure2;
|
|
3177
|
-
await fc.pause();
|
|
3178
|
-
try {
|
|
3179
|
-
await fc.snapshotCreate(s.vmstate, s.mem, mode);
|
|
3180
|
-
lineageTouched = true;
|
|
3181
|
-
await run(["cp", "--sparse=always", "--reflink=auto", m.overlay, s.overlay]);
|
|
3182
|
-
} catch (err) {
|
|
3183
|
-
failure2 = err;
|
|
3184
|
-
} finally {
|
|
3185
|
-
if (!box.paused) await fc.resume();
|
|
3186
|
-
}
|
|
3187
|
-
if (lineageTouched) {
|
|
3188
|
-
await writeMachineMeta({ ...box, memSnapshots: since + 1 }, p);
|
|
3189
|
-
}
|
|
3190
|
-
if (failure2 !== void 0) throw failure2;
|
|
3191
|
-
if (mode === "Full") {
|
|
3192
|
-
await run(["fallocate", "--dig-holes", s.mem]);
|
|
3193
|
-
} else if (since > 0) {
|
|
3194
|
-
await mergeOntoPrevious(m.memBase, s.mem, cfg.snapshotEditor ?? p.snapshotEditor);
|
|
3195
|
-
}
|
|
3196
|
-
await rm3(m.memBase, { force: true });
|
|
3197
|
-
await link2(s.mem, m.memBase);
|
|
3198
|
-
const meta = {
|
|
3199
|
-
name: req.name,
|
|
3200
|
-
owner,
|
|
3201
|
-
source: refKey(box),
|
|
3202
|
-
base: box.base,
|
|
3203
|
-
vcpu: box.vcpu,
|
|
3204
|
-
memMib: box.memMib,
|
|
3205
|
-
sizeBytes: await usedBytes(s.dir),
|
|
3206
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3207
|
-
};
|
|
3208
|
-
await writeSnapshotMeta(meta, p);
|
|
3209
|
-
return toSnapshot(meta);
|
|
3210
|
-
} catch (err) {
|
|
3211
|
-
if (lineageTouched) await rm3(m.memBase, { force: true }).catch(() => void 0);
|
|
3212
|
-
await rm3(s.dir, { recursive: true, force: true });
|
|
3213
|
-
throw err;
|
|
3214
|
-
} finally {
|
|
3215
|
-
await fc.close();
|
|
3216
|
-
}
|
|
3217
|
-
}
|
|
3218
|
-
async function mergeOntoPrevious(previous, diffFile, editor) {
|
|
3219
|
-
if (!existsSync3(editor)) throw new KvboxError(`snapshot-editor missing at ${editor}; rerun install-server.sh`);
|
|
3220
|
-
const merged = `${diffFile}.merged`;
|
|
3221
|
-
await run(["cp", "--sparse=always", "--reflink=auto", previous, merged]);
|
|
3222
|
-
await run([editor, "edit-memory", "rebase", "--memory-path", merged, "--diff-path", diffFile]);
|
|
3223
|
-
await rename2(merged, diffFile);
|
|
3224
|
-
}
|
|
3225
|
-
async function deleteSnapshot(ref, p = paths()) {
|
|
3226
|
-
const dir = p.snapshot(ref.owner, ref.name).dir;
|
|
3227
|
-
if (!existsSync3(dir)) throw new NotFoundError(`snapshot ${shown(ref)}`);
|
|
3228
|
-
await rm3(dir, { recursive: true, force: true });
|
|
3229
|
-
}
|
|
3230
|
-
|
|
3231
|
-
// src/routes/snapshots.ts
|
|
3232
3353
|
function snapshotRoutes() {
|
|
3233
3354
|
const r = new Hono4();
|
|
3234
3355
|
r.get("/", async (c) => c.json(await listSnapshots(c.get("listScope"))));
|