omakit 0.5.0 → 0.6.1
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 +39 -32
- 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 +7 -4
- 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 +37 -6
- package/tools/inspect/functions.mjs +236 -12
- package/tools/inspect/helpers.mjs +217 -0
- package/tools/inspect/inspect.mjs +71 -5
- package/tools/inspect/measure-functions.mjs +12 -3
- package/tools/inspect/patterns.mjs +35 -18
- package/tools/inspect/processes.mjs +38 -5
- package/tools/inspect/report.mjs +33 -6
- 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,430 @@
|
|
|
1
|
+
// `omakit lab setup`: the only path that may fetch bytes, and the only one
|
|
2
|
+
// that builds a base.
|
|
3
|
+
//
|
|
4
|
+
// One explicit consent, stating the exact size and the destination,
|
|
5
|
+
// before the first byte; `--yes` is that consent in the command itself
|
|
6
|
+
// for automation, and a pipe without it refuses. The plan is computed
|
|
7
|
+
// first and reads only; the disclosure prints it whole; then, in order:
|
|
8
|
+
//
|
|
9
|
+
// 1. The ISO, into $XDG_CACHE_HOME/omakit/lab/downloads/<sha256>/: a
|
|
10
|
+
// literal GET of the pinned URL to a .part file, resumed by byte
|
|
11
|
+
// range, or a copy of a local file named with --from. Never
|
|
12
|
+
// "latest": the URL, the byte count, the digest and the signer are
|
|
13
|
+
// the pin's.
|
|
14
|
+
// 2. Verification, of a downloaded file and of a file already there
|
|
15
|
+
// alike: the byte count, the SHA-256 against the pin, the published
|
|
16
|
+
// sidecar against the pin, the detached signature against the
|
|
17
|
+
// packaged key at the pinned fingerprint. A mismatch fails closed:
|
|
18
|
+
// the file is left as .part, nothing is recorded, nothing boots it.
|
|
19
|
+
// 3. The base, built by the pinned omarchy-iso toolchain's console
|
|
20
|
+
// driver from the verified ISO, in a staging directory under the lab
|
|
21
|
+
// (the driver derives its base directory from its own location, so
|
|
22
|
+
// a copy of the one script into staging keeps it out of the
|
|
23
|
+
// checkout: docs/history/2026-09-18-lab-inventory.md P6, P19), then
|
|
24
|
+
// booted once by the lab's own driver to read the installed
|
|
25
|
+
// omarchy package, hashed, made read-only, given its manifest, and
|
|
26
|
+
// promoted with one rename.
|
|
27
|
+
//
|
|
28
|
+
// Never fetched: the toolchain. It is a git checkout at a pinned commit
|
|
29
|
+
// with the packaged patch applied, and the one command that makes it is
|
|
30
|
+
// printed; `--toolchain <dir>` records where it is, after hashing its
|
|
31
|
+
// harness against the pin.
|
|
32
|
+
|
|
33
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
34
|
+
import { chmodSync, closeSync, createWriteStream, existsSync, fsyncSync, openSync, readdirSync, statSync, writeFileSync } from "node:fs"
|
|
35
|
+
import { join, resolve } from "node:path"
|
|
36
|
+
import { getStream, GitHubError } from "../marketplace/github.mjs"
|
|
37
|
+
import { LAB_DIR, bytesBoth, durationWords, labPin } from "./pin.mjs"
|
|
38
|
+
import { allocatedBytes, copyIntoLab, inLab, labDir, labLayout, moveIntoLab, readJson, removeFromLab, stampNow, writeJson } from "./paths.mjs"
|
|
39
|
+
import { BUILD_COMMANDS, VERIFY_COMMANDS, freeBytesAt, probeCommands, probeRunHost } from "./host.mjs"
|
|
40
|
+
import { judgeRelease, packagedKey, sha256File } from "./verify.mjs"
|
|
41
|
+
import { BASE_FILES, downloadDir, inspectBase, inspectDownload, inspectToolchain, toolchainCommand } from "./inspect.mjs"
|
|
42
|
+
import { LabError, acquireLock, freePort, releaseLock, withGuest } from "./run.mjs"
|
|
43
|
+
import { WEIGH_LISTED, listedPlugin } from "./suites.mjs"
|
|
44
|
+
import { marketplacePinDir } from "../marketplace/pin.mjs"
|
|
45
|
+
|
|
46
|
+
/** Record the toolchain checkout after hashing its harness against the pin; the only write `--toolchain` makes. */
|
|
47
|
+
export function recordToolchain({ dir, env = process.env, pin = labPin() }) {
|
|
48
|
+
const layout = labLayout(env)
|
|
49
|
+
const checkout = resolve(dir)
|
|
50
|
+
const harness = join(checkout, pin.toolchain.harness)
|
|
51
|
+
if (!existsSync(harness)) throw new LabError("toolchain-missing", `${harness} is not there; --toolchain names a checkout of ${pin.toolchain.repository}`, { remedy: toolchainCommand(pin, checkout) })
|
|
52
|
+
const { sha256 } = sha256File(harness)
|
|
53
|
+
if (sha256 !== pin.toolchain.patchedHarnessSha256) {
|
|
54
|
+
const unpatched = sha256 === pin.toolchain.upstreamHarnessSha256
|
|
55
|
+
throw new LabError("toolchain-mismatch", unpatched
|
|
56
|
+
? `${harness} is the upstream harness at ${pin.toolchain.commit.slice(0, 12)} without the lab patch`
|
|
57
|
+
: `${harness} hashes to ${sha256}; the pinned patched harness is ${pin.toolchain.patchedHarnessSha256} (omarchy-iso ${pin.toolchain.commit.slice(0, 12)} with ${pin.toolchain.patch})`,
|
|
58
|
+
{ remedy: unpatched ? `git -C ${checkout} apply ${join(LAB_DIR, pin.toolchain.patch)} && omakit lab setup --toolchain ${checkout}` : toolchainCommand(pin, checkout) })
|
|
59
|
+
}
|
|
60
|
+
labDir(layout.cache)
|
|
61
|
+
writeJson(layout.cache, "toolchain.json", { dir: checkout, harnessSha256: sha256, commit: pin.toolchain.commit, recordedAt: new Date().toISOString() })
|
|
62
|
+
return { dir: checkout, sha256 }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The plan, reading only: what setup would do on this host, with every
|
|
67
|
+
* size and the destination. `steps` is what the consent covers.
|
|
68
|
+
*/
|
|
69
|
+
export function planSetup({ env = process.env, pin = labPin(), from = null, plugins = false, repoRoot } = {}) {
|
|
70
|
+
const layout = labLayout(env)
|
|
71
|
+
const download = inspectDownload(layout, pin)
|
|
72
|
+
const base = inspectBase(layout, pin)
|
|
73
|
+
const toolchain = inspectToolchain(layout, pin)
|
|
74
|
+
const free = freeBytesAt(layout.cache)
|
|
75
|
+
const steps = []
|
|
76
|
+
const blockers = []
|
|
77
|
+
for (const line of probeCommands(VERIFY_COMMANDS, { env })) if (line.state !== "ok") blockers.push({ what: line.name, cost: line.reason, command: line.remedy })
|
|
78
|
+
if (!download.verified) {
|
|
79
|
+
if (from) {
|
|
80
|
+
const source = resolve(from)
|
|
81
|
+
if (!existsSync(source)) blockers.push({ what: `the file named by --from`, cost: `${source} is not there`, command: "omakit lab setup --from <path to omarchy-4.0.3.iso>" })
|
|
82
|
+
else steps.push({ kind: "import", from: source, bytes: statSync(source).size, to: join(download.dir, pin.release.fileName), sidecars: { checksum: existsSync(`${source}.sha256`) ? `${source}.sha256` : null, signature: existsSync(`${source}.sig`) ? `${source}.sig` : null } })
|
|
83
|
+
} else if (download.present) {
|
|
84
|
+
steps.push({ kind: "verify", file: download.iso, bytes: download.bytes })
|
|
85
|
+
} else {
|
|
86
|
+
steps.push({ kind: "download", url: pin.release.isoUrl, bytes: pin.release.bytes, resumeFrom: download.partial || 0, to: join(download.dir, pin.release.fileName) })
|
|
87
|
+
}
|
|
88
|
+
steps.push({ kind: "sidecars", bytes: 203, urls: [pin.release.checksumUrl, pin.release.signatureUrl], to: download.dir })
|
|
89
|
+
}
|
|
90
|
+
if (base.state !== "ready") {
|
|
91
|
+
for (const line of probeRunHost({ pin, env })) if (line.state !== "ok") blockers.push({ what: line.name, cost: line.reason, command: line.remedy })
|
|
92
|
+
// A base the toolchain built but a verification boot never promoted
|
|
93
|
+
// (an interrupted or failed setup) is verified and promoted, not
|
|
94
|
+
// rebuilt: six minutes and six gigabytes are not spent twice.
|
|
95
|
+
const staged = stagedBases(layout)
|
|
96
|
+
if (staged.length) {
|
|
97
|
+
steps.push({ kind: "promote", staged: staged.at(-1), to: layout.base, replacing: base.state === "missing" ? null : base })
|
|
98
|
+
} else {
|
|
99
|
+
for (const line of probeCommands(BUILD_COMMANDS, { env })) if (line.state !== "ok") blockers.push({ what: line.name, cost: line.reason, command: line.remedy })
|
|
100
|
+
if (toolchain.state !== "ready") blockers.push({ what: "the toolchain", cost: toolchain.reason, command: toolchain.state === "unpatched" ? `git -C ${toolchain.dir} apply ${join(LAB_DIR, pin.toolchain.patch)} && omakit lab setup --toolchain ${toolchain.dir}` : toolchainCommand(pin, join(layout.cache, "toolchain/omarchy-iso")) })
|
|
101
|
+
steps.push({ kind: "build", toolchain: toolchain.harness, to: layout.base, replacing: base.state === "missing" ? null : base, bytes: pin.measured.baseDirectoryBytes, milliseconds: pin.measured.buildMilliseconds })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (plugins) {
|
|
105
|
+
const pinDir = marketplacePinDir(repoRoot)
|
|
106
|
+
const wanted = []
|
|
107
|
+
for (const id of WEIGH_LISTED) {
|
|
108
|
+
const listing = listedPlugin(pinDir, id)
|
|
109
|
+
if (!listing.ok) blockers.push({ what: `${id} in the pinned catalog`, cost: listing.reason, command: "omakit pin" })
|
|
110
|
+
else wanted.push({ id, repo: listing.repo, commit: listing.commit, to: join(layout.plugins, id) })
|
|
111
|
+
}
|
|
112
|
+
if (wanted.length) steps.push({ kind: "plugins", plugins: wanted, to: layout.plugins, bytes: pin.measured.pluginsBytes || null })
|
|
113
|
+
}
|
|
114
|
+
const needed = steps.reduce((sum, step) => sum + (step.kind === "download" ? step.bytes - (step.resumeFrom || 0) : step.kind === "import" ? step.bytes : step.kind === "build" ? step.bytes : 0), 0)
|
|
115
|
+
if (steps.length && free.bytes < needed) blockers.push({ what: "disk", cost: `${bytesBoth(free.bytes)} free at ${free.path}; this setup needs ${bytesBoth(needed)}`, command: "omakit lab prune" })
|
|
116
|
+
return { layout, pin, download, base, toolchain, free, steps, blockers, needed, afterBytes: pin.measured.preparedLabBytes }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Staged bases awaiting verification: `staging/base-*` with the four files and no manifest, oldest first. */
|
|
120
|
+
export function stagedBases(layout) {
|
|
121
|
+
if (!existsSync(layout.staging)) return []
|
|
122
|
+
return readdirSync(layout.staging).filter((name) => /^base-\d{8}-\d{6}$/.test(name)).sort()
|
|
123
|
+
.map((name) => join(layout.staging, name))
|
|
124
|
+
.filter((dir) => [BASE_FILES.disk, BASE_FILES.vars, BASE_FILES.key, BASE_FILES.publicKey].every((file) => existsSync(join(dir, file))) && !existsSync(join(dir, BASE_FILES.manifest)))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The lines of the disclosure, exactly as the contract in docs/LAB.md shows them; the consent question follows them. */
|
|
128
|
+
export function disclosureLines(plan) {
|
|
129
|
+
const { pin } = plan
|
|
130
|
+
const lines = []
|
|
131
|
+
const download = plan.steps.find((step) => step.kind === "download")
|
|
132
|
+
const imported = plan.steps.find((step) => step.kind === "import")
|
|
133
|
+
const build = plan.steps.find((step) => step.kind === "build")
|
|
134
|
+
const plugins = plan.steps.find((step) => step.kind === "plugins")
|
|
135
|
+
lines.push(["Omarchy", `release ${pin.release.name}; installed guest expected ${pin.release.expectedGuestVersion}`])
|
|
136
|
+
if (download) lines.push(["download", `${bytesBoth(download.bytes - download.resumeFrom)}${download.resumeFrom ? ` (resuming at ${download.resumeFrom.toLocaleString("en-US")} of ${download.bytes.toLocaleString("en-US")} B)` : ""}`], ["from", download.url])
|
|
137
|
+
else if (imported) lines.push(["download", "0 B: the file named by --from is copied and verified"], ["from", imported.from])
|
|
138
|
+
else lines.push(["download", "0 B: the ISO on disk is verified"])
|
|
139
|
+
lines.push(["verify", `pinned SHA-256 ${pin.release.sha256} and the Omarchy signature ${pin.release.signingFingerprint}`])
|
|
140
|
+
lines.push(["store", plan.layout.cache])
|
|
141
|
+
const promote = plan.steps.find((step) => step.kind === "promote")
|
|
142
|
+
if (build) {
|
|
143
|
+
lines.push(["build", `${durationWords(build.milliseconds)} on the reference host (M14); download excluded`])
|
|
144
|
+
if (build.replacing) lines.push(["replacing", `the ${build.replacing.state} base there (${bytesBoth(build.replacing.allocatedBytes)}), removed after the new one verifies`])
|
|
145
|
+
}
|
|
146
|
+
if (promote) {
|
|
147
|
+
lines.push(["build", `none: the base the toolchain built at ${promote.staged} is booted once, verified and promoted`])
|
|
148
|
+
if (promote.replacing) lines.push(["replacing", `the ${promote.replacing.state} base there (${bytesBoth(promote.replacing.allocatedBytes)}), removed after the staged one verifies`])
|
|
149
|
+
}
|
|
150
|
+
if (plugins) lines.push(["plugins", `${plugins.plugins.length} listed plugins at their validated commits, shallow, into ${plugins.to}`])
|
|
151
|
+
lines.push(["afterwards", "verified ISO, one immutable base, and manifests"])
|
|
152
|
+
lines.push(["on disk", `${bytesBoth(plan.afterBytes)} (M14), before evidence; ${bytesBoth(plan.free.bytes)} free now`])
|
|
153
|
+
return lines
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The one question. */
|
|
157
|
+
export const CONSENT_QUESTION = "Acquire and build this verified base now?"
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A resumable literal GET of the pinned object to `<to>.part`. The byte
|
|
161
|
+
* count is checked against the pin as it arrives and at the end; a
|
|
162
|
+
* server that answers 200 to a range request restarts the file.
|
|
163
|
+
*/
|
|
164
|
+
export async function downloadRelease({ url, to, bytes, onProgress = () => {}, signal, fetchStream = getStream }) {
|
|
165
|
+
const part = `${to}.part`
|
|
166
|
+
let have = existsSync(part) ? statSync(part).size : 0
|
|
167
|
+
if (have > bytes) {
|
|
168
|
+
removeFromLab(resolve(to, ".."), `${to.split("/").at(-1)}.part`)
|
|
169
|
+
have = 0
|
|
170
|
+
}
|
|
171
|
+
let response
|
|
172
|
+
try {
|
|
173
|
+
response = await fetchStream(url, { rangeFrom: have, signal })
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error instanceof GitHubError) throw new LabError(error.code, error.message, { remedy: "check the network, then run `omakit lab setup` again; the download resumes" })
|
|
176
|
+
throw error
|
|
177
|
+
}
|
|
178
|
+
const append = response.status === 206 && have > 0
|
|
179
|
+
const expected = append ? bytes - have : bytes
|
|
180
|
+
const length = Number(response.headers.get("content-length"))
|
|
181
|
+
if (Number.isFinite(length) && length > 0 && length !== expected) {
|
|
182
|
+
throw new LabError("size-mismatch", `${url} announces ${length.toLocaleString("en-US")} B where the pin expects ${expected.toLocaleString("en-US")}; the object at the versioned URL is not the pinned release`)
|
|
183
|
+
}
|
|
184
|
+
const out = createWriteStream(part, { flags: append ? "a" : "w", mode: 0o600 })
|
|
185
|
+
let written = append ? have : 0
|
|
186
|
+
const started = Date.now()
|
|
187
|
+
try {
|
|
188
|
+
for await (const chunk of response.body) {
|
|
189
|
+
if (signal?.aborted) throw new LabError("interrupted", "interrupted; the partial download stays and resumes next time")
|
|
190
|
+
written += chunk.length
|
|
191
|
+
if (written > bytes) throw new LabError("size-mismatch", `${url} sent more than the pinned ${bytes.toLocaleString("en-US")} B`)
|
|
192
|
+
if (!out.write(chunk)) await new Promise((resolvePromise) => out.once("drain", resolvePromise))
|
|
193
|
+
onProgress(written, bytes, Date.now() - started)
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
await new Promise((resolvePromise) => out.end(resolvePromise))
|
|
197
|
+
}
|
|
198
|
+
if (written !== bytes) throw new LabError("size-mismatch", `${url} ended at ${written.toLocaleString("en-US")} of the pinned ${bytes.toLocaleString("en-US")} B; run setup again to resume`)
|
|
199
|
+
return { part, bytes: written, milliseconds: Date.now() - started }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The two sidecars, small, to the download directory; the signature is required, the checksum compared. */
|
|
203
|
+
async function fetchSidecars({ pin, dir, layout, fetchStream = getStream, signal }) {
|
|
204
|
+
for (const [url, name] of [[pin.release.checksumUrl, `${pin.release.fileName}.sha256`], [pin.release.signatureUrl, `${pin.release.fileName}.sig`]]) {
|
|
205
|
+
const response = await fetchStream(url, { signal })
|
|
206
|
+
const body = Buffer.from(await response.arrayBuffer())
|
|
207
|
+
if (body.length > 4096) throw new LabError("sidecar-mismatch", `${url} answered ${body.length} B; a sidecar is a few dozen`)
|
|
208
|
+
writeFileSync(inLab(layout.cache, `downloads/${pin.release.sha256}/${name}`), body, { mode: 0o600 })
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Verify the file at `<to>.part` (or an import copy) and promote it to `to`, recording the verification. */
|
|
213
|
+
async function verifyAndPromote({ candidate, to, pin, layout, stagingRoot, onProgress }) {
|
|
214
|
+
const dir = resolve(to, "..")
|
|
215
|
+
const signature = `${to}.sig`
|
|
216
|
+
const checksum = `${to}.sha256`
|
|
217
|
+
const judged = judgeRelease({ file: candidate, signature: existsSync(signature) ? signature : null, checksum: existsSync(checksum) ? checksum : null, pin, stagingRoot, onProgress })
|
|
218
|
+
if (!judged.ok) {
|
|
219
|
+
throw new LabError("iso-mismatch", `the file at ${candidate} is not the pinned release: ${judged.reason}; it stays where it is and nothing will boot it`, { remedy: "remove it with `omakit lab prune`, then `omakit lab setup` again" })
|
|
220
|
+
}
|
|
221
|
+
if (judged.sidecarMatch === false) throw new LabError("sidecar-mismatch", `the published checksum sidecar names ${judged.sidecarSha256}, the pin ${pin.release.sha256}: the object at the versioned URL was replaced, and this file, which matches the pin, is not what the site publishes now`, { remedy: "a pin update is a reviewed change; docs/LAB.md says how" })
|
|
222
|
+
if (candidate !== to) await moveIntoLab(layout.cache, candidate, `downloads/${pin.release.sha256}/${pin.release.fileName}`)
|
|
223
|
+
chmodSync(to, 0o444)
|
|
224
|
+
const st = statSync(to)
|
|
225
|
+
writeJson(layout.cache, `downloads/${pin.release.sha256}/verified.json`, { schema: 1, sha256: judged.sha256, bytes: st.size, mtimeMs: st.mtimeMs, fingerprint: judged.signature.fingerprint, signer: judged.signature.detail, sidecarSha256: judged.sidecarSha256, verifiedAt: new Date().toISOString() })
|
|
226
|
+
return { file: to, judged, dir }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Build the base with the toolchain's console driver, verify it by
|
|
231
|
+
* booting it, and promote it. The driver runs from a copy under staging,
|
|
232
|
+
* so its `test-runs/` lands there and never in the checkout.
|
|
233
|
+
*/
|
|
234
|
+
async function buildBase({ pin, layout, toolchainHarness, iso, onPhase, onLine, signal, repoRoot }) {
|
|
235
|
+
const buildId = `build-${stampNow()}`
|
|
236
|
+
const stagingDir = labDir(layout.cache, "staging", buildId)
|
|
237
|
+
labDir(layout.cache, "staging", buildId, "bin")
|
|
238
|
+
const harnessCopy = await copyIntoLab(layout.cache, toolchainHarness, `staging/${buildId}/bin/omarchy-iso-test`)
|
|
239
|
+
const { sha256 } = sha256File(harnessCopy)
|
|
240
|
+
if (sha256 !== pin.toolchain.patchedHarnessSha256) throw new LabError("toolchain-mismatch", `the harness changed between the check and the copy (${sha256})`)
|
|
241
|
+
chmodSync(harnessCopy, 0o700)
|
|
242
|
+
const port = await freePort(2322)
|
|
243
|
+
const started = Date.now()
|
|
244
|
+
onPhase(`building the base with the toolchain (about ${durationWords(pin.measured.buildMilliseconds)} on the reference host)`)
|
|
245
|
+
const log = openSync(inLab(layout.cache, `staging/${buildId}/build.log`), "a", 0o600)
|
|
246
|
+
const status = await new Promise((resolvePromise) => {
|
|
247
|
+
const child = spawn("bash", [harnessCopy, iso, "--install-only", "--memory", String(pin.guest.memoryMiB), "--port", String(port), "--no-preview"], { cwd: stagingDir, stdio: ["ignore", "pipe", "pipe"] })
|
|
248
|
+
let buffer = ""
|
|
249
|
+
const feed = (chunk) => {
|
|
250
|
+
writeFileSync(log, chunk)
|
|
251
|
+
buffer += chunk.toString("utf8")
|
|
252
|
+
let index
|
|
253
|
+
while ((index = buffer.indexOf("\n")) >= 0) {
|
|
254
|
+
// The toolchain paints its log lines; the escape is stripped so the
|
|
255
|
+
// lab's stdout stays its own.
|
|
256
|
+
const line = buffer.slice(0, index).replace(/\[[0-9;]*m/g, "")
|
|
257
|
+
buffer = buffer.slice(index + 1)
|
|
258
|
+
if (line.trim()) onLine({ state: /^==> /.test(line) ? "info" : "prose", text: line })
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
child.stdout.on("data", feed)
|
|
262
|
+
child.stderr.on("data", feed)
|
|
263
|
+
const abort = () => child.kill("SIGTERM")
|
|
264
|
+
signal?.addEventListener("abort", abort, { once: true })
|
|
265
|
+
child.on("close", (code, sig) => {
|
|
266
|
+
signal?.removeEventListener("abort", abort)
|
|
267
|
+
closeSync(log)
|
|
268
|
+
resolvePromise(sig ? null : code)
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
const buildMilliseconds = Date.now() - started
|
|
272
|
+
if (status !== 0) throw new LabError(signal?.aborted ? "interrupted" : "build-failed", `the toolchain's install driver exited ${status === null ? "on a signal" : status} after ${durationWords(buildMilliseconds)}; its log is ${join(stagingDir, "build.log")}, and nothing was promoted`, { remedy: "read the log, then `omakit lab prune` to remove the staging directory and `omakit lab setup` to build again" })
|
|
273
|
+
const built = join(stagingDir, "test-runs", pin.release.fileName.replace(/\.iso$/, ""))
|
|
274
|
+
for (const name of ["base.qcow2", "OVMF_VARS.4m.fd", "id_ed25519", "id_ed25519.pub"]) {
|
|
275
|
+
if (!existsSync(join(built, name))) throw new LabError("build-failed", `the driver exited 0 but ${join(built, name)} is not there`)
|
|
276
|
+
}
|
|
277
|
+
const baseId = `base-${stampNow()}`
|
|
278
|
+
const staged = labDir(layout.cache, "staging", baseId)
|
|
279
|
+
await moveIntoLab(layout.cache, join(built, "base.qcow2"), `staging/${baseId}/${BASE_FILES.disk}`)
|
|
280
|
+
await moveIntoLab(layout.cache, join(built, "OVMF_VARS.4m.fd"), `staging/${baseId}/${BASE_FILES.vars}`)
|
|
281
|
+
await moveIntoLab(layout.cache, join(built, "id_ed25519"), `staging/${baseId}/${BASE_FILES.key}`)
|
|
282
|
+
await moveIntoLab(layout.cache, join(built, "id_ed25519.pub"), `staging/${baseId}/${BASE_FILES.publicKey}`)
|
|
283
|
+
const runs = existsSync(join(built, "runs")) ? readdirSync(join(built, "runs")).sort() : []
|
|
284
|
+
if (runs.length) await moveIntoLab(layout.cache, join(built, "runs", runs.at(-1)), `staging/${baseId}/build`)
|
|
285
|
+
removeFromLab(layout.cache, `staging/${buildId}`)
|
|
286
|
+
// The measured duration travels with the staged base, so a promotion
|
|
287
|
+
// after an interrupted setup still records it.
|
|
288
|
+
writeJson(layout.cache, `staging/${baseId}/build/lab-build.json`, { buildMilliseconds, harnessSha256: sha256, iso, builtAt: new Date().toISOString() })
|
|
289
|
+
return { staged, baseId, buildMilliseconds, harnessSha256: sha256, origin: `built ${new Date().toISOString()} by the pinned toolchain harness ${sha256.slice(0, 12)} from the verified ISO` }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Boot the staged base once, read the installed package, hash, seal, manifest, promote. */
|
|
293
|
+
async function verifyAndPromoteBase({ staged, baseId, pin, layout, buildMilliseconds, origin, harnessSha256, iso, onPhase, signal }) {
|
|
294
|
+
onPhase("booting the staged base once to read the installed omarchy package")
|
|
295
|
+
const booted = await withGuest({ baseDir: staged, stagingName: `${baseId}-verify`, logFile: join(staged, "verify-boot.log"), layout, pin, onPhase, signal }, async () => null)
|
|
296
|
+
const version = booted.identity?.version
|
|
297
|
+
if (version !== pin.release.expectedGuestVersion) {
|
|
298
|
+
throw new LabError("guest-mismatch", `the staged base runs omarchy ${version || "unknown"} (${booted.identity?.omarchyPackage || "no package read"}); the pin expects ${pin.release.expectedGuestVersion}; the base stays in staging and is not promoted`, { remedy: "`omakit lab prune` removes it; a pin update is a reviewed change, docs/LAB.md says how" })
|
|
299
|
+
}
|
|
300
|
+
if (booted.identity.linked) throw new LabError("guest-mismatch", `the staged base is dev-linked to ${booted.identity.omarchyPath}; a base runs the installed package`)
|
|
301
|
+
onPhase("hashing the base disk and the firmware template")
|
|
302
|
+
const disk = join(staged, BASE_FILES.disk)
|
|
303
|
+
const vars = join(staged, BASE_FILES.vars)
|
|
304
|
+
const diskHash = sha256File(disk)
|
|
305
|
+
const varsHash = sha256File(vars)
|
|
306
|
+
const info = spawnSync("qemu-img", ["info", "--output=json", disk], { timeout: 60_000, encoding: "utf8" })
|
|
307
|
+
const qemuInfo = info.status === 0 ? JSON.parse(info.stdout) : null
|
|
308
|
+
chmodSync(disk, 0o444)
|
|
309
|
+
chmodSync(vars, 0o444)
|
|
310
|
+
const manifest = {
|
|
311
|
+
schema: 1,
|
|
312
|
+
state: "ready",
|
|
313
|
+
release: { name: pin.release.name, sha256: pin.release.sha256, bytes: pin.release.bytes, isoUrl: pin.release.isoUrl, embeddedBuild: pin.release.embeddedBuild, volume: pin.release.volume, iso },
|
|
314
|
+
signature: { fingerprint: pin.release.signingFingerprint },
|
|
315
|
+
guest: { version, omarchyPackage: booted.identity.omarchyPackage, kernel: booted.identity.kernel, hostname: booted.identity.hostname, readBy: "pacman -Q omarchy over SSH in the verification boot" },
|
|
316
|
+
disk: { file: BASE_FILES.disk, bytes: diskHash.bytes, sha256: diskHash.sha256, virtualBytes: qemuInfo?.["virtual-size"] ?? null, allocatedBytes: allocatedBytes(disk), format: qemuInfo?.format ?? null },
|
|
317
|
+
vars: { file: BASE_FILES.vars, bytes: varsHash.bytes, sha256: varsHash.sha256 },
|
|
318
|
+
key: { file: BASE_FILES.key },
|
|
319
|
+
build: { milliseconds: buildMilliseconds, harnessSha256, toolchainCommit: pin.toolchain.commit, origin, verificationBoot: booted.session },
|
|
320
|
+
createdAt: new Date().toISOString(),
|
|
321
|
+
createdBy: { omakit: readJson(join(LAB_DIR, "../../package.json"))?.version || null, 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 },
|
|
322
|
+
}
|
|
323
|
+
writeJson(layout.cache, `staging/${baseId}/${BASE_FILES.manifest}`, manifest)
|
|
324
|
+
const fd = openSync(staged, "r")
|
|
325
|
+
try {
|
|
326
|
+
fsyncSync(fd)
|
|
327
|
+
} finally {
|
|
328
|
+
closeSync(fd)
|
|
329
|
+
}
|
|
330
|
+
onPhase("promoting the base")
|
|
331
|
+
const superseded = existsSync(layout.base) ? `staging/superseded-${baseId}` : null
|
|
332
|
+
if (superseded) await moveIntoLab(layout.cache, layout.base, superseded)
|
|
333
|
+
await moveIntoLab(layout.cache, staged, "base")
|
|
334
|
+
if (superseded) removeFromLab(layout.cache, superseded)
|
|
335
|
+
return { manifest, dir: layout.base, supersededRemoved: Boolean(superseded) }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The listed plugins at their validated commits, shallow, into the lab's plugin cache; idempotent per commit. */
|
|
339
|
+
function fetchPlugins({ plugins, layout, onPhase }) {
|
|
340
|
+
const fetched = []
|
|
341
|
+
for (const plugin of plugins) {
|
|
342
|
+
const dir = join(layout.plugins, plugin.id)
|
|
343
|
+
const head = existsSync(join(dir, ".git")) ? spawnSync("git", ["-C", dir, "rev-parse", "HEAD"], { timeout: 60_000, encoding: "utf8" }).stdout.trim() : null
|
|
344
|
+
const clean = head === plugin.commit && spawnSync("git", ["-C", dir, "status", "--porcelain"], { timeout: 60_000, encoding: "utf8" }).stdout.trim() === ""
|
|
345
|
+
if (clean) {
|
|
346
|
+
fetched.push({ id: plugin.id, commit: plugin.commit, state: "present" })
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
onPhase(`fetching ${plugin.id} at ${plugin.commit.slice(0, 12)}`)
|
|
350
|
+
removeFromLab(layout.cache, `plugins/${plugin.id}`)
|
|
351
|
+
labDir(layout.cache, "plugins", plugin.id)
|
|
352
|
+
const steps = [["init", "-q", dir], ["-C", dir, "remote", "add", "origin", plugin.repo], ["-C", dir, "fetch", "-q", "--depth", "1", "origin", plugin.commit], ["-C", dir, "checkout", "-q", "--detach", plugin.commit]]
|
|
353
|
+
for (const args of steps) {
|
|
354
|
+
const result = spawnSync("git", args, { timeout: 300_000, encoding: "utf8" })
|
|
355
|
+
if (result.status !== 0) throw new LabError("plugin-fetch-failed", `could not fetch ${plugin.id} at ${plugin.commit} from ${plugin.repo}: ${(result.stderr || "").trim()}`)
|
|
356
|
+
}
|
|
357
|
+
fetched.push({ id: plugin.id, commit: plugin.commit, state: "fetched", bytes: allocatedBytes(dir) })
|
|
358
|
+
}
|
|
359
|
+
return fetched
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Execute a plan after consent. `consented` is the caller's statement
|
|
364
|
+
* that the disclosure was shown and answered yes, or that --yes was
|
|
365
|
+
* passed; without it, a plan with steps refuses.
|
|
366
|
+
*/
|
|
367
|
+
export async function setupLab({ plan, consented, onPhase = () => {}, onLine = () => {}, onProgress = () => {}, signal, fetchStream = getStream, repoRoot }) {
|
|
368
|
+
const { pin, layout } = plan
|
|
369
|
+
if (plan.blockers.length) throw new LabError("lab-not-ready", `\`omakit lab setup\` cannot start: ${plan.blockers.length} thing${plan.blockers.length === 1 ? " is" : "s are"} missing`, { missing: plan.blockers, remedy: plan.blockers[0].command })
|
|
370
|
+
if (!plan.steps.length) return { done: [], nothingToDo: true }
|
|
371
|
+
if (!consented) throw new LabError("not-confirmed", "not confirmed; nothing was fetched, nothing was built", { remedy: "omakit lab setup --yes" })
|
|
372
|
+
packagedKey(pin)
|
|
373
|
+
const done = []
|
|
374
|
+
await acquireLock(layout, { runId: `setup-${stampNow()}`, pid: process.pid, qmpSocket: null, startedAt: new Date().toISOString() })
|
|
375
|
+
const lockId = readJson(join(layout.lock, "holder.json"))?.runId
|
|
376
|
+
try {
|
|
377
|
+
labDir(layout.cache, "downloads", pin.release.sha256)
|
|
378
|
+
labDir(layout.cache, "staging")
|
|
379
|
+
const target = join(downloadDir(layout, pin), pin.release.fileName)
|
|
380
|
+
for (const step of plan.steps) {
|
|
381
|
+
if (signal?.aborted) throw new LabError("interrupted", "interrupted; what was verified stays verified, what was not stays unverified")
|
|
382
|
+
if (step.kind === "download") {
|
|
383
|
+
onPhase(`downloading ${bytesBoth(step.bytes - step.resumeFrom)} from ${step.url}`)
|
|
384
|
+
const got = await downloadRelease({ url: step.url, to: target, bytes: step.bytes, onProgress, signal, fetchStream })
|
|
385
|
+
done.push({ kind: "download", bytes: got.bytes, milliseconds: got.milliseconds, to: got.part })
|
|
386
|
+
} else if (step.kind === "import") {
|
|
387
|
+
onPhase(`copying ${bytesBoth(step.bytes)} from ${step.from}`)
|
|
388
|
+
const started = Date.now()
|
|
389
|
+
removeFromLab(layout.cache, `downloads/${pin.release.sha256}/${pin.release.fileName}.part`)
|
|
390
|
+
await copyIntoLab(layout.cache, step.from, `downloads/${pin.release.sha256}/${pin.release.fileName}.part`)
|
|
391
|
+
for (const [side, name] of [[step.sidecars.checksum, `${pin.release.fileName}.sha256`], [step.sidecars.signature, `${pin.release.fileName}.sig`]]) {
|
|
392
|
+
if (side && !existsSync(join(downloadDir(layout, pin), name))) await copyIntoLab(layout.cache, side, `downloads/${pin.release.sha256}/${name}`)
|
|
393
|
+
}
|
|
394
|
+
done.push({ kind: "import", bytes: step.bytes, milliseconds: Date.now() - started, from: step.from })
|
|
395
|
+
} else if (step.kind === "sidecars") {
|
|
396
|
+
const have = existsSync(`${target}.sig`) && existsSync(`${target}.sha256`)
|
|
397
|
+
if (!have) {
|
|
398
|
+
onPhase("fetching the checksum and signature sidecars")
|
|
399
|
+
await fetchSidecars({ pin, dir: step.to, layout, fetchStream, signal })
|
|
400
|
+
done.push({ kind: "sidecars", to: step.to })
|
|
401
|
+
}
|
|
402
|
+
// Verification runs here, once the sidecars are in place, for a
|
|
403
|
+
// download, an import and a file already on disk alike.
|
|
404
|
+
onPhase("verifying the ISO: byte count, SHA-256, sidecar, signature")
|
|
405
|
+
const candidate = existsSync(`${target}.part`) ? `${target}.part` : target
|
|
406
|
+
const verified = await verifyAndPromote({ candidate, to: target, pin, layout, stagingRoot: layout.staging, onProgress })
|
|
407
|
+
done.push({ kind: "verify", file: verified.file, reason: verified.judged.reason })
|
|
408
|
+
onLine({ state: "pass", text: `verified ${verified.file}: ${verified.judged.reason}` })
|
|
409
|
+
} else if (step.kind === "build") {
|
|
410
|
+
const built = await buildBase({ pin, layout, toolchainHarness: step.toolchain, iso: target, onPhase, onLine, signal, repoRoot })
|
|
411
|
+
onLine({ state: "pass", text: `the toolchain built a base in ${durationWords(built.buildMilliseconds)}` })
|
|
412
|
+
const promoted = await verifyAndPromoteBase({ ...built, pin, layout, iso: target, onPhase, signal })
|
|
413
|
+
done.push({ kind: "build", milliseconds: built.buildMilliseconds, dir: promoted.dir, guest: promoted.manifest.guest, diskSha256: promoted.manifest.disk.sha256, allocatedBytes: promoted.manifest.disk.allocatedBytes })
|
|
414
|
+
onLine({ state: "pass", text: `promoted ${promoted.dir}: guest omarchy ${promoted.manifest.guest.version}, disk sha256 ${promoted.manifest.disk.sha256}` })
|
|
415
|
+
} else if (step.kind === "promote") {
|
|
416
|
+
const baseId = step.staged.split("/").at(-1)
|
|
417
|
+
const buildRecord = readJson(join(step.staged, "build", "lab-build.json"))
|
|
418
|
+
const promoted = await verifyAndPromoteBase({ staged: step.staged, baseId, pin, layout, buildMilliseconds: buildRecord?.buildMilliseconds ?? null, origin: `built ${buildRecord?.builtAt || "earlier"} by the pinned toolchain harness ${(buildRecord?.harnessSha256 || pin.toolchain.patchedHarnessSha256).slice(0, 12)} from the verified ISO, promoted ${new Date().toISOString()} after a verification boot`, harnessSha256: buildRecord?.harnessSha256 || pin.toolchain.patchedHarnessSha256, iso: target, onPhase, signal })
|
|
419
|
+
done.push({ kind: "build", milliseconds: buildRecord?.buildMilliseconds ?? 0, dir: promoted.dir, guest: promoted.manifest.guest, diskSha256: promoted.manifest.disk.sha256, allocatedBytes: promoted.manifest.disk.allocatedBytes })
|
|
420
|
+
onLine({ state: "pass", text: `promoted ${promoted.dir}: guest omarchy ${promoted.manifest.guest.version}, disk sha256 ${promoted.manifest.disk.sha256}` })
|
|
421
|
+
} else if (step.kind === "plugins") {
|
|
422
|
+
const fetched = fetchPlugins({ plugins: step.plugins, layout, onPhase })
|
|
423
|
+
done.push({ kind: "plugins", fetched })
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
} finally {
|
|
427
|
+
if (lockId) releaseLock(layout, lockId)
|
|
428
|
+
}
|
|
429
|
+
return { done, nothingToDo: false }
|
|
430
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/bash
|
|
2
|
+
# The Run block's suite in the guest: tests/lab/run/suite.sh, the same file
|
|
3
|
+
# that runs on the desktop, staged with the block files and run inside the
|
|
4
|
+
# graphical session, its document copied back. What it proves that the
|
|
5
|
+
# desktop run cannot: the block runs on a stock install, where
|
|
6
|
+
# /usr/bin/python3 is a dependency of desktop packages and not an Omarchy
|
|
7
|
+
# choice (docs/BLOCKS_SPIKE.md), in the session of a shell nobody
|
|
8
|
+
# customised. The guest's shell is never restarted and never loads the
|
|
9
|
+
# harness: every scenario is its own quickshell instance.
|
|
10
|
+
#
|
|
11
|
+
# Runs through tools/lab/harness.sh, which defines the helpers used here.
|
|
12
|
+
|
|
13
|
+
omakit_lab_suite() {
|
|
14
|
+
local runs="${1:-1}" status
|
|
15
|
+
|
|
16
|
+
log "Checking the stock guest for the block's plumbing"
|
|
17
|
+
ssh_guest "test -x /usr/bin/python3 && test -x /usr/bin/quickshell && test -x /usr/bin/setsid && test -x /usr/bin/kill" || { echo "the guest lacks python3, quickshell, setsid or kill under /usr/bin" >&2; return 1; }
|
|
18
|
+
ssh_guest "/usr/bin/python3 --version; pacman -Q python quickshell util-linux; pacman -Qi python | grep '^Required By'" > "$RUN_DIR/guest-plumbing.txt" 2>&1 || true
|
|
19
|
+
log "$(head -n 4 "$RUN_DIR/guest-plumbing.txt" | tr '\n' ';')"
|
|
20
|
+
|
|
21
|
+
log "Staging the suite and the block files in the guest"
|
|
22
|
+
ssh_guest "rm -rf /tmp/omakit-runlab-src" || return 1
|
|
23
|
+
stage_paths "$OMAKIT_DIR" /tmp/omakit-runlab-src tests/lab/run blocks/run || return 1
|
|
24
|
+
|
|
25
|
+
log "Running the Run lab suite in the guest session ($runs run per scenario)"
|
|
26
|
+
guest_job runlab 900 "bash /tmp/omakit-runlab-src/tests/lab/run/suite.sh /tmp/omakit-runlab $runs" || return 1
|
|
27
|
+
guest_file /tmp/omakit-runlab/runlab.json runlab.json || true
|
|
28
|
+
ssh_guest "cd /tmp/omakit-runlab && tar -cf - runs" > "$RUN_DIR/runlab-runs.tar" 2>/dev/null || true
|
|
29
|
+
status=$(guest_job_status runlab)
|
|
30
|
+
[[ $status == 0 ]] || { echo "the Run lab suite exited $status in the guest" >&2; tail -n 30 "$RUN_DIR/runlab.log" >&2; return 1; }
|
|
31
|
+
guest_shell_healthy || return 1
|
|
32
|
+
capture_console "success-runlab-01-done"
|
|
33
|
+
grep '^ok - ' "$RUN_DIR/runlab.log"
|
|
34
|
+
printf 'ok - the Run block ran its lab scenarios on the stock guest, the shell untouched; document at %s\n' "$RUN_DIR/runlab.json"
|
|
35
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/bash
|
|
2
|
+
# The Store block's suite in the guest: tests/lab/store/suite.sh staged
|
|
3
|
+
# with both blocks' files and run inside the graphical session, its
|
|
4
|
+
# document copied back. Every hostile condition the contract refuses is
|
|
5
|
+
# planted in a throwaway HOME; the foreign-owner scenario needs one file
|
|
6
|
+
# owned by root, and the guest, being disposable, gets a sudoers drop-in
|
|
7
|
+
# for exactly `/usr/bin/chown`. That drop-in is written into the run's
|
|
8
|
+
# overlay, which dies with the run: the base is never touched, and the
|
|
9
|
+
# document records that the owner was simulated. The guest password is the
|
|
10
|
+
# harness's argument, never a literal here (docs/history/2026-09-18-lab-
|
|
11
|
+
# inventory.md P11).
|
|
12
|
+
#
|
|
13
|
+
# Runs through tools/lab/harness.sh, which defines the helpers used here.
|
|
14
|
+
|
|
15
|
+
omakit_lab_suite() {
|
|
16
|
+
local status
|
|
17
|
+
|
|
18
|
+
log "Checking the stock guest for the block's plumbing"
|
|
19
|
+
ssh_guest "test -x /usr/bin/python3 && test -x /usr/bin/quickshell && test -x /usr/bin/setsid && test -x /usr/bin/kill" || { echo "the guest lacks python3, quickshell, setsid or kill under /usr/bin" >&2; return 1; }
|
|
20
|
+
ssh_guest "/usr/bin/python3 --version; pacman -Q python quickshell util-linux; pacman -Qi python | grep '^Required By'" > "$RUN_DIR/guest-plumbing.txt" 2>&1 || true
|
|
21
|
+
log "$(head -n 4 "$RUN_DIR/guest-plumbing.txt" | tr '\n' ';')"
|
|
22
|
+
|
|
23
|
+
log "Staging the suite and the block files in the guest"
|
|
24
|
+
ssh_guest "rm -rf /tmp/omakit-storelab-src" || return 1
|
|
25
|
+
stage_paths "$OMAKIT_DIR" /tmp/omakit-storelab-src tests/lab/store blocks/run blocks/store || return 1
|
|
26
|
+
|
|
27
|
+
log "Allowing chown without a password in the guest's overlay, for the foreign-owner scenario"
|
|
28
|
+
printf '%s\n' "$GUEST_PASSWORD" | ssh_guest "sudo -S sh -c \"printf 'omarchy ALL=(ALL) NOPASSWD: /usr/bin/chown\\n' > /etc/sudoers.d/storelab && chmod 0440 /etc/sudoers.d/storelab\"" || return 1
|
|
29
|
+
ssh_guest "sudo -n /usr/bin/chown --version >/dev/null" || { echo "chown is not passwordless in the guest" >&2; return 1; }
|
|
30
|
+
|
|
31
|
+
log "Running the Store lab suite in the guest session"
|
|
32
|
+
guest_job storelab 900 "bash /tmp/omakit-storelab-src/tests/lab/store/suite.sh /tmp/omakit-storelab" || return 1
|
|
33
|
+
guest_file /tmp/omakit-storelab/storelab.json storelab.json || true
|
|
34
|
+
ssh_guest "cd /tmp/omakit-storelab && tar -cf - runs" > "$RUN_DIR/storelab-runs.tar" 2>/dev/null || true
|
|
35
|
+
status=$(guest_job_status storelab)
|
|
36
|
+
[[ $status == 0 ]] || { echo "the Store lab suite exited $status in the guest" >&2; tail -n 30 "$RUN_DIR/storelab.log" >&2; return 1; }
|
|
37
|
+
guest_shell_healthy || return 1
|
|
38
|
+
capture_console "success-storelab-01-done"
|
|
39
|
+
grep '^ok - ' "$RUN_DIR/storelab.log"
|
|
40
|
+
printf 'ok - the Store block ran its lab scenarios on the stock guest, the foreign owner simulated with chown, the shell untouched; document at %s\n' "$RUN_DIR/storelab.json"
|
|
41
|
+
}
|