@tikoci/quickchr 0.4.6 → 0.4.8
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/DESIGN.md +604 -3
- package/MANUAL.md +222 -19
- package/README.md +29 -2
- package/package.json +4 -2
- package/src/cli/flags.ts +194 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +258 -34
- package/src/cli/tips.ts +57 -0
- package/src/index.ts +11 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/channels.ts +23 -2
- package/src/lib/completions.ts +17 -3
- package/src/lib/console.ts +206 -75
- package/src/lib/diagnostics.ts +740 -0
- package/src/lib/download.ts +433 -0
- package/src/lib/guest-snapshot.ts +474 -0
- package/src/lib/images.ts +56 -61
- package/src/lib/names.ts +72 -0
- package/src/lib/net.ts +59 -3
- package/src/lib/network.ts +191 -19
- package/src/lib/packages.ts +85 -17
- package/src/lib/provision.ts +126 -5
- package/src/lib/qemu.ts +62 -16
- package/src/lib/quickchr.ts +504 -64
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +147 -6
- package/src/lib/types.ts +44 -6
- package/src/lib/versions.ts +99 -22
package/src/lib/names.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource-name validation — machine names and named-socket names.
|
|
3
|
+
*
|
|
4
|
+
* Both become a path segment under the data dir (`machines/<name>/`,
|
|
5
|
+
* `networks/<name>.json`), so a name is not free text: it has to be safe to join
|
|
6
|
+
* onto a path and safe to type back into a command. A name starting with `-`
|
|
7
|
+
* also reads as a flag everywhere it is echoed (`quickchr remove --help`), which
|
|
8
|
+
* is how `networks sockets create --help` persisted a socket called `--help` (#156).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { QuickCHRError } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
/** Letters, digits, dot, underscore, hyphen — the charset already produced by
|
|
14
|
+
* `generateMachineName()` (e.g. `7.24.4-x86-1`), minus anything a shell or a
|
|
15
|
+
* path would reinterpret. */
|
|
16
|
+
const VALID_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
17
|
+
|
|
18
|
+
const MAX_NAME_LENGTH = 64;
|
|
19
|
+
|
|
20
|
+
/** Throw `INVALID_NAME` unless `name` is safe as both a path segment and a CLI argument.
|
|
21
|
+
* `kind` names the resource in the message ("machine", "named socket"). */
|
|
22
|
+
export function assertValidResourceName(name: string, kind: string): void {
|
|
23
|
+
const reject = (why: string) => {
|
|
24
|
+
throw new QuickCHRError("INVALID_NAME", `Invalid ${kind} name "${name}" — ${why}`);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
if (name.length === 0) reject("names cannot be empty");
|
|
28
|
+
if (name.startsWith("-")) reject(`names cannot start with "-" (it would be read as a flag)`);
|
|
29
|
+
if (name.length > MAX_NAME_LENGTH) reject(`names are limited to ${MAX_NAME_LENGTH} characters`);
|
|
30
|
+
if (!VALID_NAME.test(name)) {
|
|
31
|
+
reject("names may contain only letters, digits, dot, underscore and hyphen, and must start with a letter or digit");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The weaker rule that applies to a name being *used*, not created.
|
|
36
|
+
*
|
|
37
|
+
* `assertValidResourceName()` cannot guard a lookup or a delete: machines created
|
|
38
|
+
* under the older, looser rules have to stay addressable. But a name still becomes a
|
|
39
|
+
* path segment, and `join(machinesDir, name)` happily normalizes `..` into the data
|
|
40
|
+
* directory itself — so `removeOrphan("..")` would `rmSync` every machine, the cache
|
|
41
|
+
* and the socket registry. This is the minimum that stops that while passing every
|
|
42
|
+
* name a real machine can have. */
|
|
43
|
+
export function isPathSafeName(name: string): boolean {
|
|
44
|
+
if (name.length === 0) return false;
|
|
45
|
+
if (name === "." || name === "..") return false;
|
|
46
|
+
if (name.includes("/") || name.includes("\0")) return false;
|
|
47
|
+
// `\` is a path separator on Windows and an ordinary filename character on POSIX.
|
|
48
|
+
// Rejecting it everywhere would strand a machine named `lab\old` under the older,
|
|
49
|
+
// looser creation rules: `list` still shows it, so `remove` has to still clear it.
|
|
50
|
+
if (process.platform === "win32" && name.includes("\\")) return false;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Throwing form of {@link isPathSafeName}, for the paths that delete. */
|
|
55
|
+
export function assertPathSafeName(name: string, kind: string): void {
|
|
56
|
+
if (!isPathSafeName(name)) {
|
|
57
|
+
throw new QuickCHRError(
|
|
58
|
+
"INVALID_NAME",
|
|
59
|
+
`Invalid ${kind} name ${JSON.stringify(name)} — a name cannot be empty, "." or "..", or contain a path separator`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Non-throwing form, for callers that want to branch rather than fail. */
|
|
65
|
+
export function isValidResourceName(name: string): boolean {
|
|
66
|
+
try {
|
|
67
|
+
assertValidResourceName(name, "resource");
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/lib/net.ts
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
* error), we retry by resolving the A record against a public DNS server
|
|
20
20
|
* directly (~10 ms), bypassing the host's resolv.conf, and connecting to the
|
|
21
21
|
* IPv4 literal with the `Host` header and TLS SNI preserved so certificate
|
|
22
|
-
* validation still passes.
|
|
22
|
+
* validation still passes. If that attempt fails too, both failures are reported
|
|
23
|
+
* together as a `ResilientFetchError` — the fallback's own error names only an IP
|
|
24
|
+
* literal, which on its own reads as a host nobody configured.
|
|
23
25
|
*
|
|
24
26
|
* Only connection-class failures trigger the fallback; HTTP responses (incl.
|
|
25
27
|
* 5xx) and aborts (`AbortError`, e.g. from `AbortSignal.timeout`) pass through
|
|
@@ -32,6 +34,49 @@ import { promises as dns } from "node:dns";
|
|
|
32
34
|
const PUBLIC_DNS_SERVERS = ["1.1.1.1", "8.8.8.8", "1.0.0.1"];
|
|
33
35
|
const DNS_TIMEOUT_MS = 3000;
|
|
34
36
|
|
|
37
|
+
/** `CODE: message` when the error carries a code, else just the message. */
|
|
38
|
+
function describeError(err: unknown): string {
|
|
39
|
+
if (!err || typeof err !== "object") return String(err);
|
|
40
|
+
const e = err as { name?: string; message?: string; code?: string; cause?: { code?: string } };
|
|
41
|
+
const code = e.code ?? e.cause?.code;
|
|
42
|
+
const label = code ?? e.name;
|
|
43
|
+
const message = e.message ?? String(err);
|
|
44
|
+
return label ? `${label}: ${message}` : message;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Both transports failed: the direct fetch with a connection-class error, and
|
|
49
|
+
* then the public-DNS/IPv4 fallback too.
|
|
50
|
+
*
|
|
51
|
+
* It exists because the fallback's own error is unreadable on its own — it names
|
|
52
|
+
* an IP literal nobody configured and no hostname, so a log cannot distinguish
|
|
53
|
+
* "the system resolver was broken" from "the host refused us" from "public DNS
|
|
54
|
+
* handed back a bad address" (#121). The message carries the URL, both failures,
|
|
55
|
+
* and which address the fallback tried; the direct failure is also the `cause`.
|
|
56
|
+
*/
|
|
57
|
+
export class ResilientFetchError extends Error {
|
|
58
|
+
/** The hostname both transports were aiming at. */
|
|
59
|
+
readonly host: string;
|
|
60
|
+
/** The IPv4 literal the fallback connected to, from public DNS. */
|
|
61
|
+
readonly address: string;
|
|
62
|
+
/** The fallback transport's failure. The direct attempt's is `cause`. */
|
|
63
|
+
readonly fallbackError: unknown;
|
|
64
|
+
|
|
65
|
+
constructor(url: string, address: string, directError: unknown, fallbackError: unknown) {
|
|
66
|
+
const host = new URL(url).hostname;
|
|
67
|
+
super(
|
|
68
|
+
`Fetch failed for ${url} on both transports: ` +
|
|
69
|
+
`system resolver — ${describeError(directError)}; ` +
|
|
70
|
+
`public DNS (${PUBLIC_DNS_SERVERS.join(", ")}) → ${address} — ${describeError(fallbackError)}`,
|
|
71
|
+
{ cause: directError },
|
|
72
|
+
);
|
|
73
|
+
this.name = "ResilientFetchError";
|
|
74
|
+
this.host = host;
|
|
75
|
+
this.address = address;
|
|
76
|
+
this.fallbackError = fallbackError;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
35
80
|
/**
|
|
36
81
|
* True for the connection-class failures raised when a socket cannot be opened
|
|
37
82
|
* (e.g. IPv4 blocked on an IPv6-only network, or Bun's `errno: 0`
|
|
@@ -39,9 +84,14 @@ const DNS_TIMEOUT_MS = 3000;
|
|
|
39
84
|
* the standard connect errors. Excludes `AbortError` (aborts/`AbortSignal`
|
|
40
85
|
* timeouts) and HTTP-level outcomes (those carry a Response and never throw
|
|
41
86
|
* here). Used to decide whether to fall back from the normal fetch to the
|
|
42
|
-
* public-DNS IPv4 attempt
|
|
87
|
+
* public-DNS IPv4 attempt, and by callers deciding whether a failure is worth
|
|
88
|
+
* another attempt.
|
|
43
89
|
*/
|
|
44
90
|
export function isConnectionFailure(err: unknown): boolean {
|
|
91
|
+
// Connection-class by construction: fetchResilient raises this only after a
|
|
92
|
+
// direct failure that already passed this test. Callers retrying on a
|
|
93
|
+
// connection failure must keep retrying once the fallback also fails.
|
|
94
|
+
if (err instanceof ResilientFetchError) return true;
|
|
45
95
|
if (!err || typeof err !== "object") return false;
|
|
46
96
|
const e = err as { name?: string; code?: string; errno?: number; cause?: { code?: string } };
|
|
47
97
|
if (e.name === "AbortError") return false;
|
|
@@ -142,6 +192,12 @@ export async function fetchResilient(url: string, init?: BunFetchRequestInit): P
|
|
|
142
192
|
const address = await resolveIpv4(new URL(url).hostname);
|
|
143
193
|
// Public DNS can't help (unreachable / no answer) — surface the original failure.
|
|
144
194
|
if (address === undefined) throw err;
|
|
145
|
-
|
|
195
|
+
try {
|
|
196
|
+
return await fetchOverIpv4(url, address, init);
|
|
197
|
+
} catch (fallbackErr) {
|
|
198
|
+
// Never let the fallback's error stand alone: it names only an IP literal,
|
|
199
|
+
// which is the one thing the reader cannot map back to what was attempted.
|
|
200
|
+
throw new ResilientFetchError(url, address, err, fallbackErr);
|
|
201
|
+
}
|
|
146
202
|
}
|
|
147
203
|
}
|
package/src/lib/network.ts
CHANGED
|
@@ -20,7 +20,13 @@ import {
|
|
|
20
20
|
SERVICE_PORTS,
|
|
21
21
|
QuickCHRError,
|
|
22
22
|
} from "./types.ts";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
getNamedSocket,
|
|
25
|
+
getSocketSlot,
|
|
26
|
+
socketEndpointPath,
|
|
27
|
+
type SocketEntry,
|
|
28
|
+
} from "./socket-registry.ts";
|
|
29
|
+
import { assertValidResourceName } from "./names.ts";
|
|
24
30
|
import { resolveInterfaceAlias, isSocketVmnetDaemonRunning } from "./platform.ts";
|
|
25
31
|
|
|
26
32
|
/** Allocate a port block for a new instance, avoiding conflicts with existing machines. */
|
|
@@ -329,6 +335,9 @@ function parseSocketSpecifier(input: string): NetworkSpecifier {
|
|
|
329
335
|
"socket:: requires a link name (e.g. socket::mylink)",
|
|
330
336
|
);
|
|
331
337
|
}
|
|
338
|
+
// start() auto-creates a registry entry (and a networks/<name>.json file) for an
|
|
339
|
+
// unknown name, so the specifier is the other door into createNamedSocket().
|
|
340
|
+
assertValidResourceName(name, "named socket");
|
|
332
341
|
return { type: "socket", name };
|
|
333
342
|
}
|
|
334
343
|
|
|
@@ -388,6 +397,80 @@ export function resolveStartNetworks(
|
|
|
388
397
|
return [{ specifier: "user", id: "net0" }];
|
|
389
398
|
}
|
|
390
399
|
|
|
400
|
+
/** Derive a stable MAC for one NIC of one machine.
|
|
401
|
+
*
|
|
402
|
+
* QEMU's default is `52:54:00:12:34:56` incremented per NIC **within** a guest, so
|
|
403
|
+
* NIC index *N* holds the same address in *every* guest. Harmless while machines
|
|
404
|
+
* only have `user` NICs (separate SLIRP namespaces), fatal the moment two share an
|
|
405
|
+
* L2 segment — a named socket, a listen/connect pair, a TAP, or a RouterOS bridge
|
|
406
|
+
* on a hub VM. Forwarding then breaks in ways that look like a RouterOS or overlay
|
|
407
|
+
* defect rather than a launcher one (#154).
|
|
408
|
+
*
|
|
409
|
+
* The first octet is `0x02`: locally administered (bit 1 set) and unicast (bit 0
|
|
410
|
+
* clear), so these never collide with a vendor-assigned address. The remaining five
|
|
411
|
+
* octets come from a SHA-256 of the machine name, NIC index and `salt`, which makes
|
|
412
|
+
* the address reproducible for a given name instead of random per boot — the same
|
|
413
|
+
* lab rebuilt under the same names presents the same addresses.
|
|
414
|
+
*/
|
|
415
|
+
export function deriveMac(name: string, index: number, salt = 0): string {
|
|
416
|
+
const digest = new Bun.CryptoHasher("sha256")
|
|
417
|
+
.update(`${name}\u0000${index}\u0000${salt}`)
|
|
418
|
+
.digest();
|
|
419
|
+
// Five octets after the fixed `02`, so a NIC's address is 48 bits total.
|
|
420
|
+
const octets = Array.from(digest.subarray(0, 5), (o) =>
|
|
421
|
+
o.toString(16).padStart(2, "0"),
|
|
422
|
+
);
|
|
423
|
+
return ["02", ...octets].join(":");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Assign a MAC to every NIC that lacks one, avoiding `taken`.
|
|
427
|
+
*
|
|
428
|
+
* Mutates and returns `networks`. Idempotent: a NIC that already has a MAC keeps it,
|
|
429
|
+
* so a machine's addresses are fixed at creation and survive restart, a re-resolve on
|
|
430
|
+
* a different platform, and any later change to {@link deriveMac} itself.
|
|
431
|
+
*
|
|
432
|
+
* **Call this only where a machine is created.** Changing a MAC on a machine that has
|
|
433
|
+
* already booted breaks it: RouterOS ties its persisted interface identity to the
|
|
434
|
+
* address, so a new one orphans the `ether1` holding the DHCP client and the guest
|
|
435
|
+
* comes up with no IP. Verified by A/B on a live CHR — see DESIGN.md, "NIC MAC
|
|
436
|
+
* Addresses". Machines created before #154 keep QEMU's defaults and must be recreated.
|
|
437
|
+
*
|
|
438
|
+
* A SHA-256 collision across five octets is vanishingly unlikely, but `taken` makes
|
|
439
|
+
* the guarantee structural rather than probabilistic — a duplicate here is the exact
|
|
440
|
+
* silent-L2 failure this exists to prevent, so it is worth not leaving to odds.
|
|
441
|
+
*
|
|
442
|
+
* `taken` is a snapshot, so two concurrent creations can each miss the other. That
|
|
443
|
+
* only changes an outcome when the two would otherwise have collided — i.e. on top
|
|
444
|
+
* of that 2^-40 event — but the window is real. It is the same read-then-write
|
|
445
|
+
* window the port allocator has on the adjacent line (`getUsedPortBases()` →
|
|
446
|
+
* `findAvailablePortBlock()` → `saveMachine()`), where it fires routinely and
|
|
447
|
+
* breaks starts outright: #140. Both allocators want one lock around
|
|
448
|
+
* read-allocate-persist; adding a second, MAC-only lock here would leave the
|
|
449
|
+
* frequent half of the race unfixed while implying it was handled.
|
|
450
|
+
*/
|
|
451
|
+
export function assignMacs(
|
|
452
|
+
name: string,
|
|
453
|
+
networks: NetworkConfig[],
|
|
454
|
+
taken: ReadonlySet<string> = new Set(),
|
|
455
|
+
): NetworkConfig[] {
|
|
456
|
+
const used = new Set(taken);
|
|
457
|
+
for (const n of networks) {
|
|
458
|
+
if (n.mac) used.add(n.mac);
|
|
459
|
+
}
|
|
460
|
+
networks.forEach((n, i) => {
|
|
461
|
+
if (n.mac) return;
|
|
462
|
+
let salt = 0;
|
|
463
|
+
let mac = deriveMac(name, i, salt);
|
|
464
|
+
while (used.has(mac)) {
|
|
465
|
+
salt++;
|
|
466
|
+
mac = deriveMac(name, i, salt);
|
|
467
|
+
}
|
|
468
|
+
used.add(mac);
|
|
469
|
+
n.mac = mac;
|
|
470
|
+
});
|
|
471
|
+
return networks;
|
|
472
|
+
}
|
|
473
|
+
|
|
391
474
|
/** Returns true when at least one network uses QEMU user-mode (hostfwd) networking.
|
|
392
475
|
* Only user-mode networks forward localhost ports — shared/bridged/tap/socket networks
|
|
393
476
|
* assign a DHCP address that is not reachable from host localhost. */
|
|
@@ -400,10 +483,26 @@ export function hasUserModeNetwork(networks: NetworkConfig[]): boolean {
|
|
|
400
483
|
export interface ResolutionContext {
|
|
401
484
|
platform: PlatformInfo;
|
|
402
485
|
socketVmnet?: SocketVmnetInfo;
|
|
486
|
+
/** Machine being resolved. A named socket's endpoint role is per-machine, so the
|
|
487
|
+
* resolver needs to know who is asking; absent for callers that only want the
|
|
488
|
+
* shape of a specifier (dry runs, unit tests over the non-socket modes). */
|
|
489
|
+
machine?: string;
|
|
490
|
+
/** QEMU version string, when the caller knows it. `-netdev dgram` landed in 7.2;
|
|
491
|
+
* an unknown version does not block, because refusing to run on a QEMU we merely
|
|
492
|
+
* failed to parse is worse than the error the spawn would give. */
|
|
493
|
+
qemuVersion?: string;
|
|
403
494
|
}
|
|
404
495
|
|
|
405
|
-
|
|
406
|
-
|
|
496
|
+
/** `-device` for one NIC — the **only** place a NIC device is emitted.
|
|
497
|
+
*
|
|
498
|
+
* Takes the whole {@link NetworkConfig} rather than an id so a caller physically
|
|
499
|
+
* cannot emit a NIC without its MAC. `qemu.ts`'s fallback path builds its own
|
|
500
|
+
* `-netdev` strings but shares this, so both paths carry the address; every guest
|
|
501
|
+
* would otherwise get QEMU's default `52:54:00:12:34:56` sequence and two machines
|
|
502
|
+
* bridged onto one L2 segment would collide (#154). */
|
|
503
|
+
export function deviceArgs(config: NetworkConfig): string[] {
|
|
504
|
+
const mac = config.mac ? `,mac=${config.mac}` : "";
|
|
505
|
+
return ["-device", `virtio-net-pci,netdev=${config.id}${mac}`];
|
|
407
506
|
}
|
|
408
507
|
|
|
409
508
|
function resolveUser(
|
|
@@ -416,7 +515,7 @@ function resolveUser(
|
|
|
416
515
|
return {
|
|
417
516
|
qemuNetdevArgs: [
|
|
418
517
|
"-netdev", netdevValue,
|
|
419
|
-
...deviceArgs(config
|
|
518
|
+
...deviceArgs(config),
|
|
420
519
|
],
|
|
421
520
|
};
|
|
422
521
|
}
|
|
@@ -438,7 +537,7 @@ function resolveShared(
|
|
|
438
537
|
return {
|
|
439
538
|
qemuNetdevArgs: [
|
|
440
539
|
"-netdev", `socket,id=${config.id},fd=3`,
|
|
441
|
-
...deviceArgs(config
|
|
540
|
+
...deviceArgs(config),
|
|
442
541
|
],
|
|
443
542
|
wrapper: [svn.client, svn.sharedSocket],
|
|
444
543
|
};
|
|
@@ -446,7 +545,7 @@ function resolveShared(
|
|
|
446
545
|
return {
|
|
447
546
|
qemuNetdevArgs: [
|
|
448
547
|
"-netdev", `vmnet-shared,id=${config.id}`,
|
|
449
|
-
...deviceArgs(config
|
|
548
|
+
...deviceArgs(config),
|
|
450
549
|
],
|
|
451
550
|
downgraded: {
|
|
452
551
|
from: "shared (socket_vmnet)",
|
|
@@ -473,7 +572,7 @@ function resolveVmnetShared(
|
|
|
473
572
|
return {
|
|
474
573
|
qemuNetdevArgs: [
|
|
475
574
|
"-netdev", `vmnet-shared,id=${config.id}`,
|
|
476
|
-
...deviceArgs(config
|
|
575
|
+
...deviceArgs(config),
|
|
477
576
|
],
|
|
478
577
|
};
|
|
479
578
|
}
|
|
@@ -498,7 +597,7 @@ function resolveBridged(
|
|
|
498
597
|
return {
|
|
499
598
|
qemuNetdevArgs: [
|
|
500
599
|
"-netdev", `socket,id=${config.id},fd=3`,
|
|
501
|
-
...deviceArgs(config
|
|
600
|
+
...deviceArgs(config),
|
|
502
601
|
],
|
|
503
602
|
wrapper: [svn.client, bridgeSocket],
|
|
504
603
|
};
|
|
@@ -506,7 +605,7 @@ function resolveBridged(
|
|
|
506
605
|
return {
|
|
507
606
|
qemuNetdevArgs: [
|
|
508
607
|
"-netdev", `vmnet-bridged,id=${config.id},ifname=${resolved}`,
|
|
509
|
-
...deviceArgs(config
|
|
608
|
+
...deviceArgs(config),
|
|
510
609
|
],
|
|
511
610
|
downgraded: {
|
|
512
611
|
from: `bridged:${iface} (socket_vmnet)`,
|
|
@@ -534,14 +633,45 @@ function resolveVmnetBridged(
|
|
|
534
633
|
return {
|
|
535
634
|
qemuNetdevArgs: [
|
|
536
635
|
"-netdev", `vmnet-bridged,id=${config.id},ifname=${iface}`,
|
|
537
|
-
...deviceArgs(config
|
|
636
|
+
...deviceArgs(config),
|
|
538
637
|
],
|
|
539
638
|
};
|
|
540
639
|
}
|
|
541
640
|
|
|
641
|
+
/** One-line description of the transport a named socket resolved to, for the line
|
|
642
|
+
* `start` prints and for `list`/`info`. The acceptance bar for #158's visibility
|
|
643
|
+
* work is that nothing about a named socket requires opening a file under the data
|
|
644
|
+
* dir, and this is what makes that true. */
|
|
645
|
+
export function describeSocketTransport(entry: SocketEntry, slot?: number): string {
|
|
646
|
+
switch (entry.mode) {
|
|
647
|
+
case "mcast":
|
|
648
|
+
return `UDP multicast ${entry.mcastGroup}:${entry.port} (N-way)`;
|
|
649
|
+
case "listen-connect":
|
|
650
|
+
if (slot === undefined) return `TCP pair on 127.0.0.1:${entry.port}`;
|
|
651
|
+
return slot === 0
|
|
652
|
+
? `TCP 127.0.0.1:${entry.port} (listening)`
|
|
653
|
+
: `TCP 127.0.0.1:${entry.port} (connecting)`;
|
|
654
|
+
case "dgram": {
|
|
655
|
+
// Unoccupied: name the pair once rather than printing the directory twice.
|
|
656
|
+
if (slot === undefined) {
|
|
657
|
+
return `unix datagram ${socketEndpointPath(entry.name, 0).replace(/\.0\.sock$/, ".{0,1}.sock")}`;
|
|
658
|
+
}
|
|
659
|
+
const peer = slot === 0 ? 1 : 0;
|
|
660
|
+
return `unix datagram ${socketEndpointPath(entry.name, slot)} -> ${socketEndpointPath(entry.name, peer)}`;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** `-netdev dgram` was added in QEMU 7.2. */
|
|
666
|
+
export function qemuSupportsDgram(version: string): boolean {
|
|
667
|
+
const [major = 0, minor = 0] = version.split(".").map((p) => Number.parseInt(p, 10) || 0);
|
|
668
|
+
return major > 7 || (major === 7 && minor >= 2);
|
|
669
|
+
}
|
|
670
|
+
|
|
542
671
|
function resolveSocketNamed(
|
|
543
672
|
config: NetworkConfig,
|
|
544
673
|
name: string,
|
|
674
|
+
ctx: ResolutionContext,
|
|
545
675
|
): ResolvedNetwork {
|
|
546
676
|
const entry = getNamedSocket(name);
|
|
547
677
|
if (!entry) {
|
|
@@ -554,18 +684,60 @@ function resolveSocketNamed(
|
|
|
554
684
|
return {
|
|
555
685
|
qemuNetdevArgs: [
|
|
556
686
|
"-netdev", `socket,id=${config.id},mcast=${entry.mcastGroup}:${entry.port}`,
|
|
557
|
-
...deviceArgs(config
|
|
687
|
+
...deviceArgs(config),
|
|
688
|
+
],
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Two-member modes carry a role. It comes from the persisted slot, claimed when the
|
|
693
|
+
// machine joined, so a listener that stops and restarts comes back as the listener
|
|
694
|
+
// instead of silently demoting itself to a second connector.
|
|
695
|
+
const slot = ctx.machine ? getSocketSlot(entry, ctx.machine) : undefined;
|
|
696
|
+
if (slot === undefined) {
|
|
697
|
+
const held = entry.endpoints?.filter((m): m is string => m !== null) ?? [];
|
|
698
|
+
const who = held.length > 0 ? held.join(" and ") : "no machine";
|
|
699
|
+
throw new QuickCHRError(
|
|
700
|
+
"NETWORK_UNAVAILABLE",
|
|
701
|
+
ctx.machine
|
|
702
|
+
? `Machine "${ctx.machine}" holds no endpoint on named socket "${name}" — it is a ${entry.mode} link, and its two ends are held by ${who}.`
|
|
703
|
+
: `Named socket "${name}" is a ${entry.mode} link, so resolving it needs the machine's name (its endpoint role is per-machine). Pass 'machine' in the resolution context.`,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (entry.mode === "dgram") {
|
|
708
|
+
// Windows first: it cannot have this transport at any QEMU version.
|
|
709
|
+
if (ctx.platform.os === "win32") {
|
|
710
|
+
throw new QuickCHRError(
|
|
711
|
+
"NETWORK_UNAVAILABLE",
|
|
712
|
+
`Named socket "${name}" is a unix-datagram link, which Windows cannot provide (AF_UNIX has no SOCK_DGRAM). ` +
|
|
713
|
+
`Recreate it with 'quickchr networks sockets create ${name} --mode listen-connect'.`,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
if (ctx.qemuVersion && !qemuSupportsDgram(ctx.qemuVersion)) {
|
|
717
|
+
throw new QuickCHRError(
|
|
718
|
+
"NETWORK_UNAVAILABLE",
|
|
719
|
+
`Named socket "${name}" is a unix-datagram link, which needs QEMU 7.2 or newer (found ${ctx.qemuVersion}). ` +
|
|
720
|
+
`Upgrade QEMU, or recreate the socket with 'quickchr networks sockets create ${name} --mode listen-connect'.`,
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
const local = socketEndpointPath(name, slot);
|
|
724
|
+
const remote = socketEndpointPath(name, slot === 0 ? 1 : 0);
|
|
725
|
+
return {
|
|
726
|
+
qemuNetdevArgs: [
|
|
727
|
+
"-netdev",
|
|
728
|
+
`dgram,id=${config.id},local.type=unix,local.path=${local},remote.type=unix,remote.path=${remote}`,
|
|
729
|
+
...deviceArgs(config),
|
|
558
730
|
],
|
|
559
731
|
};
|
|
560
732
|
}
|
|
561
|
-
|
|
562
|
-
const netdevArg =
|
|
733
|
+
|
|
734
|
+
const netdevArg = slot === 0
|
|
563
735
|
? `socket,id=${config.id},listen=:${entry.port}`
|
|
564
736
|
: `socket,id=${config.id},connect=127.0.0.1:${entry.port}`;
|
|
565
737
|
return {
|
|
566
738
|
qemuNetdevArgs: [
|
|
567
739
|
"-netdev", netdevArg,
|
|
568
|
-
...deviceArgs(config
|
|
740
|
+
...deviceArgs(config),
|
|
569
741
|
],
|
|
570
742
|
};
|
|
571
743
|
}
|
|
@@ -577,7 +749,7 @@ function resolveSocketListen(
|
|
|
577
749
|
return {
|
|
578
750
|
qemuNetdevArgs: [
|
|
579
751
|
"-netdev", `socket,id=${config.id},listen=:${port}`,
|
|
580
|
-
...deviceArgs(config
|
|
752
|
+
...deviceArgs(config),
|
|
581
753
|
],
|
|
582
754
|
};
|
|
583
755
|
}
|
|
@@ -589,7 +761,7 @@ function resolveSocketConnect(
|
|
|
589
761
|
return {
|
|
590
762
|
qemuNetdevArgs: [
|
|
591
763
|
"-netdev", `socket,id=${config.id},connect=127.0.0.1:${port}`,
|
|
592
|
-
...deviceArgs(config
|
|
764
|
+
...deviceArgs(config),
|
|
593
765
|
],
|
|
594
766
|
};
|
|
595
767
|
}
|
|
@@ -602,7 +774,7 @@ function resolveSocketMcast(
|
|
|
602
774
|
return {
|
|
603
775
|
qemuNetdevArgs: [
|
|
604
776
|
"-netdev", `socket,id=${config.id},mcast=${group}:${port}`,
|
|
605
|
-
...deviceArgs(config
|
|
777
|
+
...deviceArgs(config),
|
|
606
778
|
],
|
|
607
779
|
};
|
|
608
780
|
}
|
|
@@ -621,7 +793,7 @@ function resolveTap(
|
|
|
621
793
|
return {
|
|
622
794
|
qemuNetdevArgs: [
|
|
623
795
|
"-netdev", `tap,id=${config.id},ifname=${ifname},script=no,downscript=no`,
|
|
624
|
-
...deviceArgs(config
|
|
796
|
+
...deviceArgs(config),
|
|
625
797
|
],
|
|
626
798
|
};
|
|
627
799
|
}
|
|
@@ -650,7 +822,7 @@ export function resolveNetworkConfig(
|
|
|
650
822
|
resolved = resolveVmnetBridged(config, spec.iface, ctx);
|
|
651
823
|
break;
|
|
652
824
|
case "socket":
|
|
653
|
-
resolved = resolveSocketNamed(config, spec.name);
|
|
825
|
+
resolved = resolveSocketNamed(config, spec.name, ctx);
|
|
654
826
|
break;
|
|
655
827
|
case "socket-listen":
|
|
656
828
|
resolved = resolveSocketListen(config, spec.port);
|
package/src/lib/packages.ts
CHANGED
|
@@ -2,18 +2,62 @@
|
|
|
2
2
|
* Extra package download and installation for CHR instances.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
|
-
import type
|
|
8
|
-
import { QuickCHRError } from "./types.ts";
|
|
7
|
+
import { QuickCHRError, type Arch } from "./types.ts";
|
|
9
8
|
import { packagesDownloadUrl } from "./versions.ts";
|
|
10
|
-
import {
|
|
9
|
+
import { downloadToFile } from "./download.ts";
|
|
11
10
|
import { getCacheDir, ensureDir } from "./state.ts";
|
|
12
11
|
import { createLogger, type ProgressLogger } from "./log.ts";
|
|
13
12
|
import { restPost } from "./rest.ts";
|
|
14
13
|
import { scpPush } from "./scp.ts";
|
|
15
14
|
import { extractZip } from "./zip.ts";
|
|
16
15
|
|
|
16
|
+
const PACKAGE_CACHE_MANIFEST = ".quickchr-complete.json";
|
|
17
|
+
|
|
18
|
+
interface PackageCacheManifest {
|
|
19
|
+
version: string;
|
|
20
|
+
arch: Arch;
|
|
21
|
+
archiveSize: number;
|
|
22
|
+
packages: Array<{ name: string; size: number }>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function packageFiles(extractDir: string): Array<{ name: string; size: number }> {
|
|
26
|
+
try {
|
|
27
|
+
return readdirSync(extractDir)
|
|
28
|
+
.filter((name) => name.endsWith(".npk"))
|
|
29
|
+
.map((name) => ({ name, size: statSync(join(extractDir, name)).size }))
|
|
30
|
+
.filter((entry) => entry.size > 0)
|
|
31
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Verify the archive and every package recorded after atomic extraction. */
|
|
38
|
+
export async function isCompletePackageCache(
|
|
39
|
+
version: string,
|
|
40
|
+
arch: Arch,
|
|
41
|
+
cacheDir?: string,
|
|
42
|
+
): Promise<boolean> {
|
|
43
|
+
const cache = cacheDir ?? getCacheDir();
|
|
44
|
+
const zipPath = join(cache, `all_packages-${arch}-${version}.zip`);
|
|
45
|
+
const extractDir = join(cache, `packages-${arch}-${version}`);
|
|
46
|
+
try {
|
|
47
|
+
const archiveSize = statSync(zipPath).size;
|
|
48
|
+
if (archiveSize <= 0) return false;
|
|
49
|
+
const manifest = await Bun.file(join(extractDir, PACKAGE_CACHE_MANIFEST)).json() as PackageCacheManifest;
|
|
50
|
+
const packages = packageFiles(extractDir);
|
|
51
|
+
return manifest.version === version &&
|
|
52
|
+
manifest.arch === arch &&
|
|
53
|
+
manifest.archiveSize === archiveSize &&
|
|
54
|
+
packages.length > 0 &&
|
|
55
|
+
JSON.stringify(manifest.packages) === JSON.stringify(packages);
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
17
61
|
/** Download and extract the all-packages ZIP for a version/arch. Returns the extract dir. */
|
|
18
62
|
export async function downloadPackages(
|
|
19
63
|
version: string,
|
|
@@ -29,30 +73,54 @@ export async function downloadPackages(
|
|
|
29
73
|
const zipPath = join(cache, zipName);
|
|
30
74
|
const extractDir = join(cache, `packages-${arch}-${version}`);
|
|
31
75
|
|
|
32
|
-
if (
|
|
76
|
+
if (await isCompletePackageCache(version, arch, cache)) {
|
|
33
77
|
const log = logger ?? createLogger();
|
|
34
78
|
log.status(` Using cached packages: ${version} (${arch})`);
|
|
35
79
|
return extractDir;
|
|
36
80
|
}
|
|
37
81
|
|
|
38
|
-
// Download if needed
|
|
82
|
+
// Download if needed. Same bounded path as the image download (#116) — this
|
|
83
|
+
// call site previously had neither a deadline nor retries, so its only bound
|
|
84
|
+
// was whatever the calling test happened to impose.
|
|
39
85
|
if (!existsSync(zipPath)) {
|
|
40
86
|
const log = logger ?? createLogger();
|
|
41
87
|
log.status(`Downloading packages for ${version} (${arch})...`);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
88
|
+
log.status(` ${url}`);
|
|
89
|
+
await downloadToFile(url, zipPath, { logger: log });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Extract into a sibling directory, record the exact complete set, then
|
|
93
|
+
// rename it into place. A killed extraction can never look like a cache hit.
|
|
94
|
+
const tempDir = mkdtempSync(join(cache, ".quickchr-packages-"));
|
|
95
|
+
try {
|
|
96
|
+
extractZip(zipPath, tempDir);
|
|
97
|
+
const packages = packageFiles(tempDir);
|
|
98
|
+
if (packages.length === 0) {
|
|
99
|
+
throw new Error(`No non-empty .npk files found in ${zipName}`);
|
|
48
100
|
}
|
|
49
|
-
const
|
|
50
|
-
|
|
101
|
+
const manifest: PackageCacheManifest = {
|
|
102
|
+
version,
|
|
103
|
+
arch,
|
|
104
|
+
archiveSize: statSync(zipPath).size,
|
|
105
|
+
packages,
|
|
106
|
+
};
|
|
107
|
+
await Bun.write(join(tempDir, PACKAGE_CACHE_MANIFEST), `${JSON.stringify(manifest, null, "\t")}\n`);
|
|
108
|
+
if (existsSync(extractDir)) rmSync(extractDir, { recursive: true, force: true });
|
|
109
|
+
renameSync(tempDir, extractDir);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
112
|
+
if (error instanceof Error && error.message.startsWith("No non-empty")) {
|
|
113
|
+
throw new QuickCHRError("PROCESS_FAILED", error.message);
|
|
114
|
+
}
|
|
115
|
+
throw error;
|
|
51
116
|
}
|
|
52
117
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
118
|
+
if (!(await isCompletePackageCache(version, arch, cache))) {
|
|
119
|
+
throw new QuickCHRError(
|
|
120
|
+
"PROCESS_FAILED",
|
|
121
|
+
`Package cache verification failed for ${version} (${arch})`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
56
124
|
|
|
57
125
|
return extractDir;
|
|
58
126
|
}
|