@tikoci/quickchr 0.3.0 → 0.4.0
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 +54 -23
- package/README.md +39 -0
- package/package.json +4 -2
- package/src/cli/index.ts +69 -0
- package/src/index.ts +2 -1
- package/src/lib/auth.ts +17 -4
- package/src/lib/channels.ts +3 -1
- package/src/lib/completions.ts +12 -6
- package/src/lib/credentials.ts +2 -0
- package/src/lib/qemu.ts +11 -11
- package/src/lib/quickchr.ts +87 -21
- package/src/lib/types.ts +34 -0
package/DESIGN.md
CHANGED
|
@@ -19,7 +19,7 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
|
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **CLI** — git-style subcommands + interactive wizard. Thin layer over the library.
|
|
22
|
-
- **Library** — `QuickCHR` class with static methods: `start()`, `list()`, `get()`, `doctor()`. Returns `ChrInstance` handles with `stop()`, `remove()`, `rest()`, `monitor()`, etc.
|
|
22
|
+
- **Library** — `QuickCHR` class with static methods: `start()`, `add()`, `list()`, `get()`, `doctor()`. Returns `ChrInstance` handles with `stop()`, `remove()`, `rest()`, `exec()`, `monitor()`, etc.
|
|
23
23
|
- **Modules** — Pure functions for QEMU arg building, image download, port allocation, state persistence.
|
|
24
24
|
|
|
25
25
|
### Key Design Decisions
|
|
@@ -36,6 +36,16 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
|
|
|
36
36
|
|
|
37
37
|
6. **Class-based API** — `QuickCHR` is a class with static methods for clean namespacing. `ChrInstance` is an interface implemented as a plain object with closures.
|
|
38
38
|
|
|
39
|
+
7. **Running-only connection descriptors** — `ChrInstance.descriptor()`, `quickchr inspect`, and `quickchr env` are live connection handoff surfaces, not stale state readers. They intentionally fail with `MACHINE_STOPPED` when the VM is not running, because ports/auth/status are only safe to consume when the machine is active. Descriptor/env output includes auth material by design for subprocess handoff; callers must treat it as credential-bearing output.
|
|
40
|
+
|
|
41
|
+
8. **Boot respawn-once on hardware accel** — When `QuickCHR.start()` boots a fresh machine in background mode and `waitForBoot` exhausts its budget under `kvm`/`hvf`, it stops the QEMU process, clears its `server=on` socket files, and respawns **once** before raising `BOOT_TIMEOUT` (see `start()` in `src/lib/quickchr.ts`, gated on `accel`). This targets an observed CI flake: on GitHub's nested-KVM runner a single boot among many occasionally never reaches REST while siblings boot in ~30-45s — a *wedged* process that a longer timeout would not rescue, only a fresh one. Gated to hardware accel because TCG boots are legitimately long and doubling buys nothing.
|
|
42
|
+
|
|
43
|
+
⚠️ **Watch-item / scope caveat.** The root cause is **unconfirmed** — the respawn is a pragmatic mitigation that keeps CI green, not a proven diagnosis; the trigger could be something else (runner CPU-steal, a SLiRP stall, image-specific timing). Two deliberate limits to revisit if it recurs:
|
|
44
|
+
- **Not applied to `_launchExisting`** (the restart-existing-machine path) — that path also spawns + `waitForBoot`s but has never been observed to flake. Extend the same respawn there only with evidence.
|
|
45
|
+
- **Timeout factor left at 1.5×** (`accelTimeoutFactor`, `src/lib/platform.ts`) rather than inflated further — the respawn is the recovery mechanism, not a bigger ceiling.
|
|
46
|
+
|
|
47
|
+
How to tell it's firing: grep CI `qemu.log`/run logs for `respawning QEMU once`. A frequent occurrence, or a `BOOT_TIMEOUT` that *survives* the respawn (or appears on the `_launchExisting` path), is the signal to stop treating it as a flake and find the real cause. Tracked in `BACKLOG.md` (boot-respawn watch-item).
|
|
48
|
+
|
|
39
49
|
## Port Layout
|
|
40
50
|
|
|
41
51
|
| Offset | Service | Guest Port |
|
|
@@ -87,37 +97,41 @@ Modules (src/lib/) ← qemu, images, versions, network, state, ...
|
|
|
87
97
|
|
|
88
98
|
## CI System
|
|
89
99
|
|
|
90
|
-
**
|
|
100
|
+
**Workflows**: `.github/workflows/{ci,verify-extended,publish}.yml`. Full artifact map and
|
|
101
|
+
failure-diagnosis guide live in `.github/instructions/ci.instructions.md` — this section is
|
|
102
|
+
the high-level rationale only.
|
|
91
103
|
|
|
92
|
-
|
|
104
|
+
**`ci.yml`** — core quality gate on every push/PR to `main`:
|
|
93
105
|
|
|
106
|
+
```text
|
|
107
|
+
lint (ubuntu) ──┐
|
|
108
|
+
unit-tests (ubuntu, --coverage) ──┴→ integration-x86 (ubuntu) → windows-unit-tests (windows-latest)
|
|
94
109
|
```
|
|
95
|
-
lint unit-tests windows-unit-tests
|
|
96
|
-
Biome + tsc --noEmit bun test test/unit/ bun test test/unit/
|
|
97
|
-
--coverage (windows-latest, always)
|
|
98
|
-
↘ ↙
|
|
99
|
-
integration (matrix)
|
|
100
|
-
linux/x86_64 (always)
|
|
101
|
-
linux/aarch64 (always)
|
|
102
|
-
macos/arm64 (workflow_dispatch: macos=true)
|
|
103
|
-
macos/x86_64 (workflow_dispatch: macos=true)
|
|
104
|
-
```
|
|
105
110
|
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
`lint` and `unit-tests` run in parallel. `integration-x86` boots an x86 CHR (KVM if
|
|
112
|
+
available, else TCG) and gates on both. `windows-unit-tests` runs last — if the core is
|
|
113
|
+
broken, Windows runner minutes add no signal. Only x86 integration runs on every push;
|
|
114
|
+
cross-arch and macOS are too slow/fragile for the per-push gate.
|
|
115
|
+
|
|
116
|
+
**`verify-extended.yml`** — `workflow_dispatch` only. Independent jobs (no cross-`needs:`)
|
|
117
|
+
for the platforms kept out of the per-push gate: linux/aarch64, macOS (arm64 + x86), and
|
|
118
|
+
Windows. Dispatch inputs `arm64` / `macos` / `windows` select platforms; `test-filter`
|
|
119
|
+
narrows to specific test files for fast iteration. Each runner boots a CHR matching its
|
|
120
|
+
native arch — `detectAccel()` selects KVM/HVF/TCG automatically, no per-runner overrides.
|
|
121
|
+
|
|
122
|
+
**`publish.yml`** — triggers on `v*` tags (or dispatch). Runs lint + unit + x86 integration +
|
|
123
|
+
windows-unit before `npm publish` (`--tag next` for odd/pre-release minors, `--tag latest`
|
|
124
|
+
for even/stable). See "Release Process" in `ci.instructions.md`.
|
|
108
125
|
|
|
109
|
-
**Coverage**: `bun test
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
126
|
+
**Coverage**: `unit-tests` parses `bun test --coverage` output and compares against thresholds
|
|
127
|
+
(default 75% functions, 60% lines). Failures emit `::warning::` annotations but do NOT block
|
|
128
|
+
merges (`continue-on-error: true`). Thresholds are overridable via dispatch inputs
|
|
129
|
+
`min-funcs` / `min-lines`.
|
|
113
130
|
|
|
114
|
-
**Artifacts
|
|
131
|
+
**Artifacts**:
|
|
115
132
|
- `coverage-report` — full per-file coverage table (14 days)
|
|
116
133
|
- `integration-logs-{platform}` — bun test output + machine.json + qemu.log (7 days)
|
|
117
134
|
|
|
118
|
-
**Publish workflow** (`.github/workflows/publish.yml`): triggers on `v*` tags;
|
|
119
|
-
runs lint + typecheck + unit tests before npm publish.
|
|
120
|
-
|
|
121
135
|
## Design Principles
|
|
122
136
|
|
|
123
137
|
### Scope Boundary — QEMU Expert, Not Orchestrator
|
|
@@ -144,6 +158,23 @@ The `default-route-distance=2` ensures the shared route is backup — SLiRP ethe
|
|
|
144
158
|
|
|
145
159
|
**TCG hazard:** SLiRP half-open connections (TCP connect succeeds, data never flows) burn the full per-probe HTTP timeout in `waitForBoot`. Under cross-arch TCG where TCP round-trips are slow, this compounds badly. Lab: `test/lab/slirp-hostfwd/`.
|
|
146
160
|
|
|
161
|
+
#### Host-Side L2 Capture (MNDP, MAC-Telnet)
|
|
162
|
+
|
|
163
|
+
A caller can receive the guest's raw Layer-2 frames — RouterOS MNDP (UDP/5678
|
|
164
|
+
broadcast) being the first use case — without root or a native helper, using the
|
|
165
|
+
TCP `socket` netdev: the host runs a TCP server, the CHR gets a `socket-connect`
|
|
166
|
+
NIC, and QEMU streams every guest frame to the host length-prefixed (4-byte BE
|
|
167
|
+
length + raw Ethernet). Loopback-only, cross-platform. Writing a frame back over
|
|
168
|
+
the same connection injects L2 into the guest (the MAC-Telnet primitive). Recipe:
|
|
169
|
+
`docs/mndp.md`; example: `examples/mndp/`.
|
|
170
|
+
|
|
171
|
+
**Discovered constraint (2026-06-06):** the `socket-mcast` netdev — the documented
|
|
172
|
+
multi-VM L2 path — is **broken on macOS**. QEMU's mcast socket sets only
|
|
173
|
+
`SO_REUSEADDR`, while macOS/BSD need `SO_REUSEPORT` on every socket sharing a
|
|
174
|
+
multicast port; two CHRs on one group don't discover each other and host capture
|
|
175
|
+
gets nothing. mcast still works on Linux/CI. Prefer `socket-connect` for host
|
|
176
|
+
capture on any platform. Evidence: `test/lab/mndp/REPORT.md`.
|
|
177
|
+
|
|
147
178
|
### Platform Priority
|
|
148
179
|
|
|
149
180
|
macOS → Linux → Windows. Mac and Linux share most code paths with minor `#ifdef`-style branches. Windows is tracked but lower priority — larger RouterOS admin audience there, but fewer recipes and harder to test. Windows CI runner planned after the existing macOS/Linux matrix is stable.
|
package/README.md
CHANGED
|
@@ -93,6 +93,11 @@ quickchr list
|
|
|
93
93
|
# Detailed status for one instance
|
|
94
94
|
quickchr status my-chr
|
|
95
95
|
|
|
96
|
+
# Stable connection descriptor/env for a running instance
|
|
97
|
+
quickchr inspect my-chr
|
|
98
|
+
quickchr env my-chr
|
|
99
|
+
quickchr env my-chr --json
|
|
100
|
+
|
|
96
101
|
# Stop an instance
|
|
97
102
|
quickchr stop my-chr
|
|
98
103
|
|
|
@@ -157,6 +162,7 @@ quickchr doctor
|
|
|
157
162
|
| `--boot-disk-format <f>` | Boot disk format: qcow2\|raw | qcow2 |
|
|
158
163
|
| `--boot-size <size>` | Resize boot disk (e.g., 512M, 2G). Requires `qemu-img`. | |
|
|
159
164
|
| `--add-disk <size>` | Attach an extra blank qcow2 disk. Repeatable. Requires `qemu-img`. | |
|
|
165
|
+
| `--forward <spec>` | Add or pin a QEMU SLiRP hostfwd mapping. Repeatable. | |
|
|
160
166
|
| `--bg` / `--background` | Run in background (default) | true |
|
|
161
167
|
| `--fg` / `--foreground` | Run in foreground — serial console on stdio | |
|
|
162
168
|
| `--add-package <pkg>` | Extra package to install (repeatable) | |
|
|
@@ -261,6 +267,39 @@ Each instance gets a block of 10 ports. With the default base of 9100, the first
|
|
|
261
267
|
|
|
262
268
|
The second instance gets 9110–9119, and so on.
|
|
263
269
|
|
|
270
|
+
### Custom Port Forwards and Fixed WinBox
|
|
271
|
+
|
|
272
|
+
Use `--forward <spec>` on `add` or create-and-start `start` commands to add
|
|
273
|
+
extra QEMU SLiRP `hostfwd` mappings. The shorthand uses the built-in service
|
|
274
|
+
registry when it knows the guest port:
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
quickchr add lab --forward smb # auto host port → guest 445/tcp
|
|
278
|
+
quickchr add dude-lab --forward winbox:8291 # host 8291 → guest WinBox 8291/tcp
|
|
279
|
+
quickchr add app-lab --forward myapp:9200:7777/udp
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Reusing a built-in service name such as `winbox` pins/replaces that service's
|
|
283
|
+
host port for the machine. Existing machines keep the mapping stored in
|
|
284
|
+
`machine.json`; recreate the machine to change fixed service ports cleanly.
|
|
285
|
+
|
|
286
|
+
### Machine Descriptors and Subprocess Environments
|
|
287
|
+
|
|
288
|
+
`quickchr inspect <name> [--json]` emits a stable JSON descriptor for a
|
|
289
|
+
**running** machine: status, ports, URLs, auth, env vars, and the machine
|
|
290
|
+
directory. `--json` is accepted for parity; inspect output is always JSON.
|
|
291
|
+
|
|
292
|
+
`quickchr env <name> [--json]` prints the same subprocess environment as
|
|
293
|
+
`ChrInstance.subprocessEnv()`: shell `KEY=value` lines by default, or a JSON
|
|
294
|
+
map with `--json`.
|
|
295
|
+
|
|
296
|
+
> **Credential caveat:** descriptor/env output includes connection secrets
|
|
297
|
+
> (`auth.password`, `auth.basic`, `auth.header`, `QUICKCHR_AUTH`, `BASICAUTH`)
|
|
298
|
+
> so child processes can connect without reading quickchr's secret store. Treat
|
|
299
|
+
> it like a password: do not commit it, paste it into public issues, or leave it
|
|
300
|
+
> in CI logs. Stopped machines fail with `MACHINE_STOPPED`; start the machine
|
|
301
|
+
> before requesting a descriptor or env map.
|
|
302
|
+
|
|
264
303
|
### Library Usage
|
|
265
304
|
|
|
266
305
|
```typescript
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tikoci/quickchr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "CLI and library to download, launch, and manage MikroTik CHR virtual machines via QEMU",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
"DESIGN.md"
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
|
-
"check": "bun run lint:biome && bun run lint:typecheck && bun run lint:markdown",
|
|
23
|
+
"check": "bun run lint:biome && bun run lint:typecheck && bun run lint:markdown && bun run lint:cspell",
|
|
24
24
|
"lint": "biome check . && tsc --noEmit",
|
|
25
25
|
"lint:biome": "biome check .",
|
|
26
26
|
"lint:typecheck": "tsc --noEmit",
|
|
27
27
|
"lint:markdown": "bunx markdownlint-cli2 '**/*.md'",
|
|
28
|
+
"lint:cspell": "cspell lint . --no-progress",
|
|
28
29
|
"test": "bun test test/unit/",
|
|
29
30
|
"test:examples": "bun test examples/",
|
|
30
31
|
"test:integration": "QUICKCHR_INTEGRATION=1 bun test test/integration/",
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
"devDependencies": {
|
|
37
38
|
"@biomejs/biome": "^2.4.10",
|
|
38
39
|
"@types/bun": "^1.3.11",
|
|
40
|
+
"cspell": "^10.0.0",
|
|
39
41
|
"markdownlint-cli2": "^0.21.0"
|
|
40
42
|
},
|
|
41
43
|
"dependencies": {
|
package/src/cli/index.ts
CHANGED
|
@@ -152,6 +152,12 @@ async function main() {
|
|
|
152
152
|
case "get":
|
|
153
153
|
await cmdGet(args.slice(1));
|
|
154
154
|
break;
|
|
155
|
+
case "inspect":
|
|
156
|
+
await cmdInspect(args.slice(1));
|
|
157
|
+
break;
|
|
158
|
+
case "env":
|
|
159
|
+
await cmdEnv(args.slice(1));
|
|
160
|
+
break;
|
|
155
161
|
case "license":
|
|
156
162
|
console.error("Note: 'quickchr license' is deprecated. Use 'quickchr set <name> --license' instead.");
|
|
157
163
|
await cmdLicense(args.slice(1));
|
|
@@ -1432,6 +1438,50 @@ async function cmdList(argv: string[] = []) {
|
|
|
1432
1438
|
console.log(table(headers, rows));
|
|
1433
1439
|
}
|
|
1434
1440
|
|
|
1441
|
+
function shellQuoteEnv(value: string): string {
|
|
1442
|
+
if (/^[A-Za-z0-9_./:@+-]*$/.test(value)) return value;
|
|
1443
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
async function getRunningDescriptor(name: string) {
|
|
1447
|
+
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
1448
|
+
const { machineNotFoundMessage } = await import("./format.ts");
|
|
1449
|
+
const instance = QuickCHR.get(name);
|
|
1450
|
+
if (!instance) {
|
|
1451
|
+
console.error(machineNotFoundMessage(name));
|
|
1452
|
+
process.exit(1);
|
|
1453
|
+
}
|
|
1454
|
+
return instance.descriptor();
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
async function cmdInspect(argv: string[] = []) {
|
|
1458
|
+
const { positional } = parseFlags(argv);
|
|
1459
|
+
const name = positional[0];
|
|
1460
|
+
if (!name) {
|
|
1461
|
+
console.error("Usage: quickchr inspect <name> [--json]");
|
|
1462
|
+
process.exit(1);
|
|
1463
|
+
}
|
|
1464
|
+
const descriptor = await getRunningDescriptor(name);
|
|
1465
|
+
console.log(JSON.stringify(descriptor, null, 2));
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
async function cmdEnv(argv: string[] = []) {
|
|
1469
|
+
const { flags, positional } = parseFlags(argv);
|
|
1470
|
+
const name = positional[0];
|
|
1471
|
+
const asJson = flagBool(flags, "json");
|
|
1472
|
+
if (!name) {
|
|
1473
|
+
console.error("Usage: quickchr env <name> [--json]");
|
|
1474
|
+
process.exit(1);
|
|
1475
|
+
}
|
|
1476
|
+
const descriptor = await getRunningDescriptor(name);
|
|
1477
|
+
if (asJson) {
|
|
1478
|
+
console.log(JSON.stringify(descriptor.env, null, 2));
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
for (const [key, value] of Object.entries(descriptor.env)) {
|
|
1482
|
+
console.log(`${key}=${shellQuoteEnv(value)}`);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1435
1485
|
|
|
1436
1486
|
async function cmdGet(argv: string[]) {
|
|
1437
1487
|
const { flags, positional } = parseFlags(argv);
|
|
@@ -2457,6 +2507,8 @@ Commands:
|
|
|
2457
2507
|
stop [<name>|--all] Stop instance(s) — print list if no name
|
|
2458
2508
|
list [<name>] [--json] List all instances, or detail for one
|
|
2459
2509
|
status Alias for list
|
|
2510
|
+
inspect <name> [--json] Print running machine descriptor as JSON
|
|
2511
|
+
env <name> [--json] Print subprocess environment for a running machine
|
|
2460
2512
|
console <name> Attach to serial console of a running instance
|
|
2461
2513
|
exec <name> <command> Run a RouterOS CLI command on a running instance
|
|
2462
2514
|
remove [<name>|--all] Remove instance(s) and disk
|
|
@@ -2637,6 +2689,23 @@ List all CHR instances or show detailed info for one.
|
|
|
2637
2689
|
|
|
2638
2690
|
'quickchr status' is an alias for this command.`);
|
|
2639
2691
|
break;
|
|
2692
|
+
case "inspect":
|
|
2693
|
+
console.log(`quickchr inspect <name> [--json]
|
|
2694
|
+
|
|
2695
|
+
Print a stable JSON descriptor for a running CHR instance: ports, URLs,
|
|
2696
|
+
credentials, status, and subprocess env vars.
|
|
2697
|
+
|
|
2698
|
+
<name> Name of a running CHR instance.
|
|
2699
|
+
--json Accepted for parity; output is always JSON.`);
|
|
2700
|
+
break;
|
|
2701
|
+
case "env":
|
|
2702
|
+
console.log(`quickchr env <name> [--json]
|
|
2703
|
+
|
|
2704
|
+
Print environment variables for subprocesses that connect to a running CHR.
|
|
2705
|
+
|
|
2706
|
+
<name> Name of a running CHR instance.
|
|
2707
|
+
--json Output the env map as JSON instead of KEY=value lines.`);
|
|
2708
|
+
break;
|
|
2640
2709
|
case "doctor":
|
|
2641
2710
|
console.log(`quickchr doctor
|
|
2642
2711
|
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* - {@link QuickCHR} — Main class with static methods: `start()`, `add()`, `list()`, `get()`, `doctor()`
|
|
18
18
|
* - {@link ChrInstance} — Runtime handle returned by `start()`: `stop()`, `remove()`, `rest()`, `exec()`, `upload()`, `download()`, `snapshot`, etc.
|
|
19
19
|
* - {@link StartOptions} — All options for creating/starting a CHR instance
|
|
20
|
-
* - {@link QuickCHRError} — Typed errors with codes like `MISSING_QEMU`, `
|
|
20
|
+
* - {@link QuickCHRError} — Typed errors with codes like `MISSING_QEMU`, `BOOT_TIMEOUT`, `EXEC_FAILED` (full list: {@link ErrorCode})
|
|
21
21
|
*
|
|
22
22
|
* @packageDocumentation
|
|
23
23
|
*/
|
|
@@ -44,6 +44,7 @@ export type {
|
|
|
44
44
|
LicenseLevel,
|
|
45
45
|
LicenseOptions,
|
|
46
46
|
MachineConfig,
|
|
47
|
+
MachineDescriptor,
|
|
47
48
|
MachineState,
|
|
48
49
|
NetworkConfig,
|
|
49
50
|
NetworkMode,
|
package/src/lib/auth.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { MachineState } from "./types.ts";
|
|
11
|
-
import { getInstanceCredentials } from "./credentials.ts";
|
|
11
|
+
import { getInstanceCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
|
|
12
12
|
|
|
13
13
|
export interface ResolvedAuth {
|
|
14
14
|
/** HTTP Basic Authorization header value. */
|
|
@@ -48,9 +48,16 @@ export function resolveAuth(
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
// state.user is the canonical provisioned user written to machine.json.
|
|
52
|
-
// Check this first — no I/O, no keychain, no blocking.
|
|
53
51
|
if (state.user) {
|
|
52
|
+
if (state.user.password === STORED_IN_SECRETS_PASSWORD) {
|
|
53
|
+
const stored = getInstanceCredentials(state.name);
|
|
54
|
+
if (stored) {
|
|
55
|
+
return {
|
|
56
|
+
header: `Basic ${btoa(`${stored.user}:${stored.password}`)}`,
|
|
57
|
+
user: stored.user,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
54
61
|
return {
|
|
55
62
|
header: `Basic ${btoa(`${state.user.name}:${state.user.password}`)}`,
|
|
56
63
|
user: state.user.name,
|
|
@@ -82,7 +89,13 @@ export function resolveCreds(
|
|
|
82
89
|
password?: string,
|
|
83
90
|
): ResolvedCreds {
|
|
84
91
|
if (user !== undefined) return { user, password: password ?? "" };
|
|
85
|
-
if (state.user)
|
|
92
|
+
if (state.user) {
|
|
93
|
+
if (state.user.password === STORED_IN_SECRETS_PASSWORD) {
|
|
94
|
+
const stored = getInstanceCredentials(state.name);
|
|
95
|
+
if (stored) return { user: stored.user, password: stored.password };
|
|
96
|
+
}
|
|
97
|
+
return { user: state.user.name, password: state.user.password };
|
|
98
|
+
}
|
|
86
99
|
const stored = getInstanceCredentials(state.name);
|
|
87
100
|
if (stored) return { user: stored.user, password: stored.password };
|
|
88
101
|
return { user: "admin", password: "" };
|
package/src/lib/channels.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Communication channels — QEMU monitor, serial console, QGA (QEMU Guest Agent).
|
|
3
3
|
*
|
|
4
|
-
* Uses Unix domain sockets stored in the machine directory.
|
|
4
|
+
* Uses Unix domain sockets stored in the machine directory on POSIX. On Windows,
|
|
5
|
+
* launched machines use TCP localhost endpoints derived from portBase; named pipes
|
|
6
|
+
* remain only as the no-portBase fallback path.
|
|
5
7
|
* QGA protocol implementation lives in qga.ts — this module provides
|
|
6
8
|
* the high-level qgaCommand() wrapper with arch guards and path resolution.
|
|
7
9
|
*/
|
package/src/lib/completions.ts
CHANGED
|
@@ -328,7 +328,7 @@ _quickchr() {
|
|
|
328
328
|
cword=${D}COMP_CWORD
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
local commands="add start stop list ls status remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help"
|
|
331
|
+
local commands="add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help"
|
|
332
332
|
|
|
333
333
|
if [[ ${D}cword -eq 1 ]]; then
|
|
334
334
|
COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
|
|
@@ -337,7 +337,7 @@ _quickchr() {
|
|
|
337
337
|
|
|
338
338
|
local cmd="${D}{words[1]}"
|
|
339
339
|
case "${D}cmd" in
|
|
340
|
-
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status)
|
|
340
|
+
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env)
|
|
341
341
|
local machines
|
|
342
342
|
machines=$(quickchr completions --machines 2>/dev/null)
|
|
343
343
|
COMPREPLY=( $(compgen -W "${D}machines" -- "${D}cur") )
|
|
@@ -387,6 +387,8 @@ _quickchr() {
|
|
|
387
387
|
'list:List all instances'
|
|
388
388
|
'ls:List all instances (alias)'
|
|
389
389
|
'status:Detailed status'
|
|
390
|
+
'inspect:Print running machine descriptor'
|
|
391
|
+
'env:Print subprocess environment'
|
|
390
392
|
'remove:Remove instance(s) and disk'
|
|
391
393
|
'rm:Remove instance(s) (alias)'
|
|
392
394
|
'clean:Reset instance disk to fresh image'
|
|
@@ -410,7 +412,7 @@ _quickchr() {
|
|
|
410
412
|
args)
|
|
411
413
|
local cmd="${D}{words[2]}"
|
|
412
414
|
case ${D}cmd in
|
|
413
|
-
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status)
|
|
415
|
+
start|remove|rm|clean|console|exec|qga|license|disk|snapshot|snap|status|inspect|env)
|
|
414
416
|
local machines
|
|
415
417
|
machines=(${D}{(f)"${D}(quickchr completions --machines 2>/dev/null)"})
|
|
416
418
|
_describe 'machine' machines
|
|
@@ -432,7 +434,7 @@ _quickchr() {
|
|
|
432
434
|
_describe 'option' opts
|
|
433
435
|
;;
|
|
434
436
|
help)
|
|
435
|
-
local commands=(add start stop list status remove clean console exec qga license disk snapshot networks doctor setup completions version)
|
|
437
|
+
local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks doctor setup completions version)
|
|
436
438
|
_describe 'command' commands
|
|
437
439
|
;;
|
|
438
440
|
esac
|
|
@@ -448,7 +450,7 @@ function generateFishCompletion(): string {
|
|
|
448
450
|
return `# quickchr fish completion
|
|
449
451
|
# Generated by quickchr. Fish auto-loads files from ~/.config/fish/completions/.
|
|
450
452
|
|
|
451
|
-
set -l quickchr_commands add start stop list ls status remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help
|
|
453
|
+
set -l quickchr_commands add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor setup completions version help
|
|
452
454
|
|
|
453
455
|
# Disable file completions globally for quickchr
|
|
454
456
|
complete -c quickchr -f
|
|
@@ -464,6 +466,10 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
|
464
466
|
-a "list ls" -d "List all instances"
|
|
465
467
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
466
468
|
-a "status" -d "Detailed status"
|
|
469
|
+
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
470
|
+
-a "inspect" -d "Print running machine descriptor"
|
|
471
|
+
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
472
|
+
-a "env" -d "Print subprocess environment"
|
|
467
473
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
468
474
|
-a "remove rm" -d "Remove instance(s) and disk"
|
|
469
475
|
complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
|
|
@@ -499,7 +505,7 @@ complete -c quickchr -n "__fish_seen_subcommand_from stop" \\
|
|
|
499
505
|
-d "machine"
|
|
500
506
|
|
|
501
507
|
# Machine name completions for commands that take any machine
|
|
502
|
-
complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status" \\
|
|
508
|
+
complete -c quickchr -n "__fish_seen_subcommand_from start remove rm clean console exec qga license disk snapshot snap status inspect env" \\
|
|
503
509
|
-a "(quickchr completions --machines 2>/dev/null)" \\
|
|
504
510
|
-d "machine"
|
|
505
511
|
|
package/src/lib/credentials.ts
CHANGED
package/src/lib/qemu.ts
CHANGED
|
@@ -437,23 +437,23 @@ export async function stopQemu(pid: number): Promise<boolean> {
|
|
|
437
437
|
return true;
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
-
/**
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
// Clean up socket files
|
|
446
|
-
const machineDir = state.machineDir;
|
|
440
|
+
/** Remove the listening unix-socket files QEMU binds (monitor/serial/qga).
|
|
441
|
+
* A SIGKILLed QEMU leaves these behind; a respawn with `server=on` then fails
|
|
442
|
+
* to bind ("Address already in use"). Call before re-spawning into the same dir. */
|
|
443
|
+
export function cleanupQemuSockets(machineDir: string): void {
|
|
447
444
|
for (const sock of ["monitor.sock", "serial.sock", "qga.sock"]) {
|
|
448
445
|
const path = join(machineDir, sock);
|
|
449
446
|
try {
|
|
450
|
-
if (existsSync(path))
|
|
451
|
-
const { unlinkSync } = await import("node:fs");
|
|
452
|
-
unlinkSync(path);
|
|
453
|
-
}
|
|
447
|
+
if (existsSync(path)) unlinkSync(path);
|
|
454
448
|
} catch { /* ignore */ }
|
|
455
449
|
}
|
|
450
|
+
}
|
|
456
451
|
|
|
452
|
+
/** Stop a machine by name. */
|
|
453
|
+
export async function stopMachineByName(_name: string, state: MachineState): Promise<boolean> {
|
|
454
|
+
if (!state.pid) return false;
|
|
455
|
+
const stopped = await stopQemu(state.pid);
|
|
456
|
+
cleanupQemuSockets(state.machineDir);
|
|
457
457
|
return stopped;
|
|
458
458
|
}
|
|
459
459
|
|
package/src/lib/quickchr.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
ExecResult,
|
|
14
14
|
LicenseInput,
|
|
15
15
|
LicenseLevel,
|
|
16
|
+
MachineDescriptor,
|
|
16
17
|
MachineState,
|
|
17
18
|
QgaCommand,
|
|
18
19
|
SnapshotInfo,
|
|
@@ -45,7 +46,7 @@ import {
|
|
|
45
46
|
} from "./state.ts";
|
|
46
47
|
import { ensureCachedImage, copyImageToMachine, listCachedImages } from "./images.ts";
|
|
47
48
|
import { autoPruneIfOverCap } from "./cache.ts";
|
|
48
|
-
import { buildQemuArgs, spawnQemu, stopQemu, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
|
|
49
|
+
import { buildQemuArgs, spawnQemu, stopQemu, cleanupQemuSockets, waitForBoot, extractWrapper, type QemuLaunchConfig } from "./qemu.ts";
|
|
49
50
|
import { cleanDiskFiles, ensureConfiguredDisks, normalizeDiskOptions, parseSnapshotList, listSnapshots, formatDiskSize } from "./disk.ts";
|
|
50
51
|
import { monitorCommand, serialStreams, qgaCommand, channelEndpoint } from "./channels.ts";
|
|
51
52
|
import { installPackages, installAllPackages, downloadAndListPackages, downloadPackages, findPackageFile, uploadPackages } from "./packages.ts";
|
|
@@ -53,7 +54,7 @@ import { provision } from "./provision.ts";
|
|
|
53
54
|
import { renewLicense, getLicenseInfo } from "./license.ts";
|
|
54
55
|
import { resolveAuth, resolveCreds } from "./auth.ts";
|
|
55
56
|
import { scpPush, scpPull } from "./scp.ts";
|
|
56
|
-
import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials } from "./credentials.ts";
|
|
57
|
+
import { deleteInstanceCredentials, credentialStorageLabel, getStoredCredentials, STORED_IN_SECRETS_PASSWORD } from "./credentials.ts";
|
|
57
58
|
import { restExecute } from "./exec.ts";
|
|
58
59
|
import { qgaExec } from "./qga.ts";
|
|
59
60
|
import { consoleExec } from "./console.ts";
|
|
@@ -224,6 +225,63 @@ async function resolveLicenseInput(input: LicenseInput): Promise<LicenseOptions
|
|
|
224
225
|
function createInstance(state: MachineState): ChrInstance {
|
|
225
226
|
const ports = toChrPorts(state.ports);
|
|
226
227
|
const restUrl = `http://127.0.0.1:${ports.http}`;
|
|
228
|
+
const buildEnv = (): Record<string, string> => {
|
|
229
|
+
const creds = resolveCreds(state);
|
|
230
|
+
const rawCreds = `${creds.user}:${creds.password}`;
|
|
231
|
+
const restBase = `${restUrl}/rest`;
|
|
232
|
+
return {
|
|
233
|
+
QUICKCHR_NAME: state.name,
|
|
234
|
+
QUICKCHR_REST_URL: restUrl,
|
|
235
|
+
QUICKCHR_REST_BASE: restBase,
|
|
236
|
+
QUICKCHR_SSH_PORT: String(ports.ssh),
|
|
237
|
+
QUICKCHR_AUTH: rawCreds,
|
|
238
|
+
// Legacy compat keys used by restraml and similar consumers.
|
|
239
|
+
URLBASE: restBase,
|
|
240
|
+
BASICAUTH: rawCreds,
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
const buildDescriptor = (): MachineDescriptor => {
|
|
244
|
+
if (state.status !== "running") {
|
|
245
|
+
throw new QuickCHRError(
|
|
246
|
+
"MACHINE_STOPPED",
|
|
247
|
+
`Machine "${state.name}" must be running to inspect its connection environment (current status: ${state.status}).`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
const auth = resolveAuth(state);
|
|
251
|
+
const env = buildEnv();
|
|
252
|
+
const basic = env.QUICKCHR_AUTH ?? `${auth.user}:`;
|
|
253
|
+
return {
|
|
254
|
+
name: state.name,
|
|
255
|
+
status: "running",
|
|
256
|
+
version: state.version,
|
|
257
|
+
arch: state.arch,
|
|
258
|
+
cpu: state.cpu,
|
|
259
|
+
mem: state.mem,
|
|
260
|
+
pid: state.pid ?? null,
|
|
261
|
+
ports,
|
|
262
|
+
portMappings: state.ports,
|
|
263
|
+
urls: {
|
|
264
|
+
http: restUrl,
|
|
265
|
+
rest: `${restUrl}/rest`,
|
|
266
|
+
restBase: `${restUrl}/rest`,
|
|
267
|
+
https: ports.https ? `https://127.0.0.1:${ports.https}` : undefined,
|
|
268
|
+
ssh: ports.ssh ? `ssh://${auth.user}@127.0.0.1:${ports.ssh}` : undefined,
|
|
269
|
+
api: ports.api ? `tcp://127.0.0.1:${ports.api}` : undefined,
|
|
270
|
+
apiSsl: ports.apiSsl ? `tls://127.0.0.1:${ports.apiSsl}` : undefined,
|
|
271
|
+
winbox: ports.winbox ? `tcp://127.0.0.1:${ports.winbox}` : undefined,
|
|
272
|
+
},
|
|
273
|
+
auth: {
|
|
274
|
+
user: auth.user,
|
|
275
|
+
password: basic.slice(auth.user.length + 1),
|
|
276
|
+
basic,
|
|
277
|
+
header: auth.header,
|
|
278
|
+
},
|
|
279
|
+
env,
|
|
280
|
+
machineDir: state.machineDir,
|
|
281
|
+
createdAt: state.createdAt,
|
|
282
|
+
lastStartedAt: state.lastStartedAt ?? null,
|
|
283
|
+
};
|
|
284
|
+
};
|
|
227
285
|
|
|
228
286
|
return {
|
|
229
287
|
name: state.name,
|
|
@@ -571,22 +629,11 @@ function createInstance(state: MachineState): ChrInstance {
|
|
|
571
629
|
},
|
|
572
630
|
|
|
573
631
|
async subprocessEnv(): Promise<Record<string, string>> {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
const restBase = `${restUrl}/rest`;
|
|
580
|
-
return {
|
|
581
|
-
QUICKCHR_NAME: state.name,
|
|
582
|
-
QUICKCHR_REST_URL: restUrl,
|
|
583
|
-
QUICKCHR_REST_BASE: restBase,
|
|
584
|
-
QUICKCHR_SSH_PORT: String(ports.ssh),
|
|
585
|
-
QUICKCHR_AUTH: rawCreds,
|
|
586
|
-
// Legacy compat keys used by restraml and similar consumers.
|
|
587
|
-
URLBASE: restBase,
|
|
588
|
-
BASICAUTH: rawCreds,
|
|
589
|
-
};
|
|
632
|
+
return buildEnv();
|
|
633
|
+
},
|
|
634
|
+
|
|
635
|
+
async descriptor(): Promise<MachineDescriptor> {
|
|
636
|
+
return buildDescriptor();
|
|
590
637
|
},
|
|
591
638
|
|
|
592
639
|
async queryLoad(): Promise<ChrLoadSample | null> {
|
|
@@ -1355,7 +1402,7 @@ export class QuickCHR {
|
|
|
1355
1402
|
|
|
1356
1403
|
const qemuArgs = await buildQemuArgs(launchConfig);
|
|
1357
1404
|
const wrapper = extractWrapper(resolvedNetworks);
|
|
1358
|
-
|
|
1405
|
+
let { pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper);
|
|
1359
1406
|
|
|
1360
1407
|
// Foreground (no provisioning): spawnQemu blocks until QEMU exits
|
|
1361
1408
|
if (!background && !hasProvisioning) {
|
|
@@ -1375,7 +1422,26 @@ export class QuickCHR {
|
|
|
1375
1422
|
const bootTimeout = defaultBootTimeout(arch, opts.installAllPackages, accel) + (opts.timeoutExtra ?? 0);
|
|
1376
1423
|
|
|
1377
1424
|
// Always wait for boot in background mode — the JSDoc promises "REST-ready".
|
|
1378
|
-
|
|
1425
|
+
let booted = await instance.waitForBoot(bootTimeout);
|
|
1426
|
+
|
|
1427
|
+
// Hardware-accelerated runners (nested-KVM on CI, HVF on virtualized macOS
|
|
1428
|
+
// runners) occasionally produce a single QEMU process that boots but never
|
|
1429
|
+
// reaches REST, while sibling boots on the same host succeed in ~30-45s. The
|
|
1430
|
+
// process is wedged, not merely slow — so a fresh respawn recovers reliably
|
|
1431
|
+
// where a longer timeout would only stretch the failure. Retry once, gated to
|
|
1432
|
+
// hardware accel (TCG boots are legitimately long; doubling buys nothing there).
|
|
1433
|
+
if (!booted && (accel === "kvm" || accel === "hvf")) {
|
|
1434
|
+
logger.warn(
|
|
1435
|
+
`CHR "${name}" did not reach REST within ${bootTimeout / 1000}s (accel=${accel}) — respawning QEMU once.`,
|
|
1436
|
+
);
|
|
1437
|
+
try { await stopQemu(pid); } catch { /* ignore */ }
|
|
1438
|
+
cleanupQemuSockets(machineDir);
|
|
1439
|
+
({ pid } = await spawnQemu(qemuArgs, machineDir, spawnInBackground, wrapper));
|
|
1440
|
+
state.pid = pid;
|
|
1441
|
+
saveMachine(state);
|
|
1442
|
+
booted = await instance.waitForBoot(bootTimeout);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1379
1445
|
if (!booted) {
|
|
1380
1446
|
let qemuLogTail = "";
|
|
1381
1447
|
try {
|
|
@@ -1536,7 +1602,7 @@ export class QuickCHR {
|
|
|
1536
1602
|
// Persist user info in state (password placeholder — real password in secret store)
|
|
1537
1603
|
const current = loadMachine(machineState.name);
|
|
1538
1604
|
if (current) {
|
|
1539
|
-
current.user = { name: result.user.name, password:
|
|
1605
|
+
current.user = { name: result.user.name, password: STORED_IN_SECRETS_PASSWORD };
|
|
1540
1606
|
saveMachine(current);
|
|
1541
1607
|
}
|
|
1542
1608
|
machineState.user = { name: result.user.name, password: result.user.password };
|
package/src/lib/types.ts
CHANGED
|
@@ -290,6 +290,38 @@ export interface ChrPorts {
|
|
|
290
290
|
[key: string]: number;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
export interface MachineDescriptor {
|
|
294
|
+
name: string;
|
|
295
|
+
status: "running";
|
|
296
|
+
version: string;
|
|
297
|
+
arch: Arch;
|
|
298
|
+
cpu: number;
|
|
299
|
+
mem: number;
|
|
300
|
+
pid: number | null;
|
|
301
|
+
ports: ChrPorts;
|
|
302
|
+
portMappings: Record<string, PortMapping>;
|
|
303
|
+
urls: {
|
|
304
|
+
http: string;
|
|
305
|
+
rest: string;
|
|
306
|
+
restBase: string;
|
|
307
|
+
https?: string;
|
|
308
|
+
ssh?: string;
|
|
309
|
+
api?: string;
|
|
310
|
+
apiSsl?: string;
|
|
311
|
+
winbox?: string;
|
|
312
|
+
};
|
|
313
|
+
auth: {
|
|
314
|
+
user: string;
|
|
315
|
+
password: string;
|
|
316
|
+
basic: string;
|
|
317
|
+
header: string;
|
|
318
|
+
};
|
|
319
|
+
env: Record<string, string>;
|
|
320
|
+
machineDir: string;
|
|
321
|
+
createdAt: string;
|
|
322
|
+
lastStartedAt: string | null;
|
|
323
|
+
}
|
|
324
|
+
|
|
293
325
|
/** Return value of ChrInstance.queryLoad(). */
|
|
294
326
|
export interface ChrLoadSample {
|
|
295
327
|
/** Host CPU percent across all virtual CPUs (0–100 × vCPU count). */
|
|
@@ -437,6 +469,8 @@ export interface ChrInstance {
|
|
|
437
469
|
* Bun.spawn(["bun", "my-script.ts"], { env: { ...process.env, ...await chr.subprocessEnv() } })
|
|
438
470
|
*/
|
|
439
471
|
subprocessEnv(): Promise<Record<string, string>>;
|
|
472
|
+
/** Build a stable machine-readable connection descriptor for a running instance. */
|
|
473
|
+
descriptor(): Promise<MachineDescriptor>;
|
|
440
474
|
|
|
441
475
|
/** Sample QEMU guest load from the monitor. Returns null when the monitor is
|
|
442
476
|
* unavailable (machine stopped or running in foreground mode). */
|