Compare commits

..
3 Commits
Author SHA1 Message Date
Miles Ward e80a66b0da v0.8.2: fix iPXE menu item selection (issue #2)
In 0.8.1 on x86_64 UEFI (snponly.efi) the boot menu drew and the keyboard
worked, but every item except the first one bounced straight back to the
menu with the default Queued Deployment entry highlighted; the `x` Exit
item did the same. Root cause was the generated dispatch lines:

  iseq ${target} linux && chain .../boot/_linux_menu.ipxe || goto menu

iPXE evaluates `&&`/`||` strictly left to right (src/core/exec.c
`system()`), so for `A && B || C` the `goto menu` runs whenever A fails --
for every item except the first. Only `local` ever reached its chain line,
which is exactly the reported symptom. The same shape made `x` useless,
and `goto menu` redraws with `--default queue`. The bug report's Ctrl-B
test (chaining an entry script by hand) booted fine, confirming the
scripts and HTTP/TFTP paths were healthy.

Fix, all in crates/http-api/src/ipxe_script.rs:

- Dispatch with `goto ${target} || goto menu` to `:label` blocks (the
  canonical iPXE menu pattern) in the top menu, both installer submenus,
  the tools menu and the utilities menu, so a non-matching item can never
  fall through into a stray `goto menu`.
- Menu-to-menu transitions use `chain --replace`, so the previous menu
  image is replaced: `exit` from any submenu reaches the firmware and
  repeated Back/forward navigation no longer nests script images.
- Boot-entry scripts now recover to the menu themselves (kernel/initrd
  fetches `|| goto failed`, `:failed` chain --replace boot.ipxe), since
  with --replace the calling menu can no longer catch their errors.
- Arch-label mapping: three `|| echo` probes instead of one chain ending
  in `|| true` (`true` is not an iPXE command; non-allowlisted pairs such
  as i386+efi aborted the script before the menu was drawn).
- Utilities menu Back really returns to Tools (it used to redraw
  Utilities); `prompt --timeout` expiry no longer aborts NIC info;
  password prompt tests emptiness with `isset` instead of a literal `""`
  token (iPXE has no quoting, so an empty Enter skipped the main-menu
  path); queue entry view falls back to the main menu if the queue
  endpoint is unreachable.

Tests: new `menu_dispatch_tests` interpreter that mirrors iPXE's
`system()`/`process_script()` (whitespace tokenisation, left-associative
`&&`/`||`, goto labels, abort on nonzero line) and asserts each top-menu
and submenu item reaches its expected chain URL (the old lines would
fail these), plus exit/reboot/firmware, unknown-architecture, password
prompt, shell and queue flows. Text assertions, the insta snapshot and the
tools-menu wiring assertion in tests/full_flow.rs updated to match.
`cargo fmt --check` clean, no new clippy warnings, `cargo test
--workspace` green (all crates).
2026-10-18 06:36:23 -04:00
DSH Agent eef317fb3a dependency-check: run in an image that has both cargo and Node
The first run died on `exec: node: not found` — rust:1.95-bookworm has no Node,
and the report needs cargo to resolve the workspace graph alongside Node to read
the registries. .gitea/scripts/ci.Dockerfile layers the two official images
together, so the parsing exists once rather than once per language.
2026-09-20 02:54:22 -04:00
DSH Agent 16e535212f Add a six-hourly dependency check
Reports drifted crates, the Rust pin and the Docker base images in a single
issue titled "Dependency updates", rewritten in place rather than commented on
and closed automatically when nothing is out of date.

Nothing is upgraded automatically: this reports, a human decides. The report
separates what `cargo update` covers from what crosses a breaking boundary —
including the pre-1.0 rule, where `0.5 -> 0.6` is as breaking as `1.5 -> 2.0`.

The scanner is a standalone script under .gitea/scripts so the same file serves
the sibling Rust repos; the workflow reads the Rust version from this repo's own
Dockerfile rather than hard-coding one, so the check runs on the compiler the
image actually ships with.

Cargo is deliberately run online here: this project resolves a crate from a
private registry, and `--offline` would turn that into no report at all.

Requires a REGISTRY_TOKEN secret.
2026-09-20 02:49:35 -04:00
8 changed files with 1361 additions and 91 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
# CI-only image for the dependency check.
#
# The report is a Node script because it has to compare semantic versions and
# read four different HTTP APIs, and the official `rust` image ships no Node —
# while the `node` image ships no cargo, which `cargo metadata` needs to resolve
# the workspace graph. Rather than maintain the parsing twice in two languages,
# this layers the two together.
#
# Node is copied from the official image rather than installed from a
# third-party apt repository: no extra signing key, no extra network
# dependency at build time, and the same binaries everyone else runs.
ARG RUST_VERSION=1.95
ARG NODE_VERSION=22-bookworm-slim
FROM node:${NODE_VERSION} AS node
FROM rust:${RUST_VERSION}-bookworm
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& node --version
+516
View File
@@ -0,0 +1,516 @@
//! Report every dependency that has a newer release than the one in use.
//!
//! Runs from `.gitea/workflows/dependency-check.yml` on a six-hour schedule.
//! Prints a Markdown report on stdout and exits 0 whether or not anything is
//! out of date — "up to date" is a normal outcome, not a failure. The workflow
//! decides what to do with the report; this file only gathers facts.
//!
//! Three things are checked, because all three go stale independently and none
//! of them is visible from the others:
//!
//! * **Crates** — every locked dependency in `Cargo.lock` against crates.io.
//! * **The Rust pin** — `rust-toolchain.toml`'s channel against the latest
//! stable release, and the Dockerfile's `RUST_VERSION` against both, since
//! those two drifting apart is how a local build and a shipped image end up
//! on different compilers.
//! * **Base images** — the Dockerfile's `FROM` tags against their upstream
//! registries.
//!
//! Network only: no crates are downloaded, nothing is built, and the working
//! tree is never modified.
import { readFileSync, existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
const UA = "dependency-check (+https://gitea.milesward.dev/mward4)";
// ── version comparison ──────────────────────────────────────────────────────
/// Parse `1.2.3`, `1.2`, `1.2.3-alpha.1`, and `=1.2.3` into a comparable
/// tuple. Returns null for anything that isn't a version we understand, so an
/// unparseable requirement is reported as "unknown" rather than silently
/// sorting to the top.
function parseVersion(text) {
const m = /^[=~^]?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-+](.*))?$/.exec(text.trim());
if (!m) return null;
return {
major: Number(m[1]),
minor: Number(m[2] ?? 0),
patch: Number(m[3] ?? 0),
pre: m[4] ?? "",
};
}
/// Order two parsed versions. A pre-release sorts below its release, so
/// `2.0.0-rc.1` never displaces a stable `1.9.0` as "the newest".
function compare(a, b) {
for (const key of ["major", "minor", "patch"]) {
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1;
}
if (a.pre === b.pre) return 0;
if (!a.pre) return 1;
if (!b.pre) return -1;
return a.pre < b.pre ? -1 : 1;
}
/// How a version difference is allowed to be taken.
///
/// The pre-1.0 rule is the one that matters here: semver treats `0.x.y` as
/// unstable, so `0.5 → 0.6` breaks exactly as hard as `1.5 → 2.0`. Getting this
/// wrong would label half the RustCrypto family "minor or patch" when in fact
/// `cargo update` will refuse every one of them.
function bumpKind(from, to) {
if (to.major !== from.major) return "breaking";
if (from.major === 0 && to.minor !== from.minor) return "breaking";
return "compatible";
}
/// The newest release reached without crossing a breaking boundary — what
/// `cargo update` will move to on its own.
function bestCompatible(from, versions) {
let best = null;
for (const raw of versions) {
const v = parseVersion(raw);
// `bumpKind` is checked before comparing against `from`: `0.14.7` is not
// semver-compatible with `1.0.0` even though it is numerically lower, and
// the naive `compare > 0` test would let `0.14 → 1.4` through as routine.
if (!v || bumpKind(from, v) !== "compatible" || compare(v, from) <= 0) continue;
if (!best || compare(v, best) > 0) best = v;
}
return best;
}
/// The newest release of any kind, which for a breaking bump is what a human
/// would have to adopt.
function bestOverall(from, versions) {
let best = null;
for (const raw of versions) {
const v = parseVersion(raw);
if (!v || compare(v, from) <= 0) continue;
if (!best || compare(v, best) > 0) best = v;
}
return best;
}
const fmt = (v) => (v ? `${v.major}.${v.minor}.${v.patch}${v.pre ? `-${v.pre}` : ""}` : "?");
// ── inputs ──────────────────────────────────────────────────────────────────
/// Resolve the dependency graph with cargo itself.
///
/// `--locked` rather than a hand-rolled Cargo.lock parse: it fails instead of
/// silently rewriting the lockfile, which matters because this runs on a
/// schedule and must never mutate the repository it is reporting on. Cargo.toml
/// is still read directly below for the declared requirement, since metadata
/// reports the resolved version rather than the constraint.
function cargoMetadata() {
let raw;
try {
raw = execFileSync("cargo", ["metadata", "--format-version", "1", "--locked"], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (e) {
// A cargo failure here means no report at all. Say why in one line rather
// than letting a raw stack trace stand in for an explanation.
const detail = (e.stderr || e.message || "").toString().trim().split("\n")[0];
throw new Error(`cargo metadata failed: ${detail}`);
}
return JSON.parse(raw);
}
function declaredRequirements() {
// Handles the `dep = "1.2"` and `dep = { version = "1.2", ... }` forms and
// nothing else, which is every dependency this workspace declares.
const text = readFileSync("Cargo.toml", "utf8");
const out = new Map();
let section = "";
for (const line of text.split("\n")) {
const header = /^\[([^\]]+)\]/.exec(line);
if (header) {
section = header[1];
continue;
}
if (!/dependencies/.test(section)) continue;
const m = /^\s*([A-Za-z0-9_-]+)\s*=\s*(.+?)\s*$/.exec(line);
if (!m) continue;
const [, name, value] = m;
const quoted = /^"([^"]+)"/.exec(value);
if (quoted) out.set(name, quoted[1]);
else {
const v = /version\s*=\s*"([^"]+)"/.exec(value);
if (v) out.set(name, v[1]);
}
}
return out;
}
// ── external lookups ────────────────────────────────────────────────────────
/// The latest stable Rust, from the release channel manifest.
///
/// Read from the sparse index rather than the crates.io API. The API is rate
/// limited and answers 429 under a burst of ~130 lookups, which silently
/// turned most of this report into "could not check" on the first run; the
/// index is the same data, one request per crate, no limit and no user agent
/// requirement.
async function publishedVersions(name) {
// The index shards by name length and case: 1-char names live under "1",
// 2-char under "2", 3-char under "3/<first letter>", and everything else
// under the first two characters. Names are lowercased on the way in.
const lower = name.toLowerCase();
const prefix =
lower.length === 1
? `1/${lower}`
: lower.length === 2
? `2/${lower}`
: lower.length === 3
? `3/${lower[0]}/${lower}`
: `${lower.slice(0, 2)}/${lower.slice(2, 4)}/${lower}`;
try {
const res = await fetch(`https://index.crates.io/${prefix}`, {
headers: { "user-agent": UA },
});
// The index answers 404 for a crate that does not exist and 410 for one
// that was removed; both mean "nothing to compare against".
if (!res.ok) return null;
return (await res.text())
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
.filter((entry) => !entry.yanked && !entry.vers.includes("-"))
.map((entry) => entry.vers);
} catch {
return null;
}
}
/// The latest stable Rust, from the release channel manifest.
async function latestRust() {
try {
const res = await fetch("https://static.rust-lang.org/dist/channel-rust-stable.toml", {
headers: { "user-agent": UA },
});
if (!res.ok) return null;
const m = /\[pkg\.rust\]\nversion = "([^"]+)"/.exec(await res.text());
// The manifest value carries the commit: "1.98.1 (48a229cea 2026-09-01)".
// Only the number is comparable to the Dockerfile's RUST_VERSION.
return m ? m[1].split(/\s+/)[0] : null;
} catch {
return null;
}
}
/// Tags for a Docker Hub repository, or null if it can't be read.
async function dockerTags(repo) {
const [ns, name] = repo.includes("/") ? repo.split("/") : ["library", repo];
try {
const res = await fetch(`https://hub.docker.com/v2/repositories/${ns}/${name}/tags?page_size=100`, {
headers: { "user-agent": UA },
});
if (!res.ok) return null;
const data = await res.json();
return data.results.map((t) => t.name);
} catch {
return null;
}
}
// ── checks ──────────────────────────────────────────────────────────────────
async function checkCrates() {
const meta = cargoMetadata();
const requirements = declaredRequirements();
// One row per crate actually compiled into the workspace. Dev-dependencies
// and build-dependencies are real dependencies too, so they are included —
// a stale build script is still a stale build script.
const seen = new Map();
for (const pkg of meta.packages) {
if (meta.workspace_members.includes(pkg.id)) continue;
seen.set(pkg.name, { from: pkg.version, kind: pkg.source ? "registry" : "path" });
}
const rows = [];
for (const [name, info] of [...seen].sort((a, b) => a[0].localeCompare(b[0]))) {
if (info.kind === "path") continue;
// Direct means "named in Cargo.toml", which is the only set the author can
// act on. The rest move when whatever pulls them in moves, so they are
// reported but never counted as work.
const direct = requirements.has(name);
const current = parseVersion(info.from);
const versions = await publishedVersions(name);
if (!current || !versions) {
rows.push({ name, from: info.from, to: null, kind: "unknown", direct, note: "could not check" });
continue;
}
const compatible = bestCompatible(current, versions);
const overall = bestOverall(current, versions);
if (!overall) continue; // already newest
// `compatible` is what `cargo update` will do unaided. If the newest
// release is further out than that, the gap is a breaking change someone
// has to adopt deliberately.
const kind = compatible ? "compatible" : bumpKind(current, overall);
rows.push({
name,
from: info.from,
to: fmt(overall),
kind,
direct,
note: kind === "compatible" ? "`cargo update`" : "**breaking — needs review**",
});
}
return rows;
}
function rustPin() {
const toolchain = existsSync("rust-toolchain.toml")
? readFileSync("rust-toolchain.toml", "utf8")
: "";
const channel = /channel\s*=\s*"([^"]+)"/.exec(toolchain)?.[1] ?? null;
// The image bakes its own toolchain, so this is the number that decides what
// actually compiles the release binary.
const dockerfile = existsSync("deploy/docker/Dockerfile")
? readFileSync("deploy/docker/Dockerfile", "utf8")
: existsSync("Dockerfile")
? readFileSync("Dockerfile", "utf8")
: "";
const image = /ARG\s+RUST_VERSION=([0-9.]+)/.exec(dockerfile)?.[1] ?? null;
// The number `cargo` enforces as the minimum supported toolchain.
const manifest = readFileSync("Cargo.toml", "utf8");
const msrv = /rust-version\s*=\s*"([^"]+)"/.exec(manifest)?.[1] ?? null;
return { channel, image, msrv };
}
/// Every `FROM` in the Dockerfile, with the `ARG` substitution applied for the
/// one argument this build actually parameterises.
function baseImages() {
const path = existsSync("deploy/docker/Dockerfile")
? "deploy/docker/Dockerfile"
: existsSync("Dockerfile")
? "Dockerfile"
: null;
if (!path) return [];
const text = readFileSync(path, "utf8");
const args = new Map(
[...text.matchAll(/^ARG\s+([A-Za-z0-9_]+)=(\S+)/gm)].map((m) => [m[1], m[2]]),
);
const images = [];
for (const m of text.matchAll(/^FROM\s+(?:--platform=\S+\s+)?(\S+)/gm)) {
let ref = m[1];
// `node:${NODE_VERSION}-slim` and friends.
ref = ref.replace(/\$\{?([A-Za-z0-9_]+)\}?/g, (whole, name) => args.get(name) ?? whole);
if (ref.includes("$")) continue; // still unresolved; skip rather than guess
const [repo, tag = "latest"] = ref.split(":");
images.push({ repo, tag });
}
return images;
}
async function checkRustToolchain() {
const pin = rustPin();
const latest = await latestRust();
return { ...pin, latest };
}
/// The newest tag that keeps the variant suffix the Dockerfile asked for.
///
/// `rust:1.95-bookworm` and `node:22-bookworm-slim` are not interchangeable
/// with their plain counterparts, so a tag is only a candidate if it ends in
/// exactly the suffix already in use.
function bestTagFor(currentTag, tags) {
const m = /^([\d.]+)(-.*)?$/.exec(currentTag);
if (!m) return null;
const [, , suffix = ""] = m;
let best = null;
for (const tag of tags) {
if (suffix) {
if (!tag.endsWith(suffix)) continue;
} else if (!/^[\d.]+$/.test(tag)) {
continue;
}
const v = parseVersion(suffix ? tag.slice(0, -suffix.length) : tag);
// Date-stamped tags like `alpine:20260805` parse as versions but are edge
// or snapshot builds, not releases. No official image we track is anywhere
// near a three-digit major.
if (v && !suffix && v.major >= 100) continue;
if (v && (!best || compare(v, best) > 0)) best = v;
}
return best;
}
async function checkImages() {
const rows = [];
for (const { repo, tag } of baseImages()) {
const tags = await dockerTags(repo);
if (!tags) {
rows.push({ repo, from: tag, to: null, note: "could not check" });
continue;
}
const parsed = parseVersion(tag);
const best = bestTagFor(tag, tags);
if (!parsed || !best) {
rows.push({ repo, from: tag, to: null, note: "tag is not a version" });
continue;
}
// A base image major bump is a whole new distro release; same rule as a
// crate, so it is reported but flagged rather than treated as routine.
const bump = bumpKind(parsed, best);
rows.push({
repo,
from: tag,
to: fmt(best),
note: bump === "compatible" ? "" : "**breaking — needs review**",
});
}
return rows;
}
// ── report ──────────────────────────────────────────────────────────────────
function table(rows, columns) {
const head = `| ${columns.map((c) => c.title).join(" | ")} |`;
const rule = `| ${columns.map(() => "---").join(" | ")} |`;
const body = rows.map((r) => `| ${columns.map((c) => c.get(r)).join(" | ")} |`);
return [head, rule, ...body].join("\n");
}
async function main() {
const [crates, rust, images] = await Promise.all([checkCrates(), checkRustToolchain(), checkImages()]);
// Split by who can act. Only a crate named in Cargo.toml can be moved by
// hand; everything else follows whichever direct dependency pulls it in, so
// listing those as tasks would be noise that trains the reader to ignore the
// report.
const direct = crates.filter((r) => r.direct);
const transitive = crates.filter((r) => !r.direct);
const routine = direct.filter((r) => r.kind === "compatible");
const breaking = direct.filter((r) => r.kind === "breaking");
const rustOutdated = Boolean(rust.latest && rust.image && rust.image !== rust.latest);
// Transitive crates held back by a version boundary upstream has not crossed.
// Worth knowing, not worth acting on.
const heldBack = transitive.filter((r) => r.kind === "breaking");
const parts = [];
const actionable = routine.length + breaking.length + images.length + (rustOutdated ? 1 : 0);
parts.push(
actionable === 0
? "Everything you can act on is current."
: `${routine.length} routine bump${routine.length === 1 ? "" : "s"}, ` +
`${breaking.length} breaking change${breaking.length === 1 ? "" : "s"}` +
(images.length ? `, ${images.length} base image${images.length === 1 ? "" : "s"}` : "") +
(rustOutdated ? ", and a newer Rust" : "") +
".",
);
if (heldBack.length) {
parts.push(
`_A further ${heldBack.length} transitive crates have newer releases blocked behind a ` +
"breaking boundary upstream has not crossed yet. They are omitted below because " +
"nothing here can move them._",
);
}
if (routine.length) {
parts.push(
"### Direct dependencies — `cargo update` covers these\n\n" +
table(routine, [
{ title: "Crate", get: (r) => `\`${r.name}\`` },
{ title: "In use", get: (r) => `\`${r.from}\`` },
{ title: "Latest", get: (r) => `\`${r.to}\`` },
]),
);
}
if (breaking.length) {
parts.push(
"### Direct dependencies — breaking, a human has to decide\n\n" +
"These cross a version boundary where semver allows anything to change. " +
"`cargo update` will not touch them: `Cargo.toml` needs editing and the " +
"changelog reading.\n\n" +
table(breaking, [
{ title: "Crate", get: (r) => `\`${r.name}\`` },
{ title: "In use", get: (r) => `\`${r.from}\`` },
{ title: "Latest", get: (r) => `\`${r.to}\`` },
]),
);
}
const rustRows = rust.latest
? [
{
what: "Dockerfile `RUST_VERSION`",
using: rust.image ?? "not pinned",
latest: rust.latest,
note: rustOutdated ? "**the shipped binary is built by an older compiler**" : "current",
},
{
what: "`rust-toolchain.toml` channel",
using: rust.channel ?? "unknown",
latest: rust.latest,
note:
rust.channel === "stable"
? "tracks stable, so it is already current"
: "pinned to a fixed release",
},
]
: [
{
what: "latest stable Rust",
using: rust.image ?? "unknown",
latest: "—",
note: "could not reach static.rust-lang.org",
},
];
parts.push(
"### Rust\n\n" +
table(rustRows, [
{ title: "What", get: (r) => r.what },
{ title: "Declared", get: (r) => `\`${r.using}\`` },
{ title: "Latest", get: (r) => `\`${r.latest}\`` },
{ title: "Note", get: (r) => r.note },
]) +
(rust.msrv ? `\n\nMinimum supported Rust version: \`${rust.msrv}\`.` : ""),
);
if (images.length) {
parts.push(
"### Base images\n\n" +
table(images, [
{ title: "Image", get: (r) => `\`${r.repo}\`` },
{ title: "In use", get: (r) => `\`${r.from}\`` },
{ title: "Latest", get: (r) => `\`${r.to}\`` },
{ title: "Note", get: (r) => r.note },
]),
);
}
parts.push("_Checked every 6 hours by `.gitea/workflows/dependency-check.yml`._");
console.log(parts.join("\n\n"));
// Machine-readable footer, so the workflow never has to parse prose.
console.error(
`::dep-check:: routine=${routine.length} breaking=${breaking.length} ` +
`images=${images.length} rust=${rustOutdated ? 1 : 0}`,
);
}
try {
await main();
} catch (e) {
// Print the reason and exit non-zero. The workflow treats a missing report as
// a broken check rather than a clean bill of health, so this keeps the
// tracking issue open instead of closing it on no evidence.
console.error(`dependency check failed: ${e.message}`);
process.exit(1);
}
+184
View File
@@ -0,0 +1,184 @@
name: Check for dependency updates
# Runs every six hours and reports anything out of date in one place: the
# crates in Cargo.lock, the Rust pin in rust-toolchain.toml and the Dockerfile,
# and the Dockerfile's base images.
#
# Everything lands in ONE issue titled "Dependency updates", edited in place
# rather than commented on, so there is a single current answer instead of a
# pile of history. When nothing is out of date the issue is closed — an empty
# issue left open is a thing people stop reading, which defeats the point.
#
# Nothing is upgraded automatically. `cargo update` on a schedule would land
# unreviewed code in a service that holds the operator's second factor, and a
# breaking bump needs a human regardless.
on:
schedule:
# Every six hours, off the hour so this doesn't compete with an image
# rebuild for a runner.
- cron: '23 */6 * * *'
workflow_dispatch:
# One run at a time. A manual dispatch during a scheduled run would otherwise
# race it and could open the issue twice.
concurrency:
group: dependency-check
cancel-in-progress: false
permissions:
contents: read
issues: write
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
# The toolchain comes from the repo rather than being hard-coded here, so
# the check runs on the same compiler the image ships with and this file
# can be copied between repos unchanged.
- name: Resolve the pinned Rust version
id: rust
run: |
set -eu
FILE=deploy/docker/Dockerfile
[ -f "$FILE" ] || FILE=Dockerfile
VERSION=$(grep -E '^ARG RUST_VERSION=' "$FILE" | head -1 | cut -d= -f2)
[ -n "$VERSION" ] || { echo "::error::no ARG RUST_VERSION in $FILE"; exit 1; }
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "checking with rust ${VERSION}"
# The workspace is streamed into the container over stdin rather than
# bind-mounted, so this works whether the runner shares a filesystem with
# the daemon or not.
#
# The image is rust + node, built from .gitea/scripts/ci.Dockerfile: the
# report needs cargo to resolve the workspace graph and Node to read the
# registries, and neither official image has both.
#
# RUSTUP_TOOLCHAIN overrides rust-toolchain.toml: the image's own
# toolchain is enough to read Cargo.lock, and honouring the file would
# make every run fetch a second compiler for no benefit.
#
# Cargo is deliberately *not* run offline: at least one project here
# depends on a crate published to a private registry, and `--offline`
# turns that into a failed resolution and therefore no report at all.
- name: Generate report
run: |
set -eu
VERSION="${{ steps.rust.outputs.version }}"
docker build \
--build-arg "RUST_VERSION=${VERSION}" \
--tag "dep-check:${VERSION}" \
--file .gitea/scripts/ci.Dockerfile \
.gitea/scripts > /dev/null
tar cf - --exclude=./.git --exclude=./target . | docker run --rm -i \
-e RUSTUP_TOOLCHAIN="${VERSION}" \
-e CARGO_TERM_COLOR=never \
"dep-check:${VERSION}" \
sh -ec 'mkdir -p /work && cd /work && tar xf - && exec node .gitea/scripts/dependency-report.mjs' \
> /tmp/report.md 2> /tmp/report.err || {
echo "::error::the dependency report failed to generate"
cat /tmp/report.err
exit 1
}
cat /tmp/report.md
grep -E '::dep-check::' /tmp/report.err || true
# An empty report is a broken check, not a clean bill of health.
# Failing here keeps the tracking issue open rather than closing it on
# the strength of a file that never got written.
test -s /tmp/report.md
grep -oE 'routine=[0-9]+ breaking=[0-9]+ images=[0-9]+ rust=[0-9]+' /tmp/report.err \
> /tmp/counts
cat /tmp/counts
- name: Open, update or close the tracking issue
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -eu
API="https://gitea.milesward.dev/api/v1"
node -e '
const fs = require("fs");
const report = fs.readFileSync("/tmp/report.md", "utf8").trim();
const counts = fs.readFileSync("/tmp/counts", "utf8").trim();
const [routine, breaking, images, rust] = counts.match(/[0-9]+/g).map(Number);
const actionable = routine + breaking + images + rust;
const body =
(actionable === 0
? "Everything tracked is current. Closing this until something changes.\n\n"
: "") +
report +
"\n\n---\n\n" +
"Rewritten by `.gitea/workflows/dependency-check.yml` every 6 hours, so this is " +
"always the latest report rather than a pile of comments.";
fs.writeFileSync("/tmp/state.json", JSON.stringify({ actionable, body }));
'
ACTIONABLE=$(node -e 'process.stdout.write(String(require("/tmp/state.json").actionable))')
echo "actionable=$ACTIONABLE"
# Existing issue by title, any state: a previously closed one is
# exactly the one to update and reopen, and searching all states is
# also what stops a duplicate being created after a close.
#
# The `|| true` matters: `set -e` plus `pipefail` would abort the step
# on a transient API error, and creating a second issue is a far worse
# outcome than missing one run.
NUMBER=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/repos/${REPO}/issues?state=all&type=issues&limit=100" \
| node -e '
let s = "";
process.stdin.on("data", d => s += d).on("end", () => {
try {
const issues = JSON.parse(s);
const hit = issues.find(i => i.title === "Dependency updates");
process.stdout.write(hit ? String(hit.number) : "");
} catch { process.stdout.write(""); }
});
' || true)
BODY=$(node -e 'process.stdout.write(JSON.stringify({ body: require("/tmp/state.json").body }))')
if [ -z "$NUMBER" ]; then
if [ "$ACTIONABLE" = "0" ]; then
echo "everything current and no issue open — nothing to do"
exit 0
fi
curl -sf -X POST -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(node -e 'process.stdout.write(JSON.stringify({ title: "Dependency updates", body: require("/tmp/state.json").body }))')" \
"${API}/repos/${REPO}/issues" > /dev/null
echo "opened the tracking issue"
exit 0
fi
curl -sf -X PATCH -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$BODY" "${API}/repos/${REPO}/issues/${NUMBER}" > /dev/null
echo "updated issue #${NUMBER}"
# Reopen when there is work, close when there isn't. Done as a second
# call because the state is not part of the body above.
if [ "$ACTIONABLE" = "0" ]; then WANT=closed; else WANT=open; fi
STATE=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/repos/${REPO}/issues/${NUMBER}" \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).state))')
if [ "$STATE" != "$WANT" ]; then
curl -sf -X PATCH -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"state\":\"${WANT}\"}" "${API}/repos/${REPO}/issues/${NUMBER}" > /dev/null
echo "issue #${NUMBER}: ${STATE} -> ${WANT}"
fi
Generated
+8 -8
View File
@@ -2758,7 +2758,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openpxe"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"axum",
@@ -2778,7 +2778,7 @@ dependencies = [
[[package]]
name = "openpxe-core"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"base64",
@@ -2803,7 +2803,7 @@ dependencies = [
[[package]]
name = "openpxe-dhcp-proxy"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"dhcproto",
@@ -2818,7 +2818,7 @@ dependencies = [
[[package]]
name = "openpxe-http-api"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"axum",
@@ -2852,7 +2852,7 @@ dependencies = [
[[package]]
name = "openpxe-ipxe-assets"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"openpxe-core",
"rust-embed",
@@ -2861,7 +2861,7 @@ dependencies = [
[[package]]
name = "openpxe-iso-store"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"bcrypt",
@@ -2887,7 +2887,7 @@ dependencies = [
[[package]]
name = "openpxe-tftp"
version = "0.8.1"
version = "0.8.2"
dependencies = [
"anyhow",
"openpxe-core",
@@ -2899,7 +2899,7 @@ dependencies = [
[[package]]
name = "openpxe-webui"
version = "0.8.1"
version = "0.8.2"
[[package]]
name = "p256"
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.8.1"
version = "0.8.2"
edition = "2021"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
+612 -72
View File
@@ -85,10 +85,25 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
// iPXE evaluates `iseq` lazily, so we only set whichever line
// matches. Anything not on the allowlist falls through to a generic
// `<buildarch> <platform>` display.
//
// v0.8.2: one probe per line, each terminated with `|| echo` (`echo`
// is the harmless rc-0 no-op; there is no `true` command in iPXE).
// The previous single line tried to chain the probes with `||` and
// ended in `|| true`, which is not an iPXE command: on any
// arch/platform pair outside the allowlist (e.g. i386 + efi) the
// command failed and aborted the script before the menu was drawn.
let _ = writeln!(s, "set arch-label ${{buildarch}} ${{platform}}");
let _ = writeln!(
s,
"iseq ${{buildarch}} i386 && iseq ${{platform}} pcbios && set arch-label x86 BIOS || iseq ${{buildarch}} x86_64 && iseq ${{platform}} efi && set arch-label x86_64 UEFI || iseq ${{buildarch}} arm64 && iseq ${{platform}} efi && set arch-label arm64 UEFI || true"
"iseq ${{buildarch}} i386 && iseq ${{platform}} pcbios && set arch-label x86 BIOS || echo"
);
let _ = writeln!(
s,
"iseq ${{buildarch}} x86_64 && iseq ${{platform}} efi && set arch-label x86_64 UEFI || echo"
);
let _ = writeln!(
s,
"iseq ${{buildarch}} arm64 && iseq ${{platform}} efi && set arch-label arm64 UEFI || echo"
);
let _ = writeln!(s, ":menu");
let _ = writeln!(s, "menu OpenPXE - network boot menu");
@@ -151,34 +166,44 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
);
}
// iPXE's `||` is strict about what follows. Each test uses `goto menu`
// as the fallthrough target so the parser never sees a bare `||` with
// trailing whitespace — some iPXE builds reject that.
// v0.8.2: dispatch via `goto <item-value>` labels. This is the
// canonical iPXE menu pattern (ipxe.org examples) and, unlike
// `iseq ... && chain ... || goto menu`, it is not sensitive to the
// left-associative `&&`/`||` evaluation order that broke item
// selection in issue #2: for `A && B || C` iPXE runs C whenever A
// fails, so every item except the first used to skip its chain and
// bounce straight back to `goto menu` — redrawing the menu with the
// default (Queued Deployment) item highlighted, as if Enter did
// nothing.
//
// Menu-to-menu transitions use `chain --replace`: the replacement
// image becomes the top-most image, so `exit` from any submenu
// reaches the firmware and repeated Back/forward navigation does not
// nest script images (iPXE tail-recurses into replacements, so the
// stack stays flat). A failed fetch still returns an error, which
// the trailing `|| goto menu` turns into a menu redraw.
let _ = writeln!(s, "goto ${{target}} || goto menu");
let _ = writeln!(s, ":local");
let _ = writeln!(s, "chain --replace {base}/boot/_local.ipxe || goto menu");
let _ = writeln!(s, ":linux");
let _ = writeln!(
s,
"iseq ${{target}} local && chain {base}/boot/_local.ipxe || goto menu"
"chain --replace {base}/boot/_linux_menu.ipxe || goto menu"
);
let _ = writeln!(s, ":windows");
let _ = writeln!(
s,
"iseq ${{target}} linux && chain {base}/boot/_linux_menu.ipxe || goto menu"
"chain --replace {base}/boot/_windows_menu.ipxe || goto menu"
);
let _ = writeln!(s, ":tools");
let _ = writeln!(
s,
"iseq ${{target}} windows && chain {base}/boot/_windows_menu.ipxe || goto menu"
"chain --replace {base}/boot/_tools_menu.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} tools && chain {base}/boot/_tools_menu.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} queue && chain {base}/boot/_queue.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} exit && exit || goto menu"
);
let _ = writeln!(s, "goto menu");
let _ = writeln!(s, ":queue");
let _ = writeln!(s, "chain --replace {base}/boot/_queue.ipxe || goto menu");
let _ = writeln!(s, ":exit");
let _ = writeln!(s, "exit 0");
s
}
@@ -244,18 +269,21 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key b back < Back to main menu");
let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(
s,
"iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"
);
// v0.8.2: `back` is the only fixed item here; every other item value
// is a boot-entry id that can only be resolved at runtime, so dispatch
// on it directly. `echo` is iPXE's rc-0 no-op and keeps a
// non-matching test from aborting the script (issue #2).
let _ = writeln!(s, "iseq ${{target}} back && goto back || echo");
// Pass `?mac=${mac}` so the per-entry handler can record the booting
// client into the Host log. iPXE substitutes `${mac}` before
// the HTTP fetch; if the firmware can't resolve it the literal
// `${mac}` is sent and the server treats it as "unknown".
let _ = writeln!(
s,
"chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
"chain --replace {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
);
let _ = writeln!(s, ":back");
let _ = writeln!(s, "chain --replace {base}/boot.ipxe || goto menu");
s
}
@@ -331,36 +359,34 @@ pub fn render_tools_menu(isos: &[IsoMeta], base_url: &str) -> String {
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item --key b back < Back to main menu");
let _ = writeln!(s, "choose target || goto menu");
let _ = writeln!(
s,
"iseq ${{target}} util && chain {base}/boot/_util.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} shell && chain {base}/boot/_shell.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} nic && chain {base}/boot/_nic.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} reboot && reboot || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} firmware && exit 0 || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"
);
// v0.8.2: fixed item labels jump to their block; category-Tools ISO
// ids (only known at runtime) fall through to the entry chain. Every
// probe ends in `|| echo` so a non-matching test cannot abort the
// script (issue #2).
let _ = writeln!(s, "iseq ${{target}} util && goto util || echo");
let _ = writeln!(s, "iseq ${{target}} shell && goto shell || echo");
let _ = writeln!(s, "iseq ${{target}} nic && goto nic || echo");
let _ = writeln!(s, "iseq ${{target}} reboot && goto reboot || echo");
let _ = writeln!(s, "iseq ${{target}} firmware && goto firmware || echo");
let _ = writeln!(s, "iseq ${{target}} back && goto back || echo");
// Fall-through for category-Tools ISO ids — same as the family
// submenu, carrying `?mac=${mac}` for the boot log.
let _ = writeln!(
s,
"chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
"chain --replace {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
);
let _ = writeln!(s, ":util");
let _ = writeln!(s, "chain --replace {base}/boot/_util.ipxe || goto menu");
let _ = writeln!(s, ":shell");
let _ = writeln!(s, "chain --replace {base}/boot/_shell.ipxe || goto menu");
let _ = writeln!(s, ":nic");
let _ = writeln!(s, "chain --replace {base}/boot/_nic.ipxe || goto menu");
let _ = writeln!(s, ":reboot");
let _ = writeln!(s, "reboot || goto menu");
let _ = writeln!(s, ":firmware");
let _ = writeln!(s, "exit 0");
let _ = writeln!(s, ":back");
let _ = writeln!(s, "chain --replace {base}/boot.ipxe || goto menu");
s
}
@@ -384,7 +410,7 @@ pub fn render_local_hdd(base_url: &str) -> String {
);
let _ = writeln!(s, "exit 0");
let _ = writeln!(s, "# If the above exit returns, loop back to the main menu");
let _ = writeln!(s, "chain {base}/boot.ipxe");
let _ = writeln!(s, "chain --replace {base}/boot.ipxe");
s
}
@@ -402,15 +428,18 @@ pub fn render_util(base_url: &str) -> String {
let _ = writeln!(s, "item --gap");
let _ = writeln!(s, "item back < Back");
let _ = writeln!(s, "choose target || goto menu");
// v0.8.2: dispatch to `:memtest`; anything else (only `back` exists
// today) returns to the Tools menu. The old `iseq back && chain
// _tools_menu || goto menu` pair never ran the Tools chain: the
// `back` line's own `iseq` failed first, so `|| goto menu` redrew
// Utilities and Back appeared dead (issue #2).
let _ = writeln!(s, "iseq ${{target}} memtest && goto memtest || echo");
let _ = writeln!(
s,
"iseq ${{target}} memtest && chain {base}/ipxe/memtest.bin || goto menu"
"chain --replace {base}/boot/_tools_menu.ipxe || goto menu"
);
let _ = writeln!(
s,
"iseq ${{target}} back && chain {base}/boot/_tools_menu.ipxe || goto menu"
);
let _ = writeln!(s, "goto menu");
let _ = writeln!(s, ":memtest");
let _ = writeln!(s, "chain --replace {base}/ipxe/memtest.bin || goto menu");
s
}
@@ -425,7 +454,7 @@ pub fn render_shell(base_url: &str) -> String {
let _ = writeln!(s, "echo 'exit' returns to the main menu");
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "shell");
let _ = writeln!(s, "chain {base}/boot.ipxe");
let _ = writeln!(s, "chain --replace {base}/boot.ipxe");
s
}
@@ -443,8 +472,11 @@ pub fn render_nic_info(base_url: &str) -> String {
let _ = writeln!(s, "route");
let _ = writeln!(s, "echo");
let _ = writeln!(s, "echo 'Press any key to return to menu'");
let _ = writeln!(s, "prompt --timeout 30000");
let _ = writeln!(s, "chain {base}/boot.ipxe");
// v0.8.2: `prompt` returns -ETIMEDOUT when nobody presses a key, and
// a bare failing line aborts the script (dropping the client back to
// firmware). `|| echo` treats the timeout as "return to menu now".
let _ = writeln!(s, "prompt --timeout 30000 || echo");
let _ = writeln!(s, "chain --replace {base}/boot.ipxe");
s
}
@@ -464,7 +496,13 @@ pub fn render_queue_entry(base_url: &str) -> String {
// imgfetch writes the body to a file in iPXE's transient FS; we read
// the queue entry id out of the Location-style header by asking the server
// to put it in the response body as a single token.
let _ = writeln!(s, "chain --replace {base}/api/queue/join?mac=${{mac}}");
// v0.8.2: if the queue endpoint is unreachable, fall back to the main
// menu instead of aborting the whole boot chain into firmware.
let _ = writeln!(
s,
"chain --replace {base}/api/queue/join?mac=${{mac}} \
|| chain --replace {base}/boot.ipxe"
);
s
}
@@ -505,16 +543,21 @@ pub fn render_entry(
cmdline.push_str(extra.trim());
}
}
let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline}");
// v0.8.2: every fetch line carries its own failure branch.
// Menus now enter boot entries with `chain --replace`, so a
// failing line here can no longer be caught by the *menu's*
// `|| goto menu` — the entry script must return to the menu
// itself.
let _ = writeln!(s, "kernel {base}/{kernel_url} {cmdline} || goto failed");
for u in initrd_urls {
let _ = writeln!(s, "initrd {base}/{u}");
let _ = writeln!(s, "initrd {base}/{u} || goto failed");
}
let _ = writeln!(s, "boot || goto failed");
}
BootKind::Wimboot { wimboot_url, files } => {
let _ = writeln!(s, "kernel {base}/{wimboot_url}");
let _ = writeln!(s, "kernel {base}/{wimboot_url} || goto failed");
for (tag, url) in files {
let _ = writeln!(s, "initrd --name {tag} {base}/{url} {tag}");
let _ = writeln!(s, "initrd --name {tag} {base}/{url} {tag} || goto failed");
}
let _ = writeln!(s, "boot || goto failed");
}
@@ -525,7 +568,7 @@ pub fn render_entry(
let _ = writeln!(s, ":failed");
let _ = writeln!(s, "echo Boot failed - returning to menu in 5s");
let _ = writeln!(s, "sleep 5");
let _ = writeln!(s, "chain {base}/boot.ipxe");
let _ = writeln!(s, "chain --replace {base}/boot.ipxe");
s
}
@@ -589,9 +632,14 @@ pub fn render_password_prompt(entry_id: &str, iso_filename: &str, base_url: &str
let _ = writeln!(s, "echo ==========================================");
let _ = writeln!(s, "set password ");
let _ = writeln!(s, "read --secret password");
// v0.8.2: `isset` (empty → nonzero rc) is the correct emptiness
// test. `iseq ${password} ""` compared against the *literal* token
// `""` — iPXE's tokenizer is whitespace-only and has no quoting, so
// an empty Enter never matched and fell through to the failed-login
// path instead of returning to the menu as the banner promises.
let _ = writeln!(
s,
"iseq ${{password}} \"\" && chain {base}/boot.ipxe || goto submit"
"isset ${{password}} && goto submit || chain --replace {base}/boot.ipxe"
);
let _ = writeln!(s, ":submit");
let _ = writeln!(s, "echo Verifying...");
@@ -601,8 +649,8 @@ pub fn render_password_prompt(entry_id: &str, iso_filename: &str, base_url: &str
// as "unknown" rather than refusing to boot.
let _ = writeln!(
s,
"chain {base}/boot/{entry_id}.ipxe?token=${{password:uristring}}&mac=${{mac}} \
|| chain {base}/boot.ipxe"
"chain --replace {base}/boot/{entry_id}.ipxe?token=${{password:uristring}}&mac=${{mac}} \
|| chain --replace {base}/boot.ipxe"
);
s
}
@@ -621,7 +669,7 @@ pub fn render_password_failed(entry_id: &str, base_url: &str) -> String {
let _ = writeln!(s, "sleep 2");
let _ = writeln!(
s,
"chain {base}/boot/{entry_id}.ipxe || chain {base}/boot.ipxe"
"chain --replace {base}/boot/{entry_id}.ipxe || chain --replace {base}/boot.ipxe"
);
s
}
@@ -640,7 +688,13 @@ mod password_tests {
assert!(s.contains("token=${password:uristring}"));
// Empty enter sends back to the main menu, not back into the prompt
// (avoids a wedged client if the operator chose by mistake).
assert!(s.contains("&& chain http://10.0.0.5/boot.ipxe || goto submit"));
assert!(
s.contains(
"isset ${password} && goto submit \
|| chain --replace http://10.0.0.5/boot.ipxe"
),
"empty-password path missing:\n{s}"
);
// Never log/echo the value.
assert!(!s.contains("echo ${password"));
}
@@ -650,7 +704,7 @@ mod password_tests {
let s = render_password_failed("alpha-linux", "http://10.0.0.5");
assert!(s.contains("Wrong password."));
// Re-target the entry so the prompt flow runs again.
assert!(s.contains("chain http://10.0.0.5/boot/alpha-linux.ipxe"));
assert!(s.contains("chain --replace http://10.0.0.5/boot/alpha-linux.ipxe"));
}
#[test]
@@ -741,3 +795,489 @@ mod password_tests {
}
}
}
/// Control-flow regression tests for the generated iPXE menus.
///
/// Issue #2 ("menu cannot select items") was a script-semantics bug:
/// iPXE evaluates `&&` / `||` strictly left to right, so
/// `iseq ${target} X && chain … || goto menu` runs `goto menu` whenever
/// the `iseq` fails — for every item except the first one. Text
/// assertions cannot catch that, so these tests run the generated
/// scripts through a small interpreter that mirrors the parts of iPXE's
/// `system()` (`src/core/exec.c`) and `process_script()`
/// (`src/image/script.c`) that the menus rely on, and check which URL
/// each menu item actually reaches.
#[cfg(test)]
mod menu_dispatch_tests {
use super::*;
use openpxe_iso_store::{IsoCategory, IsoSource, KernelArgs};
use std::collections::HashMap;
const BASE: &str = "http://10.0.0.5";
/// What a script did before it left the interpreter.
#[derive(Debug, Default)]
struct Run {
/// URLs passed to `chain`, in order (normally exactly one).
chains: Vec<String>,
/// Exit status if the script reached `exit`.
exit: Option<u32>,
/// Whether the script reached `reboot`.
rebooted: bool,
}
#[derive(Clone, Copy)]
enum Sep {
/// `&&`
And,
/// `||`
Or,
/// `;`
Always,
}
enum LineOutcome {
Continue,
Jump(usize),
Done,
Abort(String),
}
enum CmdOutcome {
Rc(i32),
Jump(usize),
Done,
Abort(String),
}
/// Minimal interpreter for the subset of iPXE's script language used
/// by the generated menus.
struct IpxeSim {
vars: HashMap<String, String>,
selection: String,
/// Value the simulated operator types at `read`.
input: String,
/// Model `prompt --timeout` expiring with no key pressed.
prompt_times_out: bool,
run: Run,
}
impl IpxeSim {
fn new(selection: &str) -> Self {
let mut vars = HashMap::new();
vars.insert("buildarch".to_string(), "x86_64".to_string());
vars.insert("platform".to_string(), "efi".to_string());
vars.insert("mac".to_string(), "aa:bb:cc:dd:ee:ff".to_string());
vars.insert("password".to_string(), String::new());
Self {
vars,
selection: selection.to_string(),
input: String::new(),
prompt_times_out: false,
run: Run::default(),
}
}
fn with_var(mut self, name: &str, value: &str) -> Self {
self.vars.insert(name.to_string(), value.to_string());
self
}
fn with_input(mut self, value: &str) -> Self {
self.input = value.to_string();
self
}
/// Run a script to completion and report what it did. Panics if
/// the script aborts or never reaches a terminal command — which
/// is exactly how a bad dispatch line manifests on a real client.
fn run(mut self, script: &str) -> Run {
let lines: Vec<&str> = script.lines().collect();
let mut labels: HashMap<String, usize> = HashMap::new();
for (index, line) in lines.iter().enumerate() {
if let Some(name) = line.trim().strip_prefix(':') {
labels.insert(name.trim().to_string(), index);
}
}
let mut pc = 0;
let mut steps = 0;
while pc < lines.len() {
steps += 1;
assert!(
steps < 10_000,
"script looped without making progress\n--- script ---\n{script}"
);
let line = lines[pc].trim().to_string();
if line.is_empty() || line.starts_with('#') || line.starts_with(':') {
pc += 1;
continue;
}
match self.exec_line(&line, &labels) {
LineOutcome::Continue => pc += 1,
LineOutcome::Jump(target) => pc = target + 1,
LineOutcome::Done => return self.run,
LineOutcome::Abort(why) => {
panic!("script aborted at line {pc}: {why}\n--- script ---\n{script}");
}
}
}
panic!("script ended without booting or exiting\n--- script ---\n{script}");
}
/// Expand `${name}` / `${name:modifier}` from the simulated
/// settings. iPXE expands *after* tokenising, so a value
/// containing spaces stays a single argument.
fn expand(&self, token: &str) -> String {
let mut out = String::new();
let mut rest = token;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find('}') else {
out.push_str(&rest[start..]);
return out;
};
let spec = &after[..end];
let name = spec.split(':').next().unwrap_or(spec);
if let Some(value) = self.vars.get(name) {
out.push_str(value);
}
rest = &after[end + 1..];
}
out.push_str(rest);
out
}
/// Execute one command line, mirroring `system()`: commands are
/// separated by `&&` / `||` / `;`, and each separator decides
/// whether the *next* command runs based on the last status.
fn exec_line(&mut self, line: &str, labels: &HashMap<String, usize>) -> LineOutcome {
let mut commands: Vec<(Vec<String>, Option<Sep>)> = Vec::new();
let mut current: Vec<String> = Vec::new();
for token in tokenize(line) {
match token.as_str() {
"&&" => commands.push((std::mem::take(&mut current), Some(Sep::And))),
"||" => commands.push((std::mem::take(&mut current), Some(Sep::Or))),
";" => commands.push((std::mem::take(&mut current), Some(Sep::Always))),
_ => current.push(token),
}
}
commands.push((current, None));
let mut rc = 0;
let mut process = true;
for (argv, sep) in &commands {
if process {
if argv.is_empty() {
return LineOutcome::Abort(format!("empty command in `{line}`"));
}
match self.exec_cmd(argv, labels) {
CmdOutcome::Rc(status) => rc = status,
CmdOutcome::Jump(target) => return LineOutcome::Jump(target),
CmdOutcome::Done => return LineOutcome::Done,
CmdOutcome::Abort(why) => return LineOutcome::Abort(why),
}
}
match sep {
Some(Sep::And) => process = rc == 0,
Some(Sep::Or) => process = rc != 0,
Some(Sep::Always) => process = true,
None => break,
}
}
if rc != 0 {
return LineOutcome::Abort(format!("`{line}` failed with status {rc}"));
}
LineOutcome::Continue
}
fn exec_cmd(&mut self, argv: &[String], labels: &HashMap<String, usize>) -> CmdOutcome {
let cmd = self.expand(&argv[0]);
let args: Vec<String> = argv[1..].iter().map(|a| self.expand(a)).collect();
match cmd.as_str() {
"set" => {
if let Some(name) = args.first() {
let value = if args.len() > 1 {
args[1..].join(" ")
} else {
String::new()
};
self.vars.insert(name.clone(), value);
}
CmdOutcome::Rc(0)
}
"iseq" => {
let equal = args.len() >= 2 && args[0] == args[1];
CmdOutcome::Rc(i32::from(!equal))
}
"isset" => {
let nonempty = args.first().is_some_and(|value| !value.is_empty());
CmdOutcome::Rc(i32::from(!nonempty))
}
"goto" => match args.first().and_then(|label| labels.get(label)) {
Some(target) => CmdOutcome::Jump(*target),
None => CmdOutcome::Abort(format!("goto to missing label in `{argv:?}`")),
},
"exit" => {
let code = args
.first()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(0);
self.run.exit = Some(code);
CmdOutcome::Done
}
// A boot takes over the machine (and `chain --replace`
// additionally replaces the current script image), so the
// interpreter records the URL and stops.
"chain" | "boot" | "imgexec" | "sanboot" => {
let url = args
.iter()
.find(|arg| !arg.starts_with('-'))
.cloned()
.unwrap_or_default();
self.run.chains.push(url);
CmdOutcome::Done
}
"reboot" => {
self.run.rebooted = true;
CmdOutcome::Done
}
"choose" => {
let mut index = 0;
let mut setting = None;
while index < args.len() {
match args[index].as_str() {
"--default" | "--timeout" | "--key" => index += 2,
option if option.starts_with('-') => index += 1,
name => {
setting = Some(name.to_string());
index += 1;
}
}
}
if let Some(name) = setting {
self.vars.insert(name, self.selection.clone());
}
CmdOutcome::Rc(0)
}
"read" => {
if let Some(name) = args.iter().find(|arg| !arg.starts_with('-')) {
let typed = self.input.clone();
self.vars.insert(name.clone(), typed);
}
CmdOutcome::Rc(0)
}
"prompt" => CmdOutcome::Rc(i32::from(self.prompt_times_out)),
"echo" | "console" | "menu" | "item" | "sleep" | "ifstat" | "route" | "shell"
| "kernel" | "initrd" | "imgfetch" => CmdOutcome::Rc(0),
other => CmdOutcome::Abort(format!("unknown iPXE command `{other}`")),
}
}
}
/// iPXE splits on whitespace only — it has no shell quoting.
fn tokenize(line: &str) -> Vec<String> {
line.split_whitespace().map(str::to_string).collect()
}
// ---- fixtures ----
fn test_iso(id: &str, family: DistroFamily, entries: Vec<BootEntry>) -> IsoMeta {
IsoMeta {
id: id.to_string(),
filename: format!("{id}.iso"),
size_bytes: 700 * 1024 * 1024,
sha256_hex: None,
uploaded_at: time::OffsetDateTime::UNIX_EPOCH,
introspection: openpxe_iso_store::IntrospectionReport {
family,
..Default::default()
},
boot_entries: entries,
source: IsoSource::Local,
password_hash: None,
category: IsoCategory::default(),
}
}
fn linux_entry(id: &str) -> BootEntry {
BootEntry {
id: id.to_string(),
title: format!("Title {id}"),
kind: BootKind::LinuxKernel {
kernel_url: format!("iso/{id}/boot/vmlinuz"),
initrd_urls: vec![format!("iso/{id}/boot/initrd")],
args: KernelArgs::default(),
},
}
}
// ---- top-level menu ----
#[test]
fn every_top_menu_item_reaches_its_own_script() {
let settings = Settings::default();
let menu = render_menu(&[], &settings, BASE);
for (item, expected) in [
("local", "/boot/_local.ipxe"),
("linux", "/boot/_linux_menu.ipxe"),
("windows", "/boot/_windows_menu.ipxe"),
("tools", "/boot/_tools_menu.ipxe"),
("queue", "/boot/_queue.ipxe"),
] {
let run = IpxeSim::new(item).run(&menu);
assert_eq!(
run.chains,
vec![format!("{BASE}{expected}")],
"item `{item}` did not reach its script"
);
assert_eq!(run.exit, None, "item `{item}` should not exit");
}
}
#[test]
fn exit_item_exits_the_firmware() {
let menu = render_menu(&[], &Settings::default(), BASE);
let run = IpxeSim::new("exit").run(&menu);
assert_eq!(run.exit, Some(0));
assert!(run.chains.is_empty(), "{:?}", run.chains);
}
#[test]
fn unknown_architecture_does_not_abort_the_menu() {
// The old arch-label line ended in `|| true`, which is not an
// iPXE command: any arch/platform pair outside the allowlist
// aborted the whole script. `|| echo` keeps the generic label.
for (arch, platform) in [("riscv64", "efi"), ("arm64", "pcbios"), ("i386", "efi")] {
let run = IpxeSim::new("local")
.with_var("buildarch", arch)
.with_var("platform", platform)
.run(&render_menu(&[], &Settings::default(), BASE));
assert_eq!(run.chains.len(), 1, "menu aborted on {arch}/{platform}");
}
}
// ---- family (installer) menu ----
#[test]
fn family_menu_dispatches_each_entry_and_back() {
let isos = vec![
test_iso(
"alpha",
DistroFamily::DebianUbuntu,
vec![linux_entry("alpha-linux")],
),
test_iso(
"beta",
DistroFamily::Alpine,
vec![linux_entry("beta-linux")],
),
];
let menu = render_family_menu(&isos, BASE, false);
for id in ["alpha-linux", "beta-linux"] {
let run = IpxeSim::new(id).run(&menu);
assert_eq!(
run.chains,
vec![format!("{BASE}/boot/{id}.ipxe?mac=aa:bb:cc:dd:ee:ff")],
"entry `{id}` fell through to the menu"
);
}
let run = IpxeSim::new("back").run(&menu);
assert_eq!(run.chains, vec![format!("{BASE}/boot.ipxe")]);
}
// ---- tools menu ----
#[test]
fn tools_menu_dispatches_every_item() {
let mut iso = test_iso(
"tool",
DistroFamily::Unknown,
vec![linux_entry("tool-disk")],
);
iso.category = IsoCategory::Tools;
let menu = render_tools_menu(&[iso], BASE);
for (item, expected) in [
("util", format!("{BASE}/boot/_util.ipxe")),
("shell", format!("{BASE}/boot/_shell.ipxe")),
("nic", format!("{BASE}/boot/_nic.ipxe")),
("back", format!("{BASE}/boot.ipxe")),
(
"tool-disk",
format!("{BASE}/boot/tool-disk.ipxe?mac=aa:bb:cc:dd:ee:ff"),
),
] {
let run = IpxeSim::new(item).run(&menu);
assert_eq!(run.chains, vec![expected], "item `{item}`");
}
let run = IpxeSim::new("reboot").run(&menu);
assert!(run.rebooted, "reboot item did not reboot");
let run = IpxeSim::new("firmware").run(&menu);
assert_eq!(run.exit, Some(0), "firmware item did not exit");
}
// ---- utilities menu ----
#[test]
fn util_menu_dispatches_memtest_and_back() {
let menu = render_util(BASE);
let run = IpxeSim::new("memtest").run(&menu);
assert_eq!(run.chains, vec![format!("{BASE}/ipxe/memtest.bin")]);
let run = IpxeSim::new("back").run(&menu);
assert_eq!(run.chains, vec![format!("{BASE}/boot/_tools_menu.ipxe")]);
}
// ---- small scripts reached from the menus ----
#[test]
fn shell_and_queue_scripts_end_in_their_chain() {
let run = IpxeSim::new("").run(&render_shell(BASE));
assert_eq!(run.chains, vec![format!("{BASE}/boot.ipxe")]);
let run = IpxeSim::new("").run(&render_queue_entry(BASE));
assert_eq!(
run.chains,
vec![format!("{BASE}/api/queue/join?mac=aa:bb:cc:dd:ee:ff")]
);
}
#[test]
fn nic_info_prompt_timeout_returns_to_menu() {
let mut sim = IpxeSim::new("");
sim.prompt_times_out = true;
let run = sim.run(&render_nic_info(BASE));
assert_eq!(run.chains, vec![format!("{BASE}/boot.ipxe")]);
}
#[test]
fn password_prompt_empty_enter_returns_to_main_menu() {
let menu = render_password_prompt("alpha-linux", "Alpha.iso", BASE);
let run = IpxeSim::new("").run(&menu);
assert_eq!(run.chains, vec![format!("{BASE}/boot.ipxe")]);
let run = IpxeSim::new("").with_input("hunter2").run(&menu);
assert_eq!(
run.chains,
vec![format!(
"{BASE}/boot/alpha-linux.ipxe?token=hunter2&mac=aa:bb:cc:dd:ee:ff"
)]
);
}
#[test]
fn boot_entry_scripts_recover_to_the_menu_on_failed_fetches() {
// Menus now chain entries with `--replace`, so the entry script —
// not the caller — has to recover from a failed kernel/initrd
// fetch. Otherwise a flaky mirror drops the client to firmware.
let settings = Settings::default();
let s = render_entry(&linux_entry("alpha-linux"), &settings, BASE, None);
for line in s
.lines()
.filter(|line| line.starts_with("kernel ") || line.starts_with("initrd "))
{
assert!(
line.ends_with("|| goto failed"),
"missing failure branch on `{line}`"
);
}
assert!(s.contains("chain --replace http://10.0.0.5/boot.ipxe"));
}
}
@@ -1,5 +1,6 @@
---
source: crates/http-api/src/ipxe_script.rs
assertion_line: 787
expression: rendered
---
#!ipxe
@@ -9,7 +10,9 @@ set esc:hex 1b
set cls ${esc:string}[2J
iseq ${platform} efi && console --picture http://10.0.0.5/branding/pxe-logo --top 290 || console
set arch-label ${buildarch} ${platform}
iseq ${buildarch} i386 && iseq ${platform} pcbios && set arch-label x86 BIOS || iseq ${buildarch} x86_64 && iseq ${platform} efi && set arch-label x86_64 UEFI || iseq ${buildarch} arm64 && iseq ${platform} efi && set arch-label arm64 UEFI || true
iseq ${buildarch} i386 && iseq ${platform} pcbios && set arch-label x86 BIOS || echo
iseq ${buildarch} x86_64 && iseq ${platform} efi && set arch-label x86_64 UEFI || echo
iseq ${buildarch} arm64 && iseq ${platform} efi && set arch-label arm64 UEFI || echo
:menu
menu OpenPXE - network boot menu
item --gap
@@ -27,10 +30,16 @@ item --key x exit Exit iPXE
item --gap
item --gap -- OpenPXE vX.Y.Z - ${arch-label}
choose --default queue --timeout 600000 target || goto menu
iseq ${target} local && chain http://10.0.0.5/boot/_local.ipxe || goto menu
iseq ${target} linux && chain http://10.0.0.5/boot/_linux_menu.ipxe || goto menu
iseq ${target} windows && chain http://10.0.0.5/boot/_windows_menu.ipxe || goto menu
iseq ${target} tools && chain http://10.0.0.5/boot/_tools_menu.ipxe || goto menu
iseq ${target} queue && chain http://10.0.0.5/boot/_queue.ipxe || goto menu
iseq ${target} exit && exit || goto menu
goto menu
goto ${target} || goto menu
:local
chain --replace http://10.0.0.5/boot/_local.ipxe || goto menu
:linux
chain --replace http://10.0.0.5/boot/_linux_menu.ipxe || goto menu
:windows
chain --replace http://10.0.0.5/boot/_windows_menu.ipxe || goto menu
:tools
chain --replace http://10.0.0.5/boot/_tools_menu.ipxe || goto menu
:queue
chain --replace http://10.0.0.5/boot/_queue.ipxe || goto menu
:exit
exit 0
+2 -2
View File
@@ -495,11 +495,11 @@ async fn reboot_and_firmware_exit_in_tools_menu() {
"tools menu missing firmware-exit item:\n{tools}"
);
assert!(
tools.contains("&& reboot"),
tools.contains(":reboot\nreboot || goto menu"),
"reboot command not wired:\n{tools}"
);
assert!(
tools.contains("&& exit 0"),
tools.contains(":firmware\nexit 0"),
"firmware exit command not wired:\n{tools}"
);
}