omakit 0.6.7 → 0.6.9
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/skills/omarchy-plugin-build/SKILL.md +5 -1
- package/tools/blocks/commit.json +2 -2
- package/tools/inspect/contract.mjs +3 -2
- package/tools/inspect/patterns.mjs +37 -4
- package/tools/inspect/report.mjs +8 -4
- package/tools/inspect/writes.mjs +30 -8
- package/tools/lab/inspect.mjs +170 -31
- 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 +8 -7
- package/tools/marketplace/cli.mjs +35 -7
- package/tools/marketplace/doctor.mjs +14 -6
- package/tools/marketplace/github.mjs +11 -5
- package/tools/marketplace/options.mjs +1 -1
- package/tools/marketplace/usage.mjs +19 -16
package/tools/lab/pin.mjs
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// What the lab holds fixed: where Omarchy's releases are listed and where
|
|
2
|
+
// their ISOs are published, the signer every release must carry, the
|
|
3
|
+
// oldest release it will take, the toolchain that builds a base, and the
|
|
4
|
+
// costs measured on the reference host.
|
|
3
5
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// Which release the lab prepares is not in this file: `release.mjs` finds
|
|
7
|
+
// the newest one that is published whole (ISO, checksum, signature) each
|
|
8
|
+
// time `setup` runs, so the lab never stays on an old release unnoticed.
|
|
9
|
+
// The trust is the signature, not the list: a file is the release only
|
|
10
|
+
// when its detached signature verifies against the packaged key at the
|
|
11
|
+
// pinned fingerprint (verify.mjs). The inventory's objection to "latest"
|
|
12
|
+
// (docs/history/2026-09-18-lab-inventory.md P13) was a checksum trusted
|
|
13
|
+
// from the same host as the file with no signature checked at all; that
|
|
14
|
+
// is what the pinned signer answers.
|
|
9
15
|
|
|
10
16
|
import { readFileSync } from "node:fs"
|
|
11
17
|
import { dirname, join } from "node:path"
|
|
@@ -13,23 +19,82 @@ import { fileURLToPath } from "node:url"
|
|
|
13
19
|
|
|
14
20
|
export const LAB_DIR = dirname(fileURLToPath(import.meta.url))
|
|
15
21
|
|
|
22
|
+
/** A release name the lab takes: three numbers, as Omarchy tags them without the v. */
|
|
23
|
+
export const VERSION = /^(\d+)\.(\d+)\.(\d+)$/
|
|
24
|
+
|
|
16
25
|
let cached = null
|
|
17
26
|
|
|
18
|
-
/** The pin, parsed once. `file` is injectable for tests that need
|
|
27
|
+
/** The pin, parsed once. `file` is injectable for tests that need their own. */
|
|
19
28
|
export function labPin(file = join(LAB_DIR, "pin.json")) {
|
|
20
29
|
if (file === join(LAB_DIR, "pin.json") && cached) return cached
|
|
21
30
|
const pin = JSON.parse(readFileSync(file, "utf8"))
|
|
22
|
-
for (const key of ["
|
|
31
|
+
for (const key of ["releases", "toolchain", "guest", "measured"]) {
|
|
23
32
|
if (!pin[key] || typeof pin[key] !== "object") throw new Error(`lab pin: no ${key} section in ${file}`)
|
|
24
33
|
}
|
|
25
|
-
|
|
26
|
-
if (!/^[
|
|
27
|
-
if (
|
|
28
|
-
if (/latest/i.test(
|
|
34
|
+
const { releases } = pin
|
|
35
|
+
if (!/^https:\/\/api\.github\.com\/repos\/[\w.-]+\/[\w.-]+\/releases(?:\?[\w=&]*)?$/.test(releases.list)) throw new Error("lab pin: the release list is not a GitHub releases URL")
|
|
36
|
+
if (!/^https:\/\/[\w.-]+\/[\w./-]*\{version\}[\w./-]*\.iso$/.test(releases.iso)) throw new Error("lab pin: the ISO URL is not an https template with {version}")
|
|
37
|
+
if (/latest/i.test(releases.iso)) throw new Error("lab pin: the ISO URL names latest; a release is found by its version")
|
|
38
|
+
if (!VERSION.test(releases.floor)) throw new Error("lab pin: the floor is not a release name")
|
|
39
|
+
if (!Number.isInteger(releases.candidates) || releases.candidates < 1) throw new Error("lab pin: candidates is not a positive integer")
|
|
40
|
+
if (!/^[0-9A-F]{40}$/.test(releases.signingFingerprint)) throw new Error("lab pin: the signing fingerprint is not 40 hex characters")
|
|
41
|
+
if (!/^[0-9a-f]{64}$/.test(releases.signingKeySha256)) throw new Error("lab pin: the signing key digest is not 64 hex characters")
|
|
29
42
|
if (file === join(LAB_DIR, "pin.json")) cached = pin
|
|
30
43
|
return pin
|
|
31
44
|
}
|
|
32
45
|
|
|
46
|
+
/** -1, 0 or 1, by the three numbers; a name that is not a release sorts below every one that is. */
|
|
47
|
+
export function compareVersions(a, b) {
|
|
48
|
+
const left = String(a).match(VERSION)
|
|
49
|
+
const right = String(b).match(VERSION)
|
|
50
|
+
if (!left || !right) return left ? 1 : right ? -1 : 0
|
|
51
|
+
for (const index of [1, 2, 3]) {
|
|
52
|
+
const difference = Number(left[index]) - Number(right[index])
|
|
53
|
+
if (difference) return difference < 0 ? -1 : 1
|
|
54
|
+
}
|
|
55
|
+
return 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One release in the shape the lab reads everywhere: its name, where its
|
|
60
|
+
* ISO and the two sidecars are, its byte count and digest as published,
|
|
61
|
+
* and the pinned signer it must carry. `bytes` and `sha256` come from the
|
|
62
|
+
* host that publishes the release; the signature is what makes them
|
|
63
|
+
* trusted, and verify.mjs checks it before anything boots the file.
|
|
64
|
+
*/
|
|
65
|
+
export function releaseOf(pin, { name, bytes, sha256 }) {
|
|
66
|
+
if (!VERSION.test(String(name))) throw new Error(`lab: ${JSON.stringify(name)} is not a release name`)
|
|
67
|
+
const isoUrl = pin.releases.iso.replace("{version}", name)
|
|
68
|
+
return {
|
|
69
|
+
name,
|
|
70
|
+
fileName: isoUrl.split("/").at(-1),
|
|
71
|
+
isoUrl,
|
|
72
|
+
checksumUrl: `${isoUrl}.sha256`,
|
|
73
|
+
signatureUrl: `${isoUrl}.sig`,
|
|
74
|
+
bytes,
|
|
75
|
+
sha256,
|
|
76
|
+
signingFingerprint: pin.releases.signingFingerprint,
|
|
77
|
+
signingKey: pin.releases.signingKey,
|
|
78
|
+
signingKeySha256: pin.releases.signingKeySha256,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The pin with one release in it: what setup, inspect and a run read as `pin.release`. */
|
|
83
|
+
export function withRelease(pin, release) {
|
|
84
|
+
return { ...pin, release }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether an installed `omarchy` package version is this release's: the
|
|
89
|
+
* release name and one package revision (`4.0.3-1`). The ISO installs the
|
|
90
|
+
* package it carries, so any other version is a mislabelled release.
|
|
91
|
+
*/
|
|
92
|
+
export function guestIsRelease(version, release) {
|
|
93
|
+
if (!release || typeof version !== "string") return false
|
|
94
|
+
const match = version.match(/^(\d+\.\d+\.\d+)-(\d+)$/)
|
|
95
|
+
return Boolean(match) && match[1] === release.name
|
|
96
|
+
}
|
|
97
|
+
|
|
33
98
|
/**
|
|
34
99
|
* Bytes for a person, in both units, so a size cannot be made to look
|
|
35
100
|
* smaller by choosing one: `6,260,654,080 B (6.261 GB / 5.831 GiB)`.
|
package/tools/lab/prune.mjs
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
|
|
10
10
|
import { existsSync, lstatSync, readdirSync } from "node:fs"
|
|
11
11
|
import { join } from "node:path"
|
|
12
|
-
import { bytesBoth, labPin } from "./pin.mjs"
|
|
12
|
+
import { bytesBoth, labPin, withRelease } from "./pin.mjs"
|
|
13
13
|
import { allocatedBytes, labLayout, removeFromLab } from "./paths.mjs"
|
|
14
|
-
import { inspectBase, inspectLock, inspectStaging } from "./inspect.mjs"
|
|
14
|
+
import { baseRelease, downloadEntry, inspectBase, inspectLock, inspectStaging } from "./inspect.mjs"
|
|
15
15
|
import { LabError } from "./run.mjs"
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -32,19 +32,22 @@ export async function planPrune({ env = process.env, pin = labPin(), keepIso = f
|
|
|
32
32
|
const active = staging.filter((entry) => entry.alive)
|
|
33
33
|
const blockers = []
|
|
34
34
|
if (lock.held && lock.alive) blockers.push(`run ${lock.record?.runId || "unknown"} holds the lab (pid ${lock.record?.pid || "?"}${lock.qemuAnswers ? ", its QEMU answers on QMP" : ""})`)
|
|
35
|
-
for (const entry of active) blockers.push(`a QEMU still answers on ${entry.socket}`)
|
|
35
|
+
for (const entry of active) blockers.push(entry.pids?.length ? `a build's QEMU still runs in ${entry.dir} (pid ${entry.pids.join(", ")}), left by a setup that ended; stop it with \`kill ${entry.pids.join(" ")}\` (the next setup that builds stops it too)` : `a QEMU still answers on ${entry.socket}`)
|
|
36
36
|
if (existsSync(layout.downloads)) {
|
|
37
37
|
for (const digest of readdirSync(layout.downloads)) {
|
|
38
|
+
// Each download directory says what it holds: its verification
|
|
39
|
+
// record names the release (a record written before 0.6.9 names only
|
|
40
|
+
// the digest, and the ISO is the one .iso file in the directory).
|
|
38
41
|
const dir = join(layout.downloads, digest)
|
|
39
|
-
const
|
|
42
|
+
const { name, fileName, verified } = lstatSync(dir).isDirectory() ? downloadEntry(dir, digest) : { name: null, fileName: null, verified: false }
|
|
40
43
|
if (verified && keepIso) {
|
|
41
|
-
add(layout.cache, `downloads/${digest}/${
|
|
44
|
+
add(layout.cache, `downloads/${digest}/${fileName}.part`, "a partial download beside the verified ISO")
|
|
42
45
|
continue
|
|
43
46
|
}
|
|
44
|
-
add(layout.cache, `downloads/${digest}`, verified ? `the verified ISO of ${
|
|
47
|
+
add(layout.cache, `downloads/${digest}`, verified ? `the verified ISO of ${name ? `Omarchy ${name}` : digest.slice(0, 12)} and its record` : `an unverified or partial download${name ? ` of Omarchy ${name}` : ` (${digest.slice(0, 12)})`}`)
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
|
-
const base = inspectBase(layout, pin)
|
|
50
|
+
const base = inspectBase(layout, withRelease(pin, baseRelease(layout, pin)))
|
|
48
51
|
if (base.state !== "missing") add(layout.cache, "base", `the ${base.state} base${base.manifest ? ` (Omarchy ${base.manifest.release.name}, guest ${base.manifest.guest.version})` : ""}`)
|
|
49
52
|
for (const entry of staging) if (!entry.alive) add(layout.cache, `staging/${entry.name}`, `staging left by ${entry.name}`)
|
|
50
53
|
if (existsSync(layout.plugins)) add(layout.cache, "plugins", "the listed plugins cached for the weigh evidence gate")
|
|
@@ -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
|
})
|