omakit 0.5.1 → 0.6.2
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/README.md +38 -45
- package/blocks/history.json +68 -0
- package/blocks/run/NOTICE +12 -0
- package/blocks/run/Run.qml +242 -0
- package/blocks/run/run-supervisor.py +522 -0
- package/blocks/store/NOTICE +12 -0
- package/blocks/store/Store.qml +157 -0
- package/blocks/store/store-helper.py +431 -0
- package/package.json +12 -5
- package/skills/omarchy-plugin-audit/SKILL.md +11 -5
- package/skills/omarchy-plugin-build/SKILL.md +164 -0
- package/skills/omarchy-plugin-check/SKILL.md +6 -3
- package/skills/omarchy-plugin-submit/SKILL.md +4 -1
- package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
- package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
- package/tests/fixtures/weigh/clean/Widget.qml +19 -0
- package/tests/fixtures/weigh/clean/manifest.json +9 -0
- package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
- package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
- package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
- package/tests/fixtures/weigh/poller/Service.qml +50 -0
- package/tests/fixtures/weigh/poller/manifest.json +9 -0
- package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
- package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
- package/tests/lab/run/harness/scenarios/controls.sh +6 -0
- package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
- package/tests/lab/run/harness/scenarios/forge.sh +11 -0
- package/tests/lab/run/harness/scenarios/holder.sh +5 -0
- package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
- package/tests/lab/run/harness/scenarios/stall.sh +5 -0
- package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
- package/tests/lab/run/harness/scenarios/tree.sh +7 -0
- package/tests/lab/run/harness/shell.qml +84 -0
- package/tests/lab/run/report.py +217 -0
- package/tests/lab/run/suite.sh +106 -0
- package/tests/lab/store/harness/shell.qml +73 -0
- package/tests/lab/store/report.py +133 -0
- package/tests/lab/store/suite.sh +109 -0
- package/tests/parity/corpus.mjs +8 -3
- package/tests/parity/run.mjs +4 -4
- package/tools/audit/audit.mjs +17 -6
- package/tools/audit/git.mjs +3 -3
- package/tools/audit/report.mjs +31 -5
- package/tools/blocks/add.mjs +138 -0
- package/tools/blocks/commit.json +5 -0
- package/tools/blocks/record-commit.mjs +77 -0
- package/tools/blocks/registry.mjs +191 -0
- package/tools/blocks/stamp.mjs +61 -0
- package/tools/inspect/contract.mjs +36 -5
- package/tools/inspect/helpers.mjs +217 -0
- package/tools/inspect/inspect.mjs +68 -3
- package/tools/inspect/patterns.mjs +18 -3
- package/tools/inspect/processes.mjs +38 -5
- package/tools/inspect/report.mjs +18 -2
- package/tools/inspect/writes.mjs +22 -4
- package/tools/lab/guest.mjs +155 -0
- package/tools/lab/harness.sh +119 -0
- package/tools/lab/host.mjs +177 -0
- package/tools/lab/inspect.mjs +240 -0
- package/tools/lab/omarchy.gpg +13 -0
- package/tools/lab/patches/omarchy-iso-test.patch +351 -0
- package/tools/lab/paths.mjs +173 -0
- package/tools/lab/pin.json +42 -0
- package/tools/lab/pin.mjs +64 -0
- package/tools/lab/prune.mjs +68 -0
- package/tools/lab/qemu.mjs +153 -0
- package/tools/lab/qmp-cli.mjs +21 -0
- package/tools/lab/report.mjs +183 -0
- package/tools/lab/run.mjs +344 -0
- package/tools/lab/setup.mjs +430 -0
- package/tools/lab/suites/run.sh +35 -0
- package/tools/lab/suites/store.sh +41 -0
- package/tools/lab/suites/weigh.sh +196 -0
- package/tools/lab/suites.mjs +142 -0
- package/tools/lab/verify.mjs +134 -0
- package/tools/marketplace/README.md +38 -1
- package/tools/marketplace/banner.mjs +23 -2
- package/tools/marketplace/cli.mjs +449 -146
- package/tools/marketplace/completion-check.mjs +27 -1
- package/tools/marketplace/completion.mjs +32 -4
- package/tools/marketplace/doctor.mjs +47 -9
- package/tools/marketplace/github.mjs +52 -6
- package/tools/marketplace/local-transport.mjs +1 -1
- package/tools/marketplace/options.mjs +16 -5
- package/tools/marketplace/outcome.mjs +244 -0
- package/tools/marketplace/pin.mjs +178 -33
- package/tools/marketplace/setup.mjs +16 -15
- package/tools/marketplace/tree.mjs +1 -1
- package/tools/marketplace/upgrade.mjs +5 -5
- package/tools/marketplace/usage.mjs +116 -72
- package/tools/subject/resolve.mjs +19 -6
- package/tools/weigh/audit.mjs +47 -16
- package/tools/weigh/config.mjs +105 -24
- package/tools/weigh/list.mjs +10 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// `omakit lab prove <suite>`: one suite in a disposable guest, its document
|
|
2
|
+
// written with the identity of the guest it ran on.
|
|
3
|
+
//
|
|
4
|
+
// The lifecycle, in order, and what each step guarantees:
|
|
5
|
+
//
|
|
6
|
+
// 1. Preflight, reading only: the base is ready and is the pin's, the
|
|
7
|
+
// host can run a guest, the suite's files are there, the disk has
|
|
8
|
+
// room for one overlay. A missing thing is named with its cost and
|
|
9
|
+
// the one command; nothing is fetched, nothing is created.
|
|
10
|
+
// 2. The lock: one directory, made atomically, holding the run id, this
|
|
11
|
+
// pid and the QMP socket. A lock whose QEMU still answers on its
|
|
12
|
+
// socket is held, whatever PID namespace it is in; one whose QEMU is
|
|
13
|
+
// gone and whose holder is gone is stale and is taken over.
|
|
14
|
+
// 3. Staging, private to the run: a qcow2 overlay backed by the base
|
|
15
|
+
// (the base opened read-only through the backing chain, and made
|
|
16
|
+
// 0444 at promotion), a copy of the base's firmware variables (the
|
|
17
|
+
// template is never opened writable: docs/history/2026-09-18-lab-
|
|
18
|
+
// inventory.md P15), the QMP socket, the serial log.
|
|
19
|
+
// 4. QEMU as a child of this process: no daemon, no host directory, no
|
|
20
|
+
// host socket, SSH forwarded on 127.0.0.1 and nothing else.
|
|
21
|
+
// 5. The session the way a person gets one: SSH answers, the password
|
|
22
|
+
// is typed at the greeter until a Hyprland owned by the guest user
|
|
23
|
+
// exists, the startup notifications are dismissed.
|
|
24
|
+
// 6. The identity, read from inside before anything else: the installed
|
|
25
|
+
// omarchy package, the kernel, whether the session runs from the
|
|
26
|
+
// package or a linked checkout. Skew is printed, never collapsed (P8).
|
|
27
|
+
// 7. The suite, through tools/lab/harness.sh, its output streamed to the
|
|
28
|
+
// run's host.log; its document read back and held to the suite's
|
|
29
|
+
// assertion; provenance added to the document (P18).
|
|
30
|
+
// 8. The guest powered off, QEMU ended (QMP, then the signal), the
|
|
31
|
+
// overlay measured and removed, the base and the template checked
|
|
32
|
+
// unchanged, the lock released, on the normal path, on failure, and
|
|
33
|
+
// on SIGINT and SIGTERM.
|
|
34
|
+
//
|
|
35
|
+
// Nothing here touches the host's own session: no hyprctl, no
|
|
36
|
+
// omarchy-shell, no shell.json on the host, and tests/unit/lab.test.mjs
|
|
37
|
+
// proves it over this file, the harness and the suites.
|
|
38
|
+
|
|
39
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
40
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
41
|
+
import { createServer } from "node:net"
|
|
42
|
+
import { join, resolve } from "node:path"
|
|
43
|
+
import { bytesBoth, labPin } from "./pin.mjs"
|
|
44
|
+
import { allocatedBytes, inLab, labDir, labLayout, readJson, removeFromLab, stampNow, writeJson } from "./paths.mjs"
|
|
45
|
+
import { freeBytesAt, guestCpus, probeRunHost } from "./host.mjs"
|
|
46
|
+
import { BASE_FILES, inspectBase, inspectLock } from "./inspect.mjs"
|
|
47
|
+
import { press, qemuArgs, qmpExecute, startQemu, typeText } from "./qemu.mjs"
|
|
48
|
+
import { clearStartupNotifications, establishSession, guestIdentity, SESSION_PREAMBLE, sleep, sshGuest, waitForSsh } from "./guest.mjs"
|
|
49
|
+
import { SUITES, suitePreflight } from "./suites.mjs"
|
|
50
|
+
|
|
51
|
+
export class LabError extends Error {
|
|
52
|
+
constructor(code, message, { remedy = null, missing = [] } = {}) {
|
|
53
|
+
super(message)
|
|
54
|
+
this.name = "LabError"
|
|
55
|
+
this.code = code
|
|
56
|
+
// An interrupt's one action is the lab's, not weigh's (whose
|
|
57
|
+
// `interrupted` remedy in cli.mjs speaks of shell.json).
|
|
58
|
+
this.remedy = remedy || (code === "interrupted" ? "the guest was ended and its overlay removed; run it again when you are ready" : null)
|
|
59
|
+
this.missing = missing
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The first free port on the loopback interface from 2222 up; the toolchain's fixed port is where two runs collided (P7). */
|
|
64
|
+
export async function freePort(start = 2222, { attempts = 50 } = {}) {
|
|
65
|
+
for (let port = start; port < start + attempts; port += 1) {
|
|
66
|
+
const free = await new Promise((resolve) => {
|
|
67
|
+
const server = createServer()
|
|
68
|
+
server.once("error", () => resolve(false))
|
|
69
|
+
server.listen({ host: "127.0.0.1", port }, () => server.close(() => resolve(true)))
|
|
70
|
+
})
|
|
71
|
+
if (free) return port
|
|
72
|
+
}
|
|
73
|
+
throw new LabError("no-port", `no free port between ${start} and ${start + attempts - 1} on 127.0.0.1`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Take the lab lock or refuse. `holder.json` inside it names the run; a
|
|
78
|
+
* QEMU answering on the recorded socket, or a live holder pid, means
|
|
79
|
+
* held. A stale lock is reported and taken.
|
|
80
|
+
*/
|
|
81
|
+
export async function acquireLock(layout, record) {
|
|
82
|
+
let stale = null
|
|
83
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
84
|
+
try {
|
|
85
|
+
mkdirSync(inLab(layout.cache, "lab.lock"), { recursive: false, mode: 0o700 })
|
|
86
|
+
writeJson(layout.cache, "lab.lock/holder.json", record)
|
|
87
|
+
return { taken: true, stale }
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error.code === "ENOENT") {
|
|
90
|
+
labDir(layout.cache)
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
if (error.code !== "EEXIST") throw error
|
|
94
|
+
const held = await inspectLock(layout)
|
|
95
|
+
if (held.alive) {
|
|
96
|
+
throw new LabError("lab-busy", `the lab is in use by run ${held.record?.runId || "unknown"} (pid ${held.record?.pid || "?"}${held.qemuAnswers ? ", its QEMU answers on QMP" : ""}); one run at a time`, { remedy: "wait for it, or `omakit lab inspect` to see it" })
|
|
97
|
+
}
|
|
98
|
+
stale = held.record
|
|
99
|
+
removeFromLab(layout.cache, "lab.lock")
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw new LabError("lab-busy", "the lock could not be taken")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function releaseLock(layout, runId) {
|
|
106
|
+
const holder = readJson(join(layout.lock, "holder.json"))
|
|
107
|
+
if (holder && holder.runId !== runId) return false
|
|
108
|
+
removeFromLab(layout.cache, "lab.lock")
|
|
109
|
+
return true
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function identityOf(file) {
|
|
113
|
+
const st = statSync(file)
|
|
114
|
+
return { bytes: st.size, mtimeMs: st.mtimeMs, ino: st.ino }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sameIdentity(a, b) {
|
|
118
|
+
return a.bytes === b.bytes && a.mtimeMs === b.mtimeMs && a.ino === b.ino
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Boot a guest from a base directory, log in, read its identity, hand it
|
|
123
|
+
* to `body`, and end it on every path. The staging directory is the
|
|
124
|
+
* caller's (under the lab cache) and is removed here; the overlay's
|
|
125
|
+
* allocated bytes are measured before removal, which is the per-run disk
|
|
126
|
+
* cost M14 records.
|
|
127
|
+
*
|
|
128
|
+
* @param {{ baseDir: string, stagingName: string, logFile: string, layout: object, pin: object, onPhase?: Function, signal?: AbortSignal }} options
|
|
129
|
+
* @param {(context: { guest: object, paths: object, identity: object, session: object }) => Promise<any>} body
|
|
130
|
+
*/
|
|
131
|
+
export async function withGuest({ baseDir, stagingName, logFile, layout, pin, onPhase = () => {}, signal }, body) {
|
|
132
|
+
const staging = labDir(layout.cache, "staging", stagingName)
|
|
133
|
+
const paths = { overlay: join(staging, "run.qcow2"), vars: join(staging, "vars.fd"), qmp: join(staging, "qmp.sock"), serial: join(staging, "serial.log"), pid: join(staging, "qemu.pid") }
|
|
134
|
+
const baseDisk = join(baseDir, BASE_FILES.disk)
|
|
135
|
+
const baseVars = join(baseDir, BASE_FILES.vars)
|
|
136
|
+
const before = { disk: identityOf(baseDisk), vars: identityOf(baseVars) }
|
|
137
|
+
const guest = { key: join(baseDir, BASE_FILES.key), port: null, user: pin.guest.user }
|
|
138
|
+
const out = { identity: null, session: null, overlayBytes: null, baseUnchanged: null, result: undefined, sshPort: null, cpus: guestCpus() }
|
|
139
|
+
let qemu = null
|
|
140
|
+
let stopped = false
|
|
141
|
+
const stopGuest = async () => {
|
|
142
|
+
if (stopped) return
|
|
143
|
+
stopped = true
|
|
144
|
+
if (!qemu || qemu.exitCode !== null) return
|
|
145
|
+
onPhase("powering the guest off")
|
|
146
|
+
let off = { status: 1 }
|
|
147
|
+
if (guest.port) off = sshGuest(guest, "sudo -S systemctl poweroff", { input: `${pin.guest.password}\n`, timeoutMs: 15000 })
|
|
148
|
+
if (off.status !== 0) await qmpExecute(paths.qmp, "system_powerdown").catch(() => {})
|
|
149
|
+
for (let waited = 0; waited < 20 && qemu.exitCode === null; waited += 1) await sleep(1000)
|
|
150
|
+
if (qemu.exitCode === null) {
|
|
151
|
+
await qmpExecute(paths.qmp, "quit").catch(() => {})
|
|
152
|
+
await sleep(1000)
|
|
153
|
+
}
|
|
154
|
+
if (qemu.exitCode === null) {
|
|
155
|
+
qemu.kill("SIGTERM")
|
|
156
|
+
await sleep(1000)
|
|
157
|
+
}
|
|
158
|
+
if (qemu.exitCode === null) qemu.kill("SIGKILL")
|
|
159
|
+
}
|
|
160
|
+
const end = async () => {
|
|
161
|
+
await stopGuest()
|
|
162
|
+
if (existsSync(paths.overlay)) out.overlayBytes = allocatedBytes(paths.overlay)
|
|
163
|
+
removeFromLab(layout.cache, `staging/${stagingName}`)
|
|
164
|
+
const after = { disk: identityOf(baseDisk), vars: identityOf(baseVars) }
|
|
165
|
+
out.baseUnchanged = sameIdentity(before.disk, after.disk) && sameIdentity(before.vars, after.vars)
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
onPhase("creating the overlay and the run's firmware variables")
|
|
169
|
+
const created = spawnSync("qemu-img", ["create", "-f", "qcow2", "-b", baseDisk, "-F", "qcow2", paths.overlay], { timeout: 60_000, encoding: "utf8" })
|
|
170
|
+
if (created.status !== 0) throw new LabError("overlay-failed", `qemu-img could not create the overlay: ${(created.stderr || "").trim()}`)
|
|
171
|
+
chmodSync(paths.overlay, 0o600)
|
|
172
|
+
// The base's variables hold the boot entries the install wrote, so a
|
|
173
|
+
// guest boots from them; the run gets its own copy and the template
|
|
174
|
+
// stays as it was (P15).
|
|
175
|
+
writeFileSync(inLab(layout.cache, `staging/${stagingName}/vars.fd`), readFileSync(baseVars), { mode: 0o600 })
|
|
176
|
+
guest.port = await freePort()
|
|
177
|
+
out.sshPort = guest.port
|
|
178
|
+
const args = qemuArgs({ overlay: paths.overlay, vars: paths.vars, memoryMiB: pin.guest.memoryMiB, cpus: out.cpus, sshPort: guest.port, qmpSocket: paths.qmp, serialLog: paths.serial, pidFile: paths.pid })
|
|
179
|
+
const logFd = openSync(logFile, "a", 0o600)
|
|
180
|
+
onPhase(`booting the guest (${pin.guest.memoryMiB} MiB, ${out.cpus} vCPUs, SSH on 127.0.0.1:${guest.port})`)
|
|
181
|
+
qemu = startQemu(args, { log: logFd })
|
|
182
|
+
closeSync(logFd)
|
|
183
|
+
await new Promise((resolvePromise, reject) => {
|
|
184
|
+
qemu.once("spawn", resolvePromise)
|
|
185
|
+
qemu.once("error", (error) => reject(new LabError("qemu-failed", `QEMU did not start: ${error.message}`)))
|
|
186
|
+
})
|
|
187
|
+
// Alive means our child has not exited: it is ours, so its exit code is
|
|
188
|
+
// the whole answer. Measured on the first verification boot: a QMP
|
|
189
|
+
// probe in the first second, before QEMU had created its socket, read
|
|
190
|
+
// as "the guest exited" and the base was never promoted.
|
|
191
|
+
const alive = async () => qemu.exitCode === null
|
|
192
|
+
if (signal?.aborted) throw new LabError("interrupted", "interrupted before the guest booted")
|
|
193
|
+
const sshSeconds = await waitForSsh(guest, { timeoutSeconds: 600, alive, onPhase })
|
|
194
|
+
// Five seconds for the greeter to draw after sshd answers, as the toolchain waited; a keystroke typed before it is lost.
|
|
195
|
+
await sleep(5000)
|
|
196
|
+
if (signal?.aborted) throw new LabError("interrupted", "interrupted while the guest booted")
|
|
197
|
+
const session = await establishSession({ guest, socket: paths.qmp, password: pin.guest.password, typeText, press, onPhase })
|
|
198
|
+
const notifications = await clearStartupNotifications(guest, { onPhase })
|
|
199
|
+
out.session = { sshSeconds, loginRounds: session.rounds, loginSeconds: session.seconds, notificationsCleared: notifications.cleared, notificationsReason: notifications.reason }
|
|
200
|
+
onPhase("reading the guest's identity")
|
|
201
|
+
out.identity = guestIdentity(guest)
|
|
202
|
+
if (signal?.aborted) throw new LabError("interrupted", "interrupted before the suite started")
|
|
203
|
+
out.result = await body({ guest, paths, identity: out.identity, session: out.session })
|
|
204
|
+
} finally {
|
|
205
|
+
await end()
|
|
206
|
+
}
|
|
207
|
+
return out
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Everything a run needs before a guest boots, or a LabError naming what
|
|
212
|
+
* is missing. Reads only.
|
|
213
|
+
*/
|
|
214
|
+
export function preflightRun({ suiteName, env = process.env, pin = labPin(), repoRoot, options = {} }) {
|
|
215
|
+
const suite = SUITES[suiteName]
|
|
216
|
+
if (!suite) throw new LabError("usage", `no suite named ${JSON.stringify(suiteName)}; the suites are ${Object.keys(SUITES).join(", ")}`, { remedy: `omakit lab prove <${Object.keys(SUITES).join("|")}>` })
|
|
217
|
+
const layout = labLayout(env)
|
|
218
|
+
const missing = []
|
|
219
|
+
const base = inspectBase(layout, pin)
|
|
220
|
+
if (base.state !== "ready") missing.push({ what: `a ready base (${base.state})`, cost: base.reason, command: "omakit lab setup" })
|
|
221
|
+
for (const line of probeRunHost({ pin, env })) if (line.state !== "ok") missing.push({ what: line.name, cost: line.reason, command: line.remedy })
|
|
222
|
+
missing.push(...suitePreflight(suite, { repoRoot, layout }))
|
|
223
|
+
const free = freeBytesAt(layout.cache)
|
|
224
|
+
if (free.bytes < pin.measured.overlayAfterRunBytes) missing.push({ what: "disk for one overlay", cost: `${bytesBoth(free.bytes)} free at ${free.path}; one run's overlay measured ${bytesBoth(pin.measured.overlayAfterRunBytes)} (M14)`, command: "omakit lab prune" })
|
|
225
|
+
if (missing.length) throw new LabError("lab-not-ready", `\`omakit lab prove ${suiteName}\` cannot start: ${missing.length} thing${missing.length === 1 ? " is" : "s are"} missing`, { missing, remedy: missing[0].command })
|
|
226
|
+
return { suite, layout, base, free }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Run one suite. `onPhase` gets the progress line, `onLine` every line
|
|
231
|
+
* the harness prints, `signal` aborts (SIGINT and SIGTERM in cli.mjs).
|
|
232
|
+
* Returns the run record; the suite's document is in the run directory,
|
|
233
|
+
* with its provenance.
|
|
234
|
+
*/
|
|
235
|
+
export async function runSuite({ suiteName, env = process.env, pin = labPin(), repoRoot, options = {}, onPhase = () => {}, onLine = () => {}, signal }) {
|
|
236
|
+
const { suite, layout, base } = preflightRun({ suiteName, env, pin, repoRoot, options })
|
|
237
|
+
const runId = `${stampNow()}-${suite.name}`
|
|
238
|
+
const runDir = labDir(layout.state, "runs", runId)
|
|
239
|
+
const startedAt = new Date()
|
|
240
|
+
const record = {
|
|
241
|
+
schema: 1,
|
|
242
|
+
runId,
|
|
243
|
+
suite: suite.name,
|
|
244
|
+
startedAt: startedAt.toISOString(),
|
|
245
|
+
pin: { release: pin.release.name, isoSha256: pin.release.sha256, expectedGuestVersion: pin.release.expectedGuestVersion },
|
|
246
|
+
base: { dir: base.dir, createdAt: base.manifest.createdAt, allocatedBytes: base.allocatedBytes, diskSha256: base.manifest.disk.sha256, varsSha256: base.manifest.vars.sha256, origin: base.manifest.build?.origin || null },
|
|
247
|
+
guest: null,
|
|
248
|
+
skew: null,
|
|
249
|
+
testedSource: null,
|
|
250
|
+
host: { kernel: spawnSync("uname", ["-r"], { timeout: 60_000, encoding: "utf8" }).stdout?.trim() || null, qemu: spawnSync("qemu-system-x86_64", ["--version"], { timeout: 60_000, encoding: "utf8" }).stdout?.split("\n")[0] || null, cpus: guestCpus(), memoryMiB: pin.guest.memoryMiB, sshPort: null },
|
|
251
|
+
session: null,
|
|
252
|
+
suiteStatus: null,
|
|
253
|
+
document: null,
|
|
254
|
+
assertion: null,
|
|
255
|
+
overlayBytes: null,
|
|
256
|
+
baseUnchanged: null,
|
|
257
|
+
durationMs: null,
|
|
258
|
+
ok: false,
|
|
259
|
+
runDir,
|
|
260
|
+
}
|
|
261
|
+
const save = () => writeJson(layout.state, `runs/${runId}/run.json`, record)
|
|
262
|
+
await acquireLock(layout, { runId, pid: process.pid, qmpSocket: join(layout.staging, `run-${runId}`, "qmp.sock"), startedAt: record.startedAt })
|
|
263
|
+
save()
|
|
264
|
+
let booted
|
|
265
|
+
try {
|
|
266
|
+
booted = await withGuest({ baseDir: base.dir, stagingName: `run-${runId}`, logFile: join(runDir, "qemu.log"), layout, pin, onPhase, signal }, async ({ guest, paths, identity }) => {
|
|
267
|
+
record.guest = identity
|
|
268
|
+
record.host.sshPort = guest.port
|
|
269
|
+
record.skew = identity.linked || identity.version !== pin.release.expectedGuestVersion
|
|
270
|
+
record.testedSource = identity.testedSource
|
|
271
|
+
save()
|
|
272
|
+
onLine({ state: "info", text: `guest omarchy ${identity.version || "unknown"} on ${identity.kernel || "?"}; tested source ${identity.testedSource}; skew ${record.skew}`, record })
|
|
273
|
+
onPhase(`running ${suite.title}`)
|
|
274
|
+
const status = await runHarness({ suite, runId, runDir, layout, guest, paths, repoRoot, pin, suiteArgs: suite.args(options, layout), timeoutSeconds: suite.timeoutSeconds, onLine, signal })
|
|
275
|
+
record.suiteStatus = status
|
|
276
|
+
// An interrupt during the suite is an interrupt, exit 130, the same as
|
|
277
|
+
// one before it: the guest is ended and the staging removed on the way out.
|
|
278
|
+
if (status === "interrupted") throw new LabError("interrupted", "interrupted during the suite")
|
|
279
|
+
const documentPath = join(runDir, suite.document)
|
|
280
|
+
const document = readJson(documentPath)
|
|
281
|
+
record.document = existsSync(documentPath) ? documentPath : null
|
|
282
|
+
record.assertion = document ? suite.assert(document) : { ok: false, reason: `the suite wrote no ${suite.document}` }
|
|
283
|
+
record.ok = status === 0 && record.assertion.ok
|
|
284
|
+
if (document) {
|
|
285
|
+
// Provenance into the document itself, in the shape the evidence
|
|
286
|
+
// files already carried by hand (`where`), plus the identity a
|
|
287
|
+
// reader can check: run id, guest version, pin, skew.
|
|
288
|
+
document.where = `the ${record.skew ? "linked" : "stock"} Omarchy ${identity.version || "?"} guest of omakit lab, run ${runId}`
|
|
289
|
+
document.lab = { runId, suite: suite.name, guest: identity, skew: record.skew, testedSource: identity.testedSource, pin: record.pin, base: { createdAt: record.base.createdAt, diskSha256: record.base.diskSha256, origin: record.base.origin }, host: record.host, startedAt: record.startedAt }
|
|
290
|
+
writeJson(layout.state, `runs/${runId}/${suite.document}`, document)
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
} catch (error) {
|
|
294
|
+
record.error = error?.message || String(error)
|
|
295
|
+
record.errorCode = error?.code || null
|
|
296
|
+
if (signal?.aborted) record.suiteStatus = "interrupted"
|
|
297
|
+
throw error
|
|
298
|
+
} finally {
|
|
299
|
+
if (booted) {
|
|
300
|
+
record.session = booted.session
|
|
301
|
+
record.host.sshPort = booted.sshPort
|
|
302
|
+
record.overlayBytes = booted.overlayBytes
|
|
303
|
+
record.baseUnchanged = booted.baseUnchanged
|
|
304
|
+
}
|
|
305
|
+
releaseLock(layout, runId)
|
|
306
|
+
record.durationMs = Date.now() - startedAt.getTime()
|
|
307
|
+
save()
|
|
308
|
+
}
|
|
309
|
+
return record
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The harness as a child, its lines streamed, its exit status the suite's. `null` for a signal, `"interrupted"` for an abort. */
|
|
313
|
+
function runHarness({ suite, runId, runDir, layout, guest, paths, repoRoot, pin, suiteArgs, timeoutSeconds, onLine, signal }) {
|
|
314
|
+
return new Promise((resolvePromise) => {
|
|
315
|
+
const hostLog = openSync(inLab(layout.state, `runs/${runId}/host.log`), "a", 0o600)
|
|
316
|
+
const child = spawn("bash", [join(resolve(repoRoot), "tools/lab/harness.sh"), suite.host, runDir, guest.key, String(guest.port), paths.qmp, resolve(repoRoot), SESSION_PREAMBLE, pin.guest.user, pin.guest.password, ...suiteArgs], { stdio: ["ignore", "pipe", "pipe"] })
|
|
317
|
+
let buffer = ""
|
|
318
|
+
const feed = (chunk, stream) => {
|
|
319
|
+
writeFileSync(hostLog, chunk)
|
|
320
|
+
buffer += chunk.toString("utf8")
|
|
321
|
+
let index
|
|
322
|
+
while ((index = buffer.indexOf("\n")) >= 0) {
|
|
323
|
+
const line = buffer.slice(0, index)
|
|
324
|
+
buffer = buffer.slice(index + 1)
|
|
325
|
+
onLine({ state: /^not ok/.test(line) ? "fail" : /^ok - /.test(line) ? "pass" : /^==> /.test(line) ? "info" : "prose", text: line, stream })
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
child.stdout.on("data", (chunk) => feed(chunk, "stdout"))
|
|
329
|
+
child.stderr.on("data", (chunk) => feed(chunk, "stderr"))
|
|
330
|
+
const timer = setTimeout(() => {
|
|
331
|
+
onLine({ state: "fail", text: `the suite ran past ${timeoutSeconds} s and was stopped` })
|
|
332
|
+
child.kill("SIGKILL")
|
|
333
|
+
}, timeoutSeconds * 1000)
|
|
334
|
+
const abort = () => child.kill("SIGTERM")
|
|
335
|
+
signal?.addEventListener("abort", abort, { once: true })
|
|
336
|
+
child.on("close", (code, sig) => {
|
|
337
|
+
clearTimeout(timer)
|
|
338
|
+
signal?.removeEventListener("abort", abort)
|
|
339
|
+
if (buffer) feed("\n", "stdout")
|
|
340
|
+
closeSync(hostLog)
|
|
341
|
+
resolvePromise(signal?.aborted ? "interrupted" : sig ? null : code)
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
}
|