@rallycry/conveyor-k3 0.1.11 → 0.1.13
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/cli.js +6117 -1521
- package/dist/index.d.ts +311 -84
- package/dist/index.js +2382 -475
- package/dist/vm-qemu.js +256 -91
- package/package.json +1 -1
package/dist/vm-qemu.js
CHANGED
|
@@ -7,13 +7,127 @@ import { dirname, join as join5 } from "path";
|
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
import { createHash } from "crypto";
|
|
9
9
|
|
|
10
|
-
// src/vm/
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
// src/vm/capacity.ts
|
|
11
|
+
var GIB = 1024 ** 3;
|
|
12
|
+
var MIB = 1024 ** 2;
|
|
13
|
+
var CONTROL_RELAY_SLOTS = 6;
|
|
14
|
+
var CAPACITY_BOUNDS = {
|
|
15
|
+
relaySlots: { min: 16, max: 192, flag: "relay-slots" },
|
|
16
|
+
relayMaxGiB: { min: 1, max: 16, flag: "relay-max-gib" },
|
|
17
|
+
diskMiBps: { min: 25, max: 2e3, flag: "disk-mbps" },
|
|
18
|
+
diskIops: { min: 500, max: 5e4, flag: "disk-iops" }
|
|
19
|
+
};
|
|
20
|
+
var LEGACY_CAPACITY_LIMITS = Object.freeze({
|
|
21
|
+
schema: 1,
|
|
22
|
+
profile: "default",
|
|
23
|
+
relaySlots: 16,
|
|
24
|
+
reservedRelaySlots: 0,
|
|
25
|
+
relayMaxBytes: GIB,
|
|
26
|
+
diskBytesPerSecond: 100 * MIB,
|
|
27
|
+
diskIops: 2e3
|
|
28
|
+
});
|
|
29
|
+
var KEYS = Object.keys(CAPACITY_BOUNDS);
|
|
30
|
+
var PROFILES = /* @__PURE__ */ new Set(["default", "server", "custom"]);
|
|
31
|
+
function parseCapacityLimits(value) {
|
|
32
|
+
if (value === void 0) return { ...LEGACY_CAPACITY_LIMITS };
|
|
33
|
+
const limits = typeof value === "object" && value !== null ? value : {};
|
|
34
|
+
const within = (key, min, max) => Number.isSafeInteger(limits[key]) && limits[key] >= min && limits[key] <= max;
|
|
35
|
+
const bounds = CAPACITY_BOUNDS;
|
|
36
|
+
if (limits.schema !== 1 || !PROFILES.has(limits.profile) || !within("relaySlots", bounds.relaySlots.min, bounds.relaySlots.max) || limits.reservedRelaySlots !== 0 && limits.reservedRelaySlots !== CONTROL_RELAY_SLOTS || !within("relayMaxBytes", bounds.relayMaxGiB.min * GIB, bounds.relayMaxGiB.max * GIB) || !within("diskBytesPerSecond", bounds.diskMiBps.min * MIB, bounds.diskMiBps.max * MIB) || !within("diskIops", bounds.diskIops.min, bounds.diskIops.max))
|
|
37
|
+
throw new Error("The VM capacity limits in the host network policy are invalid.");
|
|
38
|
+
return {
|
|
39
|
+
schema: 1,
|
|
40
|
+
profile: limits.profile,
|
|
41
|
+
relaySlots: limits.relaySlots,
|
|
42
|
+
reservedRelaySlots: limits.reservedRelaySlots,
|
|
43
|
+
relayMaxBytes: limits.relayMaxBytes,
|
|
44
|
+
diskBytesPerSecond: limits.diskBytesPerSecond,
|
|
45
|
+
diskIops: limits.diskIops
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function guardProcessLimit(relaySlots) {
|
|
49
|
+
return 48 + relaySlots;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/vm/instance-name.ts
|
|
53
|
+
var VM_NAME = "conveyor";
|
|
54
|
+
var DEFAULT_INSTANCE = "default";
|
|
55
|
+
var NAMED_INSTANCE = /^(?=.{1,12}$)[a-z](?:-?[a-z0-9])*$/;
|
|
56
|
+
function isNamedInstance(value) {
|
|
57
|
+
return typeof value === "string" && NAMED_INSTANCE.test(value) && value !== DEFAULT_INSTANCE && value !== VM_NAME;
|
|
58
|
+
}
|
|
59
|
+
function vmNameFor(instance) {
|
|
60
|
+
return instance === DEFAULT_INSTANCE ? VM_NAME : instance;
|
|
61
|
+
}
|
|
62
|
+
function stateInstance(value) {
|
|
63
|
+
if (value === void 0) return DEFAULT_INSTANCE;
|
|
64
|
+
if (!isNamedInstance(value)) throw new Error("Invalid VM instance in host state");
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
13
67
|
|
|
14
68
|
// src/vm/network.ts
|
|
15
69
|
import { networkInterfaces } from "os";
|
|
16
70
|
import { resolve4 as systemResolve4, resolveSrv as systemResolveSrv } from "dns/promises";
|
|
71
|
+
|
|
72
|
+
// src/vm/endpoints.ts
|
|
73
|
+
var DEFAULT_ENDPOINTS = [
|
|
74
|
+
"archive.ubuntu.com",
|
|
75
|
+
"security.ubuntu.com",
|
|
76
|
+
"ports.ubuntu.com",
|
|
77
|
+
"registry.npmjs.org",
|
|
78
|
+
// Font Awesome Pro tarballs through Conveyor's Artifact Registry cache.
|
|
79
|
+
"us-central1-npm.pkg.dev",
|
|
80
|
+
// Tiptap Pro packages, which every claudespace bake installs.
|
|
81
|
+
"registry.tiptap.dev",
|
|
82
|
+
// The Python venv a pod builds at runtime.
|
|
83
|
+
"pypi.org",
|
|
84
|
+
"files.pythonhosted.org",
|
|
85
|
+
"bun.sh",
|
|
86
|
+
"get.k3s.io",
|
|
87
|
+
"update.k3s.io",
|
|
88
|
+
"github.com",
|
|
89
|
+
"api.github.com",
|
|
90
|
+
"raw.githubusercontent.com",
|
|
91
|
+
"release-assets.githubusercontent.com",
|
|
92
|
+
"objects.githubusercontent.com",
|
|
93
|
+
"ghcr.io",
|
|
94
|
+
"pkg-containers.githubusercontent.com",
|
|
95
|
+
// The crane image that shadow bakes' state-bake and publish Jobs run. gcr.io
|
|
96
|
+
// serves its blobs from the same host (/artifacts-downloads), on Google's
|
|
97
|
+
// shared frontend addresses like us-central1-npm.pkg.dev.
|
|
98
|
+
"gcr.io",
|
|
99
|
+
// The first shadow bake on a VM builds the toolchain base inside it; base
|
|
100
|
+
// reuse never crosses machines. These are the hosts that build downloads
|
|
101
|
+
// from. Several sit on shared CDN addresses.
|
|
102
|
+
// Plain HTTP too: the first apt-get runs before ca-certificates exists.
|
|
103
|
+
"deb.debian.org",
|
|
104
|
+
"www.postgresql.org",
|
|
105
|
+
"apt.postgresql.org",
|
|
106
|
+
"cli.github.com",
|
|
107
|
+
"johnvansickle.com",
|
|
108
|
+
"astral.sh",
|
|
109
|
+
// uv downloads CPython from here.
|
|
110
|
+
"releases.astral.sh",
|
|
111
|
+
"opencode.ai",
|
|
112
|
+
"cdn.playwright.dev",
|
|
113
|
+
"playwright.download.prss.microsoft.com",
|
|
114
|
+
"registry-1.docker.io",
|
|
115
|
+
"auth.docker.io",
|
|
116
|
+
"production.cloudflare.docker.com",
|
|
117
|
+
"production.cloudfront.docker.com",
|
|
118
|
+
"api.cloudflare.com",
|
|
119
|
+
"conveyor.rallycryapp.com",
|
|
120
|
+
"conveyor-api.rallycryapp.com",
|
|
121
|
+
"api.conveyor.rallycryapp.com",
|
|
122
|
+
"api.anthropic.com",
|
|
123
|
+
"api.openai.com",
|
|
124
|
+
"chatgpt.com"
|
|
125
|
+
].map((host) => ({
|
|
126
|
+
host,
|
|
127
|
+
ports: host.endsWith(".ubuntu.com") || host === "deb.debian.org" ? [80, 443] : [443]
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
// src/vm/network.ts
|
|
17
131
|
var WORKER_SUBNET = "192.168.5.0/24";
|
|
18
132
|
var WORKER_IP = "192.168.5.15";
|
|
19
133
|
var MAX_NETWORK_FORWARDS = 100;
|
|
@@ -55,6 +169,8 @@ function isGloballyRoutableIPv4(address) {
|
|
|
55
169
|
}
|
|
56
170
|
|
|
57
171
|
// src/vm/public-web.ts
|
|
172
|
+
import { resolve4 } from "dns/promises";
|
|
173
|
+
import { networkInterfaces as networkInterfaces2 } from "os";
|
|
58
174
|
var PUBLIC_WEB_IP = "192.168.5.250";
|
|
59
175
|
var PUBLIC_WEB_PORT = 3128;
|
|
60
176
|
var PUBLIC_WEB_URL = `http://${PUBLIC_WEB_IP}:${PUBLIC_WEB_PORT}`;
|
|
@@ -146,6 +262,65 @@ function proxyUrl(target, connect) {
|
|
|
146
262
|
return { url, port };
|
|
147
263
|
}
|
|
148
264
|
|
|
265
|
+
// src/vm/qemu-forwards.ts
|
|
266
|
+
var ENDPOINT_IP = /^192\.168\.5\.(?:1\d\d)$/;
|
|
267
|
+
var CONTROL_ALIAS_IP = /^192\.168\.5\.24[0-7]$/;
|
|
268
|
+
var PINNED_IPV4 = /^(?:[1-9]?\d|1\d\d|2[0-4]\d|25[0-5])(?:\.(?:[1-9]?\d|1\d\d|2[0-4]\d|25[0-5])){3}$/;
|
|
269
|
+
function assertPort(port) {
|
|
270
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
271
|
+
throw new Error("Invalid guest forwarding port");
|
|
272
|
+
}
|
|
273
|
+
function validateForwards(forwards) {
|
|
274
|
+
const seen = /* @__PURE__ */ new Set();
|
|
275
|
+
for (const forward of forwards) {
|
|
276
|
+
assertPort(forward.port);
|
|
277
|
+
const key = `${forward.guestIp}:${forward.port}`;
|
|
278
|
+
if (seen.has(key)) throw new Error("Duplicate QEMU guest forwarding rule");
|
|
279
|
+
seen.add(key);
|
|
280
|
+
if (!isGloballyRoutableIPv4(forward.address) || !PINNED_IPV4.test(forward.address))
|
|
281
|
+
throw new Error("Invalid pinned forwarding address");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function forwardsOf(entries, guestIps, control) {
|
|
285
|
+
return entries.flatMap((host) => {
|
|
286
|
+
if (!guestIps.test(host.guestIp) || control && host.reserve !== "control")
|
|
287
|
+
throw new Error("Invalid pinned forwarding address");
|
|
288
|
+
return host.ports.map((port) => ({
|
|
289
|
+
guestIp: host.guestIp,
|
|
290
|
+
address: host.address,
|
|
291
|
+
port,
|
|
292
|
+
...host.reserve === "control" ? { reserve: "control" } : {}
|
|
293
|
+
}));
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
function qemuForwards(network) {
|
|
297
|
+
const forwards = [
|
|
298
|
+
...forwardsOf(network.hosts, ENDPOINT_IP, false),
|
|
299
|
+
...forwardsOf(network.control ?? [], CONTROL_ALIAS_IP, true)
|
|
300
|
+
];
|
|
301
|
+
if (forwards.length > MAX_NETWORK_FORWARDS) throw new Error("Too many QEMU forwarding rules");
|
|
302
|
+
validateForwards(forwards);
|
|
303
|
+
return forwards;
|
|
304
|
+
}
|
|
305
|
+
function restrictedUsernet(network, forwards, ncPath) {
|
|
306
|
+
const guestForwards = forwards.map(
|
|
307
|
+
(forward) => `guestfwd=tcp:${forward.guestIp}:${forward.port}-cmd:${ncPath} -4 -n -w 60 ${forward.address} ${forward.port}${forward.reserve === "control" ? " control" : ""}`
|
|
308
|
+
);
|
|
309
|
+
return [
|
|
310
|
+
"user",
|
|
311
|
+
"id=net0",
|
|
312
|
+
`net=${WORKER_SUBNET}`,
|
|
313
|
+
`dhcpstart=${WORKER_IP}`,
|
|
314
|
+
"restrict=on",
|
|
315
|
+
"ipv6=off",
|
|
316
|
+
`hostfwd=tcp:127.0.0.1:${network.sshPort}-:22`,
|
|
317
|
+
...guestForwards,
|
|
318
|
+
...network.publicWeb === true ? [
|
|
319
|
+
`guestfwd=tcp:${PUBLIC_WEB_IP}:${PUBLIC_WEB_PORT}-cmd:${ncPath} -4 -n -w 60 public-web ${PUBLIC_WEB_PORT}`
|
|
320
|
+
] : []
|
|
321
|
+
].join(",");
|
|
322
|
+
}
|
|
323
|
+
|
|
149
324
|
// src/vm/qemu-policy.ts
|
|
150
325
|
var ALLOWED_PROBES = new Set(
|
|
151
326
|
[
|
|
@@ -199,9 +374,7 @@ var REQUIRED_CONTROLS = [
|
|
|
199
374
|
"drive:cidata"
|
|
200
375
|
];
|
|
201
376
|
var DISALLOWED_CONTROLS = /* @__PURE__ */ new Set(["-sandbox", "-no-user-config", "-nodefaults"]);
|
|
202
|
-
|
|
203
|
-
var BOOT_DISK_IOPS = "2000";
|
|
204
|
-
function assertPort(port, field) {
|
|
377
|
+
function assertPort2(port, field) {
|
|
205
378
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid ${field}`);
|
|
206
379
|
}
|
|
207
380
|
function isProbe(args) {
|
|
@@ -322,10 +495,11 @@ function validateDrive(value, context) {
|
|
|
322
495
|
}
|
|
323
496
|
assertFields(fields, { file: /.+/, if: "none", discard: "on", id: "boot-disk" }, "boot drive");
|
|
324
497
|
assertInstancePath(path, "disk", context);
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
498
|
+
const disk = context.disk ?? {
|
|
499
|
+
bps: LEGACY_CAPACITY_LIMITS.diskBytesPerSecond,
|
|
500
|
+
iops: LEGACY_CAPACITY_LIMITS.diskIops
|
|
328
501
|
};
|
|
502
|
+
return { kind: "boot", value: `${value},bps=${disk.bps},iops=${disk.iops}` };
|
|
329
503
|
}
|
|
330
504
|
if (fields.get("id") === "cdrom0") {
|
|
331
505
|
assertFields(
|
|
@@ -370,38 +544,6 @@ function parseOriginalUsernet(value, sshPort) {
|
|
|
370
544
|
"Lima QEMU user network"
|
|
371
545
|
);
|
|
372
546
|
}
|
|
373
|
-
function restrictedUsernet(network, forwards, ncPath) {
|
|
374
|
-
const guestForwards = forwards.map(
|
|
375
|
-
(forward) => `guestfwd=tcp:${forward.guestIp}:${forward.port}-cmd:${ncPath} -4 -n -w 60 ${forward.address} ${forward.port}`
|
|
376
|
-
);
|
|
377
|
-
return [
|
|
378
|
-
"user",
|
|
379
|
-
"id=net0",
|
|
380
|
-
`net=${WORKER_SUBNET}`,
|
|
381
|
-
`dhcpstart=${WORKER_IP}`,
|
|
382
|
-
"restrict=on",
|
|
383
|
-
"ipv6=off",
|
|
384
|
-
`hostfwd=tcp:127.0.0.1:${network.sshPort}-:22`,
|
|
385
|
-
...guestForwards,
|
|
386
|
-
...network.publicWeb === true ? [
|
|
387
|
-
`guestfwd=tcp:${PUBLIC_WEB_IP}:${PUBLIC_WEB_PORT}-cmd:${ncPath} -4 -n -w 60 public-web ${PUBLIC_WEB_PORT}`
|
|
388
|
-
] : []
|
|
389
|
-
].join(",");
|
|
390
|
-
}
|
|
391
|
-
function validateForwards(forwards) {
|
|
392
|
-
const seen = /* @__PURE__ */ new Set();
|
|
393
|
-
for (const forward of forwards) {
|
|
394
|
-
assertPort(forward.port, "guest forwarding port");
|
|
395
|
-
const key = `${forward.guestIp}:${forward.port}`;
|
|
396
|
-
if (seen.has(key)) throw new Error("Duplicate QEMU guest forwarding rule");
|
|
397
|
-
seen.add(key);
|
|
398
|
-
if (!/^192\.168\.5\.(?:1\d\d)$/.test(forward.guestIp) || !isGloballyRoutableIPv4(forward.address) || !/^(?:[1-9]?\d|1\d\d|2[0-4]\d|25[0-5])(?:\.(?:[1-9]?\d|1\d\d|2[0-4]\d|25[0-5])){3}$/.test(
|
|
399
|
-
forward.address
|
|
400
|
-
)) {
|
|
401
|
-
throw new Error("Invalid pinned forwarding address");
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
547
|
function fixedControl(control, expected) {
|
|
406
548
|
return (value) => {
|
|
407
549
|
if (value !== expected) throw new Error(`Unexpected QEMU ${control}`);
|
|
@@ -434,7 +576,7 @@ var SIMPLE_VALIDATORS = {
|
|
|
434
576
|
"-parallel": fixedControl("-parallel", "none"),
|
|
435
577
|
"-serial": fixedControl("serial", "chardev:char-serial"),
|
|
436
578
|
"-qmp": fixedControl("qmp", "chardev:char-qmp"),
|
|
437
|
-
"-name": fixedControl("name",
|
|
579
|
+
"-name": (value, { vmName = VM_NAME }) => fixedControl("name", `lima-${vmName}`)(value, {}),
|
|
438
580
|
"-pidfile": (value, context) => {
|
|
439
581
|
assertInstancePath(value, "qemu.pid", context);
|
|
440
582
|
return { value, control: "pidfile" };
|
|
@@ -505,15 +647,16 @@ function assertCompleteProfile(state) {
|
|
|
505
647
|
throw new Error("QEMU PCI serial device and chardev must appear together");
|
|
506
648
|
}
|
|
507
649
|
}
|
|
508
|
-
function rewriteQemuArgs(args, network, ncPath = "/usr/bin/nc",
|
|
650
|
+
function rewriteQemuArgs(args, network, ncPath = "/usr/bin/nc", hostContext = {}) {
|
|
509
651
|
if (isProbe(args)) return [...args];
|
|
510
|
-
|
|
652
|
+
assertPort2(network.sshPort, "SSH port");
|
|
511
653
|
if (!TRUSTED_NC_PATH.test(ncPath)) throw new Error("Invalid netcat path");
|
|
512
|
-
const forwards = network
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
654
|
+
const forwards = qemuForwards(network);
|
|
655
|
+
const limits = parseCapacityLimits(network.limits);
|
|
656
|
+
const context = {
|
|
657
|
+
...hostContext,
|
|
658
|
+
disk: { bps: limits.diskBytesPerSecond, iops: limits.diskIops }
|
|
659
|
+
};
|
|
517
660
|
const output = [];
|
|
518
661
|
const state = {
|
|
519
662
|
controls: /* @__PURE__ */ new Set(),
|
|
@@ -569,12 +712,12 @@ import { execFileSync } from "child_process";
|
|
|
569
712
|
import { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
570
713
|
import { join } from "path";
|
|
571
714
|
import { Socket } from "net";
|
|
572
|
-
var
|
|
573
|
-
var MAX_RELAY_BYTES = 1024 * 1024 * 1024;
|
|
715
|
+
var SLOT_CEILING = CAPACITY_BOUNDS.relaySlots.max;
|
|
574
716
|
var RELAY_CONNECT_MS = 6e4;
|
|
575
717
|
var RELAY_IDLE_MS = 15 * 6e4;
|
|
576
718
|
var GUARD_FRESH_MS = 6e3;
|
|
577
719
|
var RELAY_PREFIX = "--conveyor-vm-relay";
|
|
720
|
+
var RELAY_FLAGS = ["-4", "-n", "-w", "60"];
|
|
578
721
|
function validPid(value) {
|
|
579
722
|
return Number.isInteger(value) && value > 1 && value <= 4194304;
|
|
580
723
|
}
|
|
@@ -625,11 +768,13 @@ function readNetwork(root) {
|
|
|
625
768
|
if (typeof parsed !== "object" || parsed === null)
|
|
626
769
|
throw new Error("VM relay network policy is invalid");
|
|
627
770
|
const network = parsed;
|
|
628
|
-
if (!Array.isArray(network.hosts)
|
|
629
|
-
|
|
771
|
+
if (!Array.isArray(network.hosts) || network.control !== void 0 && !Array.isArray(network.control))
|
|
772
|
+
throw new Error("VM relay network policy is invalid");
|
|
773
|
+
return { ...network, limits: parseCapacityLimits(network.limits) };
|
|
630
774
|
}
|
|
631
|
-
function assertApprovedDestination(
|
|
632
|
-
const
|
|
775
|
+
function assertApprovedDestination(network, invocation) {
|
|
776
|
+
const entries = invocation.reserve === "control" ? [...network.hosts, ...network.control ?? []].filter((host) => host?.reserve === "control") : network.hosts;
|
|
777
|
+
const allowed = entries.some(
|
|
633
778
|
(host) => host && host.address === invocation.address && Array.isArray(host.ports) && host.ports.includes(invocation.port)
|
|
634
779
|
);
|
|
635
780
|
if (!allowed) throw new Error("VM relay destination is not in the host network policy");
|
|
@@ -640,9 +785,10 @@ function recordPath(root, pid) {
|
|
|
640
785
|
function slotPath(root, slot) {
|
|
641
786
|
return join(relayDirectory(root), `slot-${slot}`);
|
|
642
787
|
}
|
|
643
|
-
function claimSlot(root) {
|
|
788
|
+
function claimSlot(root, limits, reserve) {
|
|
644
789
|
safeDirectory(relayDirectory(root));
|
|
645
|
-
|
|
790
|
+
const first = reserve === "control" ? 0 : limits.reservedRelaySlots;
|
|
791
|
+
for (let slot = first; slot < limits.relaySlots; slot += 1) {
|
|
646
792
|
try {
|
|
647
793
|
mkdirSync(slotPath(root, slot), { mode: 448 });
|
|
648
794
|
return slot;
|
|
@@ -650,10 +796,10 @@ function claimSlot(root) {
|
|
|
650
796
|
if (error.code !== "EEXIST") throw error;
|
|
651
797
|
}
|
|
652
798
|
}
|
|
653
|
-
throw new Error(`VM relay limit reached (${
|
|
799
|
+
throw new Error(`VM relay limit reached (${limits.relaySlots})`);
|
|
654
800
|
}
|
|
655
|
-
function registerRelay(root, runId) {
|
|
656
|
-
const slot = claimSlot(root);
|
|
801
|
+
function registerRelay(root, runId, limits, reserve) {
|
|
802
|
+
const slot = claimSlot(root, limits, reserve);
|
|
657
803
|
const record = {
|
|
658
804
|
schema: 1,
|
|
659
805
|
pid: process.pid,
|
|
@@ -683,15 +829,20 @@ function unregisterRelay(root, record) {
|
|
|
683
829
|
}
|
|
684
830
|
function parseRelayInvocation(args) {
|
|
685
831
|
if (args[0] !== RELAY_PREFIX) return null;
|
|
686
|
-
|
|
832
|
+
const control = args.length === 8 && args[7] === "control";
|
|
833
|
+
if (args.length !== 7 && !control || RELAY_FLAGS.some((flag, index) => args[index + 1] !== flag)) {
|
|
687
834
|
throw new Error("Invalid VM relay invocation");
|
|
688
835
|
}
|
|
689
836
|
const address = args[5] ?? "";
|
|
690
837
|
const port = Number(args[6]);
|
|
691
|
-
|
|
838
|
+
const publicWeb = !control && address === "public-web" && port === PUBLIC_WEB_PORT;
|
|
839
|
+
if (!(isGloballyRoutableIPv4(address) || publicWeb) || !validPort(port)) {
|
|
692
840
|
throw new Error("Invalid VM relay destination");
|
|
693
841
|
}
|
|
694
|
-
return { address, port };
|
|
842
|
+
return { address, port, ...control ? { reserve: "control" } : {} };
|
|
843
|
+
}
|
|
844
|
+
function validPort(port) {
|
|
845
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
|
695
846
|
}
|
|
696
847
|
function parsePs(output) {
|
|
697
848
|
const result = /* @__PURE__ */ new Map();
|
|
@@ -712,7 +863,7 @@ function parsePs(output) {
|
|
|
712
863
|
}
|
|
713
864
|
function listedRecords(root, runId) {
|
|
714
865
|
const records = [];
|
|
715
|
-
for (let slot = 0; slot <
|
|
866
|
+
for (let slot = 0; slot < SLOT_CEILING; slot += 1) {
|
|
716
867
|
try {
|
|
717
868
|
const record = JSON.parse(
|
|
718
869
|
readFileSync(join(slotPath(root, slot), "owner.json"), "utf8")
|
|
@@ -769,11 +920,12 @@ function runRelay(root, invocation, io) {
|
|
|
769
920
|
}
|
|
770
921
|
async function runRelayNow(root, invocation, io) {
|
|
771
922
|
const guard = readLiveGuard(root);
|
|
923
|
+
const network = readNetwork(root);
|
|
772
924
|
const publicWeb = invocation.address === "public-web" && invocation.port === PUBLIC_WEB_PORT;
|
|
773
925
|
if (publicWeb) {
|
|
774
|
-
if (
|
|
775
|
-
} else assertApprovedDestination(
|
|
776
|
-
const record = registerRelay(root, guard.runId);
|
|
926
|
+
if (network.publicWeb !== true) throw new Error("Public web access is not enabled");
|
|
927
|
+
} else assertApprovedDestination(network, invocation);
|
|
928
|
+
const record = registerRelay(root, guard.runId, network.limits, invocation.reserve);
|
|
777
929
|
const streams = io ?? { socket: new Socket(), input: process.stdin, output: process.stdout };
|
|
778
930
|
let connectedResponse = false;
|
|
779
931
|
if (publicWeb) {
|
|
@@ -804,9 +956,13 @@ async function runRelayNow(root, invocation, io) {
|
|
|
804
956
|
releaseHandshake();
|
|
805
957
|
}
|
|
806
958
|
}
|
|
807
|
-
return transportRelay(root, invocation, streams, record,
|
|
959
|
+
return transportRelay(root, invocation, streams, record, {
|
|
960
|
+
connectedResponse,
|
|
961
|
+
maxBytes: network.limits.relayMaxBytes
|
|
962
|
+
});
|
|
808
963
|
}
|
|
809
|
-
function transportRelay(root, invocation, streams, record,
|
|
964
|
+
function transportRelay(root, invocation, streams, record, options) {
|
|
965
|
+
const { connectedResponse, maxBytes } = options;
|
|
810
966
|
return new Promise((resolve) => {
|
|
811
967
|
let finished = false;
|
|
812
968
|
let sent = 0;
|
|
@@ -840,7 +996,7 @@ function transportRelay(root, invocation, streams, record, connectedResponse) {
|
|
|
840
996
|
};
|
|
841
997
|
process.once("exit", cleanup);
|
|
842
998
|
for (const signal of signals) process.once(signal, terminated);
|
|
843
|
-
const exceedsLimit = (total) => total >
|
|
999
|
+
const exceedsLimit = (total) => total > maxBytes;
|
|
844
1000
|
socket.setTimeout(RELAY_IDLE_MS, () => finish(new Error("VM relay idle timeout")));
|
|
845
1001
|
socket.once("connect", () => {
|
|
846
1002
|
if (connectTimer) clearTimeout(connectTimer);
|
|
@@ -899,25 +1055,30 @@ function handshakeExitCleanup(root, record) {
|
|
|
899
1055
|
}
|
|
900
1056
|
|
|
901
1057
|
// src/vm/memory-budget.ts
|
|
902
|
-
|
|
903
|
-
|
|
1058
|
+
var RELAY_RSS_ALLOWANCE_MIB = 128;
|
|
1059
|
+
var BASE_RELAY_SLOTS = 16;
|
|
1060
|
+
function relayAllowanceGiB(relaySlots = BASE_RELAY_SLOTS) {
|
|
1061
|
+
return Math.max(0, relaySlots - BASE_RELAY_SLOTS) * RELAY_RSS_ALLOWANCE_MIB / 1024;
|
|
1062
|
+
}
|
|
1063
|
+
function vmHostMemoryGiB(guestGiB, relaySlots = BASE_RELAY_SLOTS) {
|
|
1064
|
+
return guestGiB + Math.max(1, guestGiB / 4) + relayAllowanceGiB(relaySlots);
|
|
904
1065
|
}
|
|
905
1066
|
|
|
906
1067
|
// src/vm/resource-guard.ts
|
|
907
|
-
var
|
|
1068
|
+
var GIB2 = 1024 ** 3;
|
|
908
1069
|
var GUARD_INTERVAL_MS = 2e3;
|
|
909
|
-
function guardLimits(resources) {
|
|
1070
|
+
function guardLimits(resources, capacity = LEGACY_CAPACITY_LIMITS) {
|
|
910
1071
|
if (!Number.isInteger(resources.memoryGiB) || resources.memoryGiB < 2 || resources.memoryGiB > 1024 || !Number.isInteger(resources.diskGiB) || resources.diskGiB < 20 || resources.diskGiB > 512) {
|
|
911
1072
|
throw new Error("Invalid resource limits in host state");
|
|
912
1073
|
}
|
|
913
1074
|
return {
|
|
914
|
-
minFreeBytes: 10 *
|
|
1075
|
+
minFreeBytes: 10 * GIB2,
|
|
915
1076
|
// A 24 GiB Mac guest exceeded 27 GiB during image unpacking. Allow 25%
|
|
916
1077
|
// overhead (at least 1 GiB); admission reserves host RAM using this same cap.
|
|
917
|
-
maxRssKiB: vmHostMemoryGiB(resources.memoryGiB) * 1024 ** 2,
|
|
918
|
-
maxProcesses:
|
|
1078
|
+
maxRssKiB: vmHostMemoryGiB(resources.memoryGiB, capacity.relaySlots) * 1024 ** 2,
|
|
1079
|
+
maxProcesses: guardProcessLimit(capacity.relaySlots),
|
|
919
1080
|
maxOutputBytes: 1024 * 1024,
|
|
920
|
-
maxDiskBytes: (resources.diskGiB + 1) *
|
|
1081
|
+
maxDiskBytes: (resources.diskGiB + 1) * GIB2,
|
|
921
1082
|
maxLogBytes: 8 * 1024 ** 2
|
|
922
1083
|
};
|
|
923
1084
|
}
|
|
@@ -969,20 +1130,19 @@ function hostPs(args) {
|
|
|
969
1130
|
env: { PATH: "/usr/bin:/bin", LANG: "C" }
|
|
970
1131
|
});
|
|
971
1132
|
}
|
|
972
|
-
function sampleResources(root, qemuPid, runId) {
|
|
1133
|
+
function sampleResources(root, instanceDir, qemuPid, runId, ps = hostPs) {
|
|
973
1134
|
const fs = statfsSync(root);
|
|
974
|
-
const
|
|
975
|
-
const
|
|
976
|
-
const relays = sampleRelayProcesses(root, runId, hostPs(["-axo", "pid=,pgid=,rss=,command="]));
|
|
1135
|
+
const processes = parseProcessGroup(ps(["-axo", "pgid=,pid=,rss="]), qemuPid);
|
|
1136
|
+
const relays = sampleRelayProcesses(root, runId, ps(["-axo", "pid=,pgid=,rss=,command="]));
|
|
977
1137
|
return {
|
|
978
1138
|
freeBytes: fs.bavail * fs.bsize,
|
|
979
1139
|
rssKiB: processes.rssKiB + relays.reduce((total, relay) => total + relay.rssKiB, 0),
|
|
980
1140
|
processes: processes.processes + relays.length,
|
|
981
1141
|
relayProcesses: relays.length,
|
|
982
|
-
diskBytes: regularFileSize(join2(
|
|
1142
|
+
diskBytes: regularFileSize(join2(instanceDir, "disk")),
|
|
983
1143
|
largestLogBytes: Math.max(
|
|
984
1144
|
...["ha.stdout.log", "ha.stderr.log", "serial.log", "serialp.log", "serialv.log"].map(
|
|
985
|
-
(file) => regularFileSize(join2(
|
|
1145
|
+
(file) => regularFileSize(join2(instanceDir, file))
|
|
986
1146
|
)
|
|
987
1147
|
)
|
|
988
1148
|
};
|
|
@@ -997,7 +1157,7 @@ function writeGuardEvidence(root, evidence) {
|
|
|
997
1157
|
rmSync2(temporary, { force: true });
|
|
998
1158
|
}
|
|
999
1159
|
}
|
|
1000
|
-
function startResourceGuard(root, identity, limits, stop) {
|
|
1160
|
+
function startResourceGuard(root, identity, limits, stop, instanceDir) {
|
|
1001
1161
|
const evidence = {
|
|
1002
1162
|
schema: 1,
|
|
1003
1163
|
...identity,
|
|
@@ -1022,7 +1182,7 @@ function startResourceGuard(root, identity, limits, stop) {
|
|
|
1022
1182
|
function check() {
|
|
1023
1183
|
if (finished) return;
|
|
1024
1184
|
try {
|
|
1025
|
-
evidence.observed = sampleResources(root, identity.qemuPid, identity.runId);
|
|
1185
|
+
evidence.observed = sampleResources(root, instanceDir, identity.qemuPid, identity.runId);
|
|
1026
1186
|
const violation = resourceViolation(evidence.observed, limits);
|
|
1027
1187
|
if (violation) throw new Error(violation);
|
|
1028
1188
|
evidence.lastCheckedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1225,9 +1385,9 @@ function attachBoundedOutput(child, limit, terminate) {
|
|
|
1225
1385
|
child.stdout?.on("data", consume);
|
|
1226
1386
|
child.stderr?.on("data", consume);
|
|
1227
1387
|
}
|
|
1228
|
-
function runGuardedQemu(root, qemu, args, networkHash, resources) {
|
|
1388
|
+
function runGuardedQemu(root, qemu, args, networkHash, resources, instanceDir, capacity) {
|
|
1229
1389
|
const runId = startIdentity(root);
|
|
1230
|
-
const limits = guardLimits(resources);
|
|
1390
|
+
const limits = guardLimits(resources, parseCapacityLimits(capacity));
|
|
1231
1391
|
const hardenedArgs = [...args, "-no-user-config", "-nodefaults"];
|
|
1232
1392
|
const child = spawn2(qemu, hardenedArgs, {
|
|
1233
1393
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -1285,7 +1445,7 @@ function runGuardedQemu(root, qemu, args, networkHash, resources) {
|
|
|
1285
1445
|
JSON.stringify({ ...identity, args: hardenedArgs, startedAt: (/* @__PURE__ */ new Date()).toISOString() }),
|
|
1286
1446
|
{ mode: 384 }
|
|
1287
1447
|
);
|
|
1288
|
-
guard = startResourceGuard(root, identity, limits, terminate);
|
|
1448
|
+
guard = startResourceGuard(root, identity, limits, terminate, instanceDir);
|
|
1289
1449
|
} catch {
|
|
1290
1450
|
terminate("failed to record or start host resource supervision");
|
|
1291
1451
|
}
|
|
@@ -1316,6 +1476,8 @@ function main() {
|
|
|
1316
1476
|
if (state.schema !== 1 || !state.qemu.startsWith("/") || state.qemu.includes("wrapper")) {
|
|
1317
1477
|
throw new Error("Invalid QEMU host state");
|
|
1318
1478
|
}
|
|
1479
|
+
const vmName = vmNameFor(stateInstance(state.instance));
|
|
1480
|
+
const instanceDir = join5(root, "lima", vmName);
|
|
1319
1481
|
const networkText = readFileSync4(join5(root, "network.json"), "utf8");
|
|
1320
1482
|
const network = JSON.parse(networkText);
|
|
1321
1483
|
const args = process.argv.slice(2);
|
|
@@ -1324,7 +1486,8 @@ function main() {
|
|
|
1324
1486
|
original?.match(/hostfwd=tcp:127\.0\.0\.1:(\d+)-(?:192\.168\.5\.15)?:22/)?.[1] ?? 0
|
|
1325
1487
|
);
|
|
1326
1488
|
const rewritten = rewriteQemuArgs(args, { ...network, sshPort }, join5(root, "qemu-relay"), {
|
|
1327
|
-
instanceDir
|
|
1489
|
+
instanceDir,
|
|
1490
|
+
vmName
|
|
1328
1491
|
});
|
|
1329
1492
|
if (original) {
|
|
1330
1493
|
const expected = process.platform === "darwin" ? "hvf" : "kvm";
|
|
@@ -1336,7 +1499,9 @@ function main() {
|
|
|
1336
1499
|
state.qemu,
|
|
1337
1500
|
rewritten,
|
|
1338
1501
|
createHash("sha256").update(networkText).digest("hex"),
|
|
1339
|
-
state.resources
|
|
1502
|
+
state.resources,
|
|
1503
|
+
instanceDir,
|
|
1504
|
+
network.limits
|
|
1340
1505
|
);
|
|
1341
1506
|
return;
|
|
1342
1507
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rallycry/conveyor-k3",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Managed personal compute for Conveyor. Installs and maintains a k3s cluster that runs your own card builds, kept converged on Conveyor's desired state.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflared",
|