omakit 0.6.8 → 0.6.10
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/package.json +1 -1
- package/tools/blocks/commit.json +2 -2
- package/tools/lab/guest.mjs +1 -1
- package/tools/lab/host.mjs +2 -2
- package/tools/lab/inspect.mjs +170 -31
- package/tools/lab/paths.mjs +1 -1
- package/tools/lab/pin.json +10 -14
- package/tools/lab/pin.mjs +78 -13
- package/tools/lab/prune.mjs +10 -7
- package/tools/lab/release.mjs +178 -0
- package/tools/lab/report.mjs +78 -18
- package/tools/lab/run.mjs +27 -9
- package/tools/lab/setup.mjs +301 -68
- package/tools/lab/verify.mjs +149 -20
- package/tools/marketplace/README.md +11 -10
- package/tools/marketplace/cli.mjs +36 -8
- package/tools/marketplace/doctor.mjs +128 -23
- package/tools/marketplace/github.mjs +11 -5
- package/tools/marketplace/options.mjs +1 -1
- package/tools/marketplace/pin.mjs +79 -14
- package/tools/marketplace/preflight.mjs +2 -2
- package/tools/marketplace/registry.mjs +31 -49
- package/tools/marketplace/review-cost.mjs +2 -3
- package/tools/marketplace/submit.mjs +2 -2
- package/tools/marketplace/usage.mjs +19 -16
- package/tools/marketplace/watch.mjs +2 -3
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// The newest Omarchy release the lab can prepare, found each time it is
|
|
2
|
+
// asked: the release list on GitHub, newest first by version, and for each
|
|
3
|
+
// the three objects the ISO host must publish whole (the ISO, its .sha256
|
|
4
|
+
// and its detached .sig). Only GETs, through github.mjs's one call site;
|
|
5
|
+
// the ISO's size is read from a one-byte range request, so no image is
|
|
6
|
+
// fetched here.
|
|
7
|
+
//
|
|
8
|
+
// What it will not do, each measured against a way the lab went wrong or
|
|
9
|
+
// could:
|
|
10
|
+
// - Stay on an old release unnoticed. 4.0.4 was published on 2026-09-15
|
|
11
|
+
// and the lab, pinned to 4.0.3 on 2026-09-18, still offered 4.0.3 on
|
|
12
|
+
// 2026-09-23; nothing looked.
|
|
13
|
+
// - Fall back past a release that could not be read. A newer release is
|
|
14
|
+
// skipped only when the host answers that an object is not there (404:
|
|
15
|
+
// tagged, not yet published, as 4.0.0 has no .sha256). Everything else
|
|
16
|
+
// stops the search with its reason: a timeout, a 5xx, a checksum that
|
|
17
|
+
// does not read, an ISO whose size is not announced. A fallback on any
|
|
18
|
+
// of those is the stale lab again.
|
|
19
|
+
// - Take a release older than the floor, whatever the list says.
|
|
20
|
+
// - Accept a signer it does not ship. The .sig names its issuers; one with
|
|
21
|
+
// none of them the pinned fingerprint stops the search before the ISO is
|
|
22
|
+
// asked for, rather than falling back to an older release the old key
|
|
23
|
+
// signed. A .sig with the pinned key among its signers (a rotation that
|
|
24
|
+
// signs with both) is the pinned key's.
|
|
25
|
+
|
|
26
|
+
import { getJson, getStream } from "../marketplace/github.mjs"
|
|
27
|
+
import { compareVersions, labPin, releaseOf, VERSION } from "./pin.mjs"
|
|
28
|
+
import { signatureIssuers, signedByPinned } from "./verify.mjs"
|
|
29
|
+
import { LabError } from "./run.mjs"
|
|
30
|
+
|
|
31
|
+
/** A sidecar is a few dozen bytes; anything past this is not one. */
|
|
32
|
+
const SIDECAR_LIMIT = 4096
|
|
33
|
+
|
|
34
|
+
/** A GET that answers 404 is "not published"; every other failure is thrown as it came. */
|
|
35
|
+
async function read(fetchStream, url, signal) {
|
|
36
|
+
try {
|
|
37
|
+
return await fetchStream(url, { signal })
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error?.code === "not-found" || error?.status === 404) return null
|
|
40
|
+
throw error
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A sidecar's body, refused past SIDECAR_LIMIT as announced or as it
|
|
46
|
+
* streams, never buffered whole first; `code` names the refusal for the
|
|
47
|
+
* caller (the search's release-unavailable, setup's sidecar-mismatch).
|
|
48
|
+
*/
|
|
49
|
+
export async function smallBody(response, url, code = "release-unavailable") {
|
|
50
|
+
const tooBig = (bytes) => new LabError(code, `${url} answered ${bytes.toLocaleString("en-US")} B or more; a sidecar is a few dozen`)
|
|
51
|
+
const announced = Number(response.headers?.get?.("content-length"))
|
|
52
|
+
if (Number.isFinite(announced) && announced > SIDECAR_LIMIT) {
|
|
53
|
+
try {
|
|
54
|
+
await response.body?.cancel()
|
|
55
|
+
} catch {
|
|
56
|
+
// Nothing left to cancel.
|
|
57
|
+
}
|
|
58
|
+
throw tooBig(announced)
|
|
59
|
+
}
|
|
60
|
+
if (!response.body?.getReader) {
|
|
61
|
+
const body = Buffer.from(await response.arrayBuffer())
|
|
62
|
+
if (body.length > SIDECAR_LIMIT) throw tooBig(body.length)
|
|
63
|
+
return body
|
|
64
|
+
}
|
|
65
|
+
const reader = response.body.getReader()
|
|
66
|
+
const chunks = []
|
|
67
|
+
let size = 0
|
|
68
|
+
for (;;) {
|
|
69
|
+
const { done, value } = await reader.read()
|
|
70
|
+
if (done) break
|
|
71
|
+
size += value.length
|
|
72
|
+
if (size > SIDECAR_LIMIT) {
|
|
73
|
+
await reader.cancel()
|
|
74
|
+
throw tooBig(size)
|
|
75
|
+
}
|
|
76
|
+
chunks.push(Buffer.from(value))
|
|
77
|
+
}
|
|
78
|
+
return Buffer.concat(chunks)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The ISO's full size from a ranged answer (`Content-Range: bytes 0-0/<size>`), or from Content-Length when the host sent the whole object. */
|
|
82
|
+
function announcedSize(response) {
|
|
83
|
+
const range = String(response.headers.get("content-range") || "").match(/^bytes\s+\d+-\d+\/(\d+)$/)
|
|
84
|
+
if (response.status === 206) return range ? Number(range[1]) : NaN
|
|
85
|
+
return Number(response.headers.get("content-length"))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The published digest from a `.sha256` sidecar: `<64 hex> <file name>`,
|
|
90
|
+
* the name the ISO's own. Null for anything else.
|
|
91
|
+
*/
|
|
92
|
+
export function sidecarDigest(text, fileName) {
|
|
93
|
+
const match = String(text).trim().match(/^([0-9a-fA-F]{64})\s+\*?(\S+)\s*$/)
|
|
94
|
+
if (!match) return null
|
|
95
|
+
if (match[2].split("/").at(-1) !== fileName) return null
|
|
96
|
+
return match[1].toLowerCase()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One release checked on the ISO host, in the order that stops earliest:
|
|
101
|
+
* the checksum, the signature (whose signer is checked before the ISO is
|
|
102
|
+
* asked for, so a release omakit would refuse is never requested), then
|
|
103
|
+
* the ISO's announced size. `{ ok: true, release, signers }` when the three
|
|
104
|
+
* are there and read as they should; `{ ok: false, reason }` when the host
|
|
105
|
+
* says one is not published. A signer other than the pinned one throws
|
|
106
|
+
* `signer-changed`; any other failure throws as it came.
|
|
107
|
+
*/
|
|
108
|
+
export async function probeRelease({ pin, name, fetchStream = getStream, signal }) {
|
|
109
|
+
const shape = releaseOf(pin, { name, bytes: 0, sha256: "0".repeat(64) })
|
|
110
|
+
const checksum = await read(fetchStream, shape.checksumUrl, signal)
|
|
111
|
+
if (!checksum) return { ok: false, reason: `${shape.fileName}.sha256 is not published` }
|
|
112
|
+
const sha256 = sidecarDigest((await smallBody(checksum, shape.checksumUrl)).toString("utf8"), shape.fileName)
|
|
113
|
+
if (!sha256) throw new LabError("release-unavailable", `${shape.checksumUrl} is published but does not name ${shape.fileName} with a SHA-256; the lab will not fall back past it to an older release`)
|
|
114
|
+
const signature = await read(fetchStream, shape.signatureUrl, signal)
|
|
115
|
+
if (!signature) return { ok: false, reason: `${shape.fileName}.sig is not published` }
|
|
116
|
+
const signers = signatureIssuers(await smallBody(signature, shape.signatureUrl))
|
|
117
|
+
if (signedByPinned(signers, pin.releases.signingFingerprint) === false) {
|
|
118
|
+
const named = signers.map((signer) => signer.fingerprint || `key ${signer.keyId}`).join(" and ")
|
|
119
|
+
throw new LabError("signer-changed", `Omarchy ${name} is signed by ${named}, not the key omakit ships (${pin.releases.signingFingerprint}); omakit will not prepare it, and will not fall back to an older release the old key signed`, { remedy: "omakit upgrade: a newer omakit carries the new key once it is verified" })
|
|
120
|
+
}
|
|
121
|
+
// One byte of the image, for its size in Content-Range; the rest is never asked for.
|
|
122
|
+
const iso = await read((url, options) => fetchStream(url, { ...options, rangeEnd: 0 }), shape.isoUrl, signal)
|
|
123
|
+
if (!iso) return { ok: false, reason: `${shape.fileName} is not published` }
|
|
124
|
+
const bytes = announcedSize(iso)
|
|
125
|
+
try {
|
|
126
|
+
await iso.body?.cancel()
|
|
127
|
+
} catch {
|
|
128
|
+
// A body that is already closed has nothing left to cancel.
|
|
129
|
+
}
|
|
130
|
+
if (!Number.isSafeInteger(bytes) || bytes <= 0) throw new LabError("release-unavailable", `${shape.isoUrl} is published but its size is not announced; the lab will not fall back past it to an older release`)
|
|
131
|
+
return { ok: true, release: releaseOf(pin, { name, bytes, sha256 }), signers }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The newest release at or above the floor that is published whole.
|
|
136
|
+
* Returns `{ release, tag, publishedAt, checkedAt, list, skipped }`, where
|
|
137
|
+
* `skipped` names each newer release passed over and why. Throws a
|
|
138
|
+
* LabError: `release-unavailable` when none of the newest `candidates`
|
|
139
|
+
* is published whole, `signer-changed` when the newest is signed by
|
|
140
|
+
* another key; a network error comes through with its own code.
|
|
141
|
+
*/
|
|
142
|
+
export async function findNewestRelease({ pin = labPin(), readJson = getJson, fetchStream = getStream, signal, now = () => new Date() } = {}) {
|
|
143
|
+
const listed = await readJson(pin.releases.list, { signal })
|
|
144
|
+
if (!Array.isArray(listed)) throw new LabError("release-unavailable", `${pin.releases.list} did not answer with a list of releases`)
|
|
145
|
+
const seen = new Set()
|
|
146
|
+
const releases = listed
|
|
147
|
+
.filter((entry) => entry && typeof entry === "object" && !entry.draft && !entry.prerelease && typeof entry.tag_name === "string")
|
|
148
|
+
.map((entry) => ({ tag: entry.tag_name, name: entry.tag_name.replace(/^v/, ""), publishedAt: entry.published_at || null }))
|
|
149
|
+
.filter((entry) => VERSION.test(entry.name) && !seen.has(entry.name) && seen.add(entry.name))
|
|
150
|
+
.sort((a, b) => compareVersions(b.name, a.name))
|
|
151
|
+
const eligible = releases.filter((entry) => compareVersions(entry.name, pin.releases.floor) >= 0)
|
|
152
|
+
if (!eligible.length) {
|
|
153
|
+
throw new LabError("release-unavailable", `${pin.releases.list} lists no release at or above ${pin.releases.floor}${releases.length ? `; the newest listed is ${releases[0].name}` : ""}`)
|
|
154
|
+
}
|
|
155
|
+
const skipped = []
|
|
156
|
+
for (const entry of eligible.slice(0, pin.releases.candidates)) {
|
|
157
|
+
const probed = await probeRelease({ pin, name: entry.name, fetchStream, signal })
|
|
158
|
+
if (!probed.ok) {
|
|
159
|
+
skipped.push({ name: entry.name, reason: probed.reason })
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
return { release: probed.release, tag: entry.tag, publishedAt: entry.publishedAt, checkedAt: now().toISOString(), list: pin.releases.list, skipped }
|
|
163
|
+
}
|
|
164
|
+
throw new LabError("release-unavailable", `none of the newest ${Math.min(eligible.length, pin.releases.candidates)} releases is published whole: ${skipped.map((entry) => `${entry.name} (${entry.reason})`).join("; ")}`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The newest-release check as a value, never a throw: what inspect, doctor
|
|
169
|
+
* and a run print. `{ checked: true, ... }` with the release, or
|
|
170
|
+
* `{ checked: false, code, reason }`.
|
|
171
|
+
*/
|
|
172
|
+
export async function checkNewestRelease(options = {}) {
|
|
173
|
+
try {
|
|
174
|
+
return { checked: true, ...(await findNewestRelease(options)) }
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return { checked: false, code: error?.code || "error", reason: error?.message || String(error) }
|
|
177
|
+
}
|
|
178
|
+
}
|
package/tools/lab/report.mjs
CHANGED
|
@@ -30,25 +30,67 @@ export function labDoctorChecks(lab) {
|
|
|
30
30
|
// To the GiB: the exact figure moves with every write on the filesystem,
|
|
31
31
|
// and doctor's two consecutive runs are held to the same bytes (a tenth
|
|
32
32
|
// of a GiB flipped once while another test packed a tarball beside it).
|
|
33
|
-
add("lab.disk", enough, `${Math.floor(lab.free.bytes / 2 ** 30).toLocaleString("en-US")} GiB free on the lab cache's filesystem; a prepared lab measured ${bytesBoth(lab.pin.measured.preparedLabBytes)} (M14)`, enough ? null : "omakit lab prune, or free the difference")
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
add("lab.disk", enough, `${Math.floor(lab.free.bytes / 2 ** 30).toLocaleString("en-US")} GiB free on the lab cache's filesystem; a prepared lab measured ${bytesBoth(lab.pin.measured.preparedLabBytes)} with Omarchy ${lab.pin.measured.release} (M14)`, enough ? null : "omakit lab prune, or free the difference")
|
|
34
|
+
checks.push(releaseCheck(lab))
|
|
35
|
+
const usable = ["ready", "outdated", "ahead"].includes(lab.base.state)
|
|
36
|
+
// The ISO is what a build needs: with a usable base there it is only
|
|
37
|
+
// advice when a build is ahead, and the release check says when that is.
|
|
38
|
+
const release = lab.pin.release
|
|
39
|
+
checks.push({
|
|
40
|
+
id: "lab.iso",
|
|
41
|
+
state: lab.download.verified ? "ok" : usable ? "info" : "advice",
|
|
42
|
+
detail: release ? `${release.name}, ${lab.download.reason}` : lab.download.reason,
|
|
43
|
+
action: lab.download.verified || usable ? null : "omakit lab setup",
|
|
44
|
+
evidence: { release: release?.name ?? null, sha256: release?.sha256 ?? null, present: lab.download.present, verified: lab.download.verified },
|
|
45
|
+
})
|
|
46
|
+
add("lab.base", usable, `${lab.base.state}: ${lab.base.reason}`, usable ? null : "omakit lab setup", { state: lab.base.state, release: lab.base.manifest?.release?.name ?? null, guestVersion: lab.base.manifest?.guest?.version ?? null, diskSha256: lab.base.manifest?.disk?.sha256 ?? null, allocatedBytes: lab.base.allocatedBytes, lockHeld: lab.lock.held, lockAlive: lab.lock.alive })
|
|
36
47
|
if (lab.lock.held) add("lab.lock", !lab.lock.alive, lab.lock.alive ? `held by run ${lab.lock.record?.runId || "unknown"} (pid ${lab.lock.record?.pid || "?"})` : `a stale lock from ${lab.lock.record?.runId || "an unknown run"}`, lab.lock.alive ? "wait for the run" : "omakit lab prune")
|
|
37
48
|
return checks
|
|
38
49
|
}
|
|
39
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Whether the lab is on the newest Omarchy release, one check: ok when the
|
|
53
|
+
* base is built from it, advice when a newer one is out, info when there
|
|
54
|
+
* is no base yet or the check was skipped (--offline), unknown when the
|
|
55
|
+
* release list could not be read.
|
|
56
|
+
*/
|
|
57
|
+
export function releaseCheck(lab) {
|
|
58
|
+
const newest = lab.newest || { checked: false, code: "not-asked", reason: "the newest release was not looked up" }
|
|
59
|
+
const evidence = { newest: newest.checked ? newest.release.name : null, tag: newest.checked ? newest.tag : null, base: lab.base.manifest?.release?.name ?? null, code: newest.checked ? null : newest.code }
|
|
60
|
+
if (!newest.checked) {
|
|
61
|
+
if (newest.code === "offline") return { id: "lab.release", state: "info", detail: "the newest Omarchy release is not looked up (--offline)", action: null, evidence }
|
|
62
|
+
// Found, and refused: a finding, not a lookup that failed.
|
|
63
|
+
if (newest.code === "signer-changed") return { id: "lab.release", state: "advice", detail: newest.reason, action: "omakit upgrade: a newer omakit carries Omarchy's new key once it is verified", evidence }
|
|
64
|
+
return { id: "lab.release", state: "unknown", detail: `could not look up the newest Omarchy release: ${newest.reason}`, action: newest.code === "network-unavailable" ? "Connect to the network, or pass --offline to skip the checks that need it." : null, evidence }
|
|
65
|
+
}
|
|
66
|
+
const name = newest.release.name
|
|
67
|
+
if (lab.base.state === "ready") return { id: "lab.release", state: "ok", detail: `Omarchy ${name} is the newest release, and the lab's base is built from it`, action: null, evidence }
|
|
68
|
+
if (lab.base.state === "ahead") return { id: "lab.release", state: "info", detail: `the lab's base is Omarchy ${lab.base.manifest.release.name}, newer than ${name}, the newest release published whole now; it is kept`, action: null, evidence }
|
|
69
|
+
if (lab.base.state === "outdated") return { id: "lab.release", state: "advice", detail: `Omarchy ${name} is the newest release; the lab's base is ${lab.base.manifest.release.name}${lab.base.manifest.release.name === name ? " as first published" : ""}, and a run still uses it`, action: "omakit lab setup", evidence }
|
|
70
|
+
return { id: "lab.release", state: "info", detail: `Omarchy ${name} is the newest release; \`omakit lab setup\` prepares it`, action: null, evidence }
|
|
71
|
+
}
|
|
72
|
+
|
|
40
73
|
/** `omakit lab inspect`. */
|
|
41
74
|
export function renderLab(lab, { colour = colourEnabled(), env = process.env } = {}) {
|
|
42
75
|
const c = styler(colour)
|
|
43
76
|
const home = (text) => withHomeAbbreviated(text, env)
|
|
44
77
|
const out = []
|
|
45
78
|
const { release } = lab.pin
|
|
46
|
-
|
|
47
|
-
out.push(...
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
79
|
+
const newest = lab.newest || { checked: false, reason: "the newest release was not looked up" }
|
|
80
|
+
out.push(...section("the newest release", c))
|
|
81
|
+
if (newest.checked) {
|
|
82
|
+
out.push(...field("release", `Omarchy ${release.name} (${newest.tag}${newest.publishedAt ? `, published ${newest.publishedAt.slice(0, 10)}` : ""}), the newest in ${newest.list.replace(/^https:\/\/api\.github\.com\/repos\/([^/]+\/[^/]+)\/releases.*$/, "github.com/$1")} with its ISO, checksum and signature published`, c))
|
|
83
|
+
for (const skipped of newest.skipped || []) out.push(...field("passed over", `${skipped.name}: ${skipped.reason}`, c))
|
|
84
|
+
} else {
|
|
85
|
+
const why = newest.code === "signer-changed" ? `refused: ${newest.reason}` : `not looked up: ${newest.reason}`
|
|
86
|
+
out.push(...field("release", `${why}${release ? `; the lab is held to its base's release, Omarchy ${release.name}` : ""}`, c))
|
|
87
|
+
}
|
|
88
|
+
if (release) {
|
|
89
|
+
out.push(...field("iso", release.isoUrl, c))
|
|
90
|
+
out.push(...field("size", bytesBoth(release.bytes), c))
|
|
91
|
+
out.push(...field("sha256", release.sha256, c, { wrapValue: false }))
|
|
92
|
+
}
|
|
93
|
+
out.push(...field("signed by", `${lab.pin.releases.signingFingerprint} (Omarchy <pkgs@omarchy.org>, the key omakit ships); every release must carry it`, c))
|
|
52
94
|
out.push(...field("toolchain", `omarchy-iso ${lab.pin.toolchain.commit.slice(0, 12)} with ${lab.pin.toolchain.patch}; harness sha256 ${shortId(lab.pin.toolchain.patchedHarnessSha256)}`, c))
|
|
53
95
|
out.push("")
|
|
54
96
|
out.push(...section("on disk", c))
|
|
@@ -61,7 +103,7 @@ export function renderLab(lab, { colour = colourEnabled(), env = process.env } =
|
|
|
61
103
|
out.push(...wrap(home(lab.toolchain.reason), { indent: GUTTER }, c))
|
|
62
104
|
if (lab.staging.length) {
|
|
63
105
|
out.push(`${mark(lab.staging.some((entry) => entry.alive) ? "info" : "advisory", c)}${c("name", "staging")}`)
|
|
64
|
-
for (const entry of lab.staging) out.push(...wrap(home(`${entry.name}: ${bytesBoth(entry.allocatedBytes)}${entry.alive ? ", a QEMU answers on its socket" : ", inactive"}`), { indent: GUTTER }, c))
|
|
106
|
+
for (const entry of lab.staging) out.push(...wrap(home(`${entry.name}: ${bytesBoth(entry.allocatedBytes)}${entry.pids?.length ? `, a build's QEMU still runs (pid ${entry.pids.join(", ")})` : entry.alive ? ", a QEMU answers on its socket" : ", inactive"}`), { indent: GUTTER }, c))
|
|
65
107
|
}
|
|
66
108
|
if (lab.lock.held) {
|
|
67
109
|
out.push(`${mark(lab.lock.alive ? "info" : "advisory", c)}${c("name", "lock")}`)
|
|
@@ -77,6 +119,13 @@ export function renderLab(lab, { colour = colourEnabled(), env = process.env } =
|
|
|
77
119
|
out.push(...wrap(line.reason, { indent: GUTTER }, c))
|
|
78
120
|
}
|
|
79
121
|
out.push("")
|
|
122
|
+
// Behind is said whether or not something else is missing: a host that
|
|
123
|
+
// is fixed next still has an older base, and hears it here first.
|
|
124
|
+
if (lab.behind) {
|
|
125
|
+
out.push(...field("behind", `Omarchy ${lab.behind.newest} is out and the base is ${lab.behind.base}: ${lab.behind.cost}`, c))
|
|
126
|
+
out.push(...action(lab.behind.command, c))
|
|
127
|
+
out.push("")
|
|
128
|
+
}
|
|
80
129
|
if (lab.missing.length) {
|
|
81
130
|
out.push(...section(`missing for \`omakit lab prove\`: ${lab.missing.length}`, c))
|
|
82
131
|
for (const item of lab.missing) {
|
|
@@ -85,9 +134,12 @@ export function renderLab(lab, { colour = colourEnabled(), env = process.env } =
|
|
|
85
134
|
if (item.command) out.push(...action(home(item.command), c))
|
|
86
135
|
}
|
|
87
136
|
out.push("")
|
|
88
|
-
out.push(...verdict("advisory", "NOT PREPARED", `${lab.missing.length} thing${lab.missing.length === 1 ? "" : "s"} before a run; nothing was fetched or written.`, c))
|
|
137
|
+
out.push(...verdict("advisory", "NOT PREPARED", `${lab.missing.length} thing${lab.missing.length === 1 ? "" : "s"} before a run; ${lab.newest?.checked ? "only the release list was read" : "nothing was fetched or written"}.`, c))
|
|
138
|
+
} else if (lab.behind) {
|
|
139
|
+
out.push(...verdict("advisory", "PREPARED", `on Omarchy ${lab.behind.base}, which a run uses; Omarchy ${lab.behind.newest} is the newest, and \`omakit lab setup\` builds it. Only the release list was read.`, c))
|
|
89
140
|
} else {
|
|
90
|
-
|
|
141
|
+
const on = lab.base.manifest?.release?.name
|
|
142
|
+
out.push(...verdict("pass", "PREPARED", `${on ? `Omarchy ${on}, ` : ""}a ready base the host can run${newest.checked ? ", the newest release" : `; the newest release was not looked up (${newest.reason})`}. ${newest.checked ? "Only the release list was read." : "Nothing was fetched or written."}`, c))
|
|
91
143
|
}
|
|
92
144
|
return out.join("\n")
|
|
93
145
|
}
|
|
@@ -106,7 +158,9 @@ export function renderSetupPlan(plan, { colour = colourEnabled(), env = process.
|
|
|
106
158
|
return out.join("\n")
|
|
107
159
|
}
|
|
108
160
|
if (!plan.steps.length) {
|
|
109
|
-
out.push(...verdict("
|
|
161
|
+
if (plan.stale) out.push(...verdict("advisory", "PREPARED", `the base on disk is Omarchy ${plan.stale.base}, left as it is: the newest release could not be looked up (${plan.stale.reason}). Run \`omakit lab setup\` again with the network.`, c))
|
|
162
|
+
else if (plan.ahead) out.push(...verdict("advisory", "PREPARED", `the base on disk is Omarchy ${plan.ahead.base}, newer than ${plan.ahead.release}, ${plan.ahead.from === "newest" ? "the newest release published whole now" : "the release the file named by --from is"}; it is kept, and nothing older is fetched or built over it.`, c))
|
|
163
|
+
else out.push(...verdict("pass", "PREPARED", `Omarchy ${plan.pin.release.name}, ${plan.newest?.checked ? "the newest release" : "the release the file named by --from is (the newest was not looked up)"}: the verified ISO and a ready base are there; nothing to acquire.`, c))
|
|
110
164
|
return out.join("\n")
|
|
111
165
|
}
|
|
112
166
|
out.push(...section("what setup will do, once you say yes", c))
|
|
@@ -123,11 +177,13 @@ export function renderSetupResult(result, { colour = colourEnabled(), env = proc
|
|
|
123
177
|
else if (step.kind === "import") out.push(`${mark("pass", c)}copied ${bytesBoth(step.bytes)} from ${withHomeAbbreviated(step.from, env)} in ${durationWords(step.milliseconds)}`)
|
|
124
178
|
else if (step.kind === "sidecars") out.push(`${mark("pass", c)}fetched the checksum and signature sidecars into ${withHomeAbbreviated(step.to, env)}`)
|
|
125
179
|
else if (step.kind === "verify") out.push(`${mark("pass", c)}verified ${withHomeAbbreviated(step.file, env)}`, ...wrap(step.reason, { indent: GUTTER }, c))
|
|
126
|
-
else if (step.kind === "build") out.push(`${mark("pass", c)}built and promoted the base in ${durationWords(step.milliseconds)}: guest omarchy ${step.guest.version}, ${bytesBoth(step.allocatedBytes)} allocated, disk sha256 ${step.diskSha256}`)
|
|
180
|
+
else if (step.kind === "build") out.push(`${mark("pass", c)}built and promoted the Omarchy ${step.release} base in ${durationWords(step.milliseconds)}: guest omarchy ${step.guest.version}, ${bytesBoth(step.allocatedBytes)} allocated, disk sha256 ${step.diskSha256}`)
|
|
181
|
+
else if (step.kind === "superseded" && step.removed.length) out.push(`${mark("pass", c)}removed ${step.removed.length === 1 ? "the older download" : `${step.removed.length} older downloads`} (${step.removed.map((entry) => entry.name || entry.digest.slice(0, 12)).join(", ")}), ${bytesBoth(step.removed.reduce((sum, entry) => sum + entry.bytes, 0))}`)
|
|
127
182
|
else if (step.kind === "plugins") out.push(`${mark("pass", c)}${step.fetched.length} listed plugins in the lab cache: ${step.fetched.map((row) => `${row.id} ${row.state}`).join(", ")}`)
|
|
128
183
|
}
|
|
129
184
|
out.push("")
|
|
130
|
-
|
|
185
|
+
const built = result.done.find((step) => step.kind === "build")
|
|
186
|
+
out.push(...verdict("pass", "PREPARED", `the lab is ready${built ? ` on Omarchy ${built.release}` : ""}; \`omakit lab prove <suite>\` boots it.`, c))
|
|
131
187
|
return out.join("\n")
|
|
132
188
|
}
|
|
133
189
|
|
|
@@ -136,12 +192,16 @@ export function renderRunIdentity(record, { colour = colourEnabled() } = {}) {
|
|
|
136
192
|
const c = styler(colour)
|
|
137
193
|
const out = []
|
|
138
194
|
out.push(...field("run", record.runId, c))
|
|
139
|
-
out.push(...field("release", record.pin.release
|
|
195
|
+
out.push(...field("release", `Omarchy ${record.pin.release}`, c))
|
|
196
|
+
const newest = record.newest || { release: null, reason: "not looked up" }
|
|
197
|
+
out.push(...field("newest", record.behind
|
|
198
|
+
? `Omarchy ${newest.release} is out and this base is ${record.pin.release}: the run uses the base there; \`omakit lab setup\` builds ${newest.release}`
|
|
199
|
+
: newest.release ? `this is the newest release (${newest.tag}, checked ${String(newest.checkedAt).slice(0, 10)})` : `not looked up: ${newest.reason}`, c))
|
|
140
200
|
out.push(...field("guest", record.guest ? `omarchy ${record.guest.version || "unknown"} on ${record.guest.kernel || "?"}` : "not read", c))
|
|
141
201
|
out.push(...field("iso sha256", record.pin.isoSha256, c, { wrapValue: false }))
|
|
142
202
|
out.push(...field("base", `created ${record.base.createdAt}; ${bytesBoth(record.base.allocatedBytes)}; disk sha256 ${shortId(record.base.diskSha256)}`, c))
|
|
143
203
|
out.push(...field("tested", record.testedSource || "unknown", c))
|
|
144
|
-
out.push(...field("skew", record.skew === null ? "unknown" : record.skew ? `true: the guest is ${record.guest?.linked ? "linked to a checkout" : `omarchy ${record.guest?.version}`}, the
|
|
204
|
+
out.push(...field("skew", record.skew === null ? "unknown" : record.skew ? `true: the guest is ${record.guest?.linked ? "linked to a checkout" : `omarchy ${record.guest?.version}`}, the base was built with ${record.pin.expectedGuestVersion}` : `false: the installed package is the base's ${record.pin.expectedGuestVersion}`, c))
|
|
145
205
|
return out.join("\n")
|
|
146
206
|
}
|
|
147
207
|
|
|
@@ -154,7 +214,7 @@ export function renderRunResult(record, { colour = colourEnabled(), env = proces
|
|
|
154
214
|
if (record.document) out.push(...field("document", withHomeAbbreviated(record.document, env), c))
|
|
155
215
|
out.push(...field("record", withHomeAbbreviated(`${record.runDir}/run.json`, env), c))
|
|
156
216
|
out.push("")
|
|
157
|
-
if (record.ok) out.push(...verdict("pass", "PROVED", `${record.suite}: ${record.assertion?.reason || "ok"}; guest omarchy ${record.guest?.version}, skew ${record.skew}.`, c))
|
|
217
|
+
if (record.ok) out.push(...verdict("pass", "PROVED", `${record.suite}: ${record.assertion?.reason || "ok"}; guest omarchy ${record.guest?.version}, skew ${record.skew}${record.behind ? `; Omarchy ${record.newest.release} is out, and \`omakit lab setup\` builds it` : ""}.`, c))
|
|
158
218
|
else out.push(...verdict("fail", "NOT PROVED", `${record.suite}: ${record.assertion?.reason || record.error || `the suite exited ${record.suiteStatus}`}.`, c))
|
|
159
219
|
return out.join("\n")
|
|
160
220
|
}
|
package/tools/lab/run.mjs
CHANGED
|
@@ -40,10 +40,10 @@ import { spawn, spawnSync } from "node:child_process"
|
|
|
40
40
|
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
41
41
|
import { createServer } from "node:net"
|
|
42
42
|
import { join, resolve } from "node:path"
|
|
43
|
-
import { bytesBoth, labPin } from "./pin.mjs"
|
|
43
|
+
import { bytesBoth, compareVersions, guestIsRelease, labPin, withRelease } from "./pin.mjs"
|
|
44
44
|
import { allocatedBytes, inLab, labDir, labLayout, readJson, removeFromLab, stampNow, writeJson } from "./paths.mjs"
|
|
45
45
|
import { freeBytesAt, guestCpus, probeRunHost } from "./host.mjs"
|
|
46
|
-
import { BASE_FILES, inspectBase, inspectLock } from "./inspect.mjs"
|
|
46
|
+
import { BASE_FILES, baseRelease, inspectBase, inspectLock } from "./inspect.mjs"
|
|
47
47
|
import { press, qemuArgs, qmpExecute, startQemu, typeText } from "./qemu.mjs"
|
|
48
48
|
import { clearStartupNotifications, establishSession, guestIdentity, SESSION_PREAMBLE, sleep, sshGuest, waitForSsh } from "./guest.mjs"
|
|
49
49
|
import { SUITES, suitePreflight } from "./suites.mjs"
|
|
@@ -209,13 +209,16 @@ export async function withGuest({ baseDir, stagingName, logFile, layout, pin, on
|
|
|
209
209
|
|
|
210
210
|
/**
|
|
211
211
|
* Everything a run needs before a guest boots, or a LabError naming what
|
|
212
|
-
* is missing. Reads only.
|
|
212
|
+
* is missing. Reads only. The base is held to its own release: a run uses
|
|
213
|
+
* the base that is there, and whether a newer release is out is the run
|
|
214
|
+
* record's `newest`, said beside the result, never a reason to refuse.
|
|
213
215
|
*/
|
|
214
216
|
export function preflightRun({ suiteName, env = process.env, pin = labPin(), repoRoot, options = {} }) {
|
|
215
217
|
const suite = SUITES[suiteName]
|
|
216
218
|
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
219
|
const layout = labLayout(env)
|
|
218
220
|
const missing = []
|
|
221
|
+
pin = withRelease(pin, baseRelease(layout, pin))
|
|
219
222
|
const base = inspectBase(layout, pin)
|
|
220
223
|
if (base.state !== "ready") missing.push({ what: `a ready base (${base.state})`, cost: base.reason, command: "omakit lab setup" })
|
|
221
224
|
for (const line of probeRunHost({ pin, env })) if (line.state !== "ok") missing.push({ what: line.name, cost: line.reason, command: line.remedy })
|
|
@@ -223,7 +226,7 @@ export function preflightRun({ suiteName, env = process.env, pin = labPin(), rep
|
|
|
223
226
|
const free = freeBytesAt(layout.cache)
|
|
224
227
|
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
228
|
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 }
|
|
229
|
+
return { suite, layout, base, free, pin }
|
|
227
230
|
}
|
|
228
231
|
|
|
229
232
|
/**
|
|
@@ -232,8 +235,21 @@ export function preflightRun({ suiteName, env = process.env, pin = labPin(), rep
|
|
|
232
235
|
* Returns the run record; the suite's document is in the run directory,
|
|
233
236
|
* with its provenance.
|
|
234
237
|
*/
|
|
235
|
-
export async function runSuite({ suiteName, env = process.env, pin = labPin(), repoRoot, options = {}, onPhase = () => {}, onLine = () => {}, signal }) {
|
|
236
|
-
const
|
|
238
|
+
export async function runSuite({ suiteName, env = process.env, pin = labPin(), repoRoot, options = {}, onPhase = () => {}, onLine = () => {}, signal, checkNewest = null }) {
|
|
239
|
+
const preflight = preflightRun({ suiteName, env, pin, repoRoot, options })
|
|
240
|
+
const { suite, layout, base } = preflight
|
|
241
|
+
pin = preflight.pin
|
|
242
|
+
// Whether a newer release is out, asked once the run can start, so a
|
|
243
|
+
// run that cannot start reads nothing from the network. A failed check
|
|
244
|
+
// is recorded as not checked; it never stops a run.
|
|
245
|
+
let newest = { checked: false, code: "not-asked", reason: "the newest release was not looked up" }
|
|
246
|
+
if (checkNewest) {
|
|
247
|
+
onPhase("looking for the newest Omarchy release")
|
|
248
|
+
newest = await checkNewest()
|
|
249
|
+
// An interrupt during the lookup is an interrupt: nothing is booted.
|
|
250
|
+
if (signal?.aborted || newest.code === "interrupted") throw new LabError("interrupted", "interrupted while looking for the newest release; nothing was booted", { remedy: `omakit lab prove ${suite.name}, when you are ready` })
|
|
251
|
+
}
|
|
252
|
+
const behind = newest.checked ? compareVersions(pin.release.name, newest.release.name) < 0 || (pin.release.name === newest.release.name && pin.release.sha256 !== newest.release.sha256) : null
|
|
237
253
|
const runId = `${stampNow()}-${suite.name}`
|
|
238
254
|
const runDir = labDir(layout.state, "runs", runId)
|
|
239
255
|
const startedAt = new Date()
|
|
@@ -242,7 +258,9 @@ export async function runSuite({ suiteName, env = process.env, pin = labPin(), r
|
|
|
242
258
|
runId,
|
|
243
259
|
suite: suite.name,
|
|
244
260
|
startedAt: startedAt.toISOString(),
|
|
245
|
-
pin: { release: pin.release.name, isoSha256: pin.release.sha256, expectedGuestVersion:
|
|
261
|
+
pin: { release: pin.release.name, isoSha256: pin.release.sha256, expectedGuestVersion: base.manifest.guest.version },
|
|
262
|
+
newest: newest.checked ? { release: newest.release.name, isoSha256: newest.release.sha256, tag: newest.tag, publishedAt: newest.publishedAt, checkedAt: newest.checkedAt } : { release: null, code: newest.code, reason: newest.reason },
|
|
263
|
+
behind,
|
|
246
264
|
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
265
|
guest: null,
|
|
248
266
|
skew: null,
|
|
@@ -266,7 +284,7 @@ export async function runSuite({ suiteName, env = process.env, pin = labPin(), r
|
|
|
266
284
|
booted = await withGuest({ baseDir: base.dir, stagingName: `run-${runId}`, logFile: join(runDir, "qemu.log"), layout, pin, onPhase, signal }, async ({ guest, paths, identity }) => {
|
|
267
285
|
record.guest = identity
|
|
268
286
|
record.host.sshPort = guest.port
|
|
269
|
-
record.skew = identity.linked || identity.version
|
|
287
|
+
record.skew = identity.linked || !guestIsRelease(identity.version, pin.release)
|
|
270
288
|
record.testedSource = identity.testedSource
|
|
271
289
|
save()
|
|
272
290
|
onLine({ state: "info", text: `guest omarchy ${identity.version || "unknown"} on ${identity.kernel || "?"}; tested source ${identity.testedSource}; skew ${record.skew}`, record })
|
|
@@ -286,7 +304,7 @@ export async function runSuite({ suiteName, env = process.env, pin = labPin(), r
|
|
|
286
304
|
// files already carried by hand (`where`), plus the identity a
|
|
287
305
|
// reader can check: run id, guest version, pin, skew.
|
|
288
306
|
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 }
|
|
307
|
+
document.lab = { runId, suite: suite.name, guest: identity, skew: record.skew, testedSource: identity.testedSource, pin: record.pin, newest: record.newest, behind: record.behind, base: { createdAt: record.base.createdAt, diskSha256: record.base.diskSha256, origin: record.base.origin }, host: record.host, startedAt: record.startedAt }
|
|
290
308
|
writeJson(layout.state, `runs/${runId}/${suite.document}`, document)
|
|
291
309
|
}
|
|
292
310
|
})
|