diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8796354 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,152 @@ +# Every guarantee in ROADMAP.md was, until this file existed, a claim about +# something someone had run once on their own machine. This runs them. +# +# Split by cost. `check` is seconds and gates everything; the boot suites are +# minutes each and run in parallel with one another. aarch64 has no hardware +# acceleration on an x86_64 runner, so its boot goes through QEMU's JIT and is +# the slowest thing here by a wide margin. +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# A second push supersedes the first. Nothing here is worth finishing for a +# commit that is already history. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + # A failed build should not also cost a stack trace nobody reads. + RUST_BACKTRACE: 1 + +jobs: + check: + name: fmt, clippy, host tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Add the musl targets + run: | + rustup target add x86_64-unknown-linux-musl + rustup target add aarch64-unknown-linux-musl + + - uses: Swatinem/rust-cache@v2 + + - run: cargo fmt --all --check + + # Both targets. The oxinit crate refuses to compile off Linux, and a + # lint that only ever ran against one architecture is a lint that has + # not run against the other. + - run: cargo clippy --target x86_64-unknown-linux-musl --workspace --all-targets -- -D warnings + - run: cargo clippy --target aarch64-unknown-linux-musl --workspace --all-targets -- -D warnings + + # The library crates have no OS dependency and run on the host. The + # parser, the graph, the restart policy, the log format and the calendar + # arithmetic are where the logic errors are. + - name: Host tests + run: cargo test -p oxinit-unit -p oxinit-graph -p oxinit-service -p oxinit-cgroup -p oxinit-user -p oxinit-ipc -p oxinit-log -p oxinit-timer + + boot: + name: boot (${{ matrix.arch }}) + runs-on: ubuntu-latest + needs: check + strategy: + fail-fast: false + matrix: + arch: [x86_64, aarch64] + steps: + - uses: actions/checkout@v4 + + - run: rustup target add ${{ matrix.arch }}-unknown-linux-musl + + - uses: Swatinem/rust-cache@v2 + + - name: QEMU and cpio + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends qemu-system-x86 qemu-system-arm cpio + + # Keyed on the Alpine release the fetch is pinned to, so a bump + # invalidates it and nothing else does. + - name: Cache the boot artifacts + uses: actions/cache@v4 + with: + path: | + target/vmlinuz-${{ matrix.arch }} + target/busybox-${{ matrix.arch }} + key: alpine-v3.22-${{ matrix.arch }} + + - run: cargo xtask fetch --arch ${{ matrix.arch }} + + - run: cargo xtask test-boot --arch ${{ matrix.arch }} + + # The log is the whole evidence, and a failure that only says which + # needle was missing is a failure you have to reproduce locally to read. + - name: The serial log + if: always() + uses: actions/upload-artifact@v4 + with: + name: serial-${{ matrix.arch }} + path: target/test-boot-${{ matrix.arch }}.log + if-no-files-found: ignore + + container: + name: container + runs-on: ubuntu-latest + needs: check + steps: + - uses: actions/checkout@v4 + - run: rustup target add x86_64-unknown-linux-musl + - uses: Swatinem/rust-cache@v2 + + - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cpio + + - name: Cache the boot artifacts + uses: actions/cache@v4 + with: + path: target/busybox-x86_64 + key: alpine-v3.22-x86_64-shell + + - run: cargo xtask fetch --arch x86_64 + + # Docker is already on the runner, which is the only thing this needs + # beyond the build. + - run: cargo xtask container + + distro: + name: a real userspace + runs-on: ubuntu-latest + needs: check + steps: + - uses: actions/checkout@v4 + - run: rustup target add x86_64-unknown-linux-musl + - uses: Swatinem/rust-cache@v2 + + - name: QEMU and cpio + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends qemu-system-x86 cpio + + - name: Cache the boot artifacts + uses: actions/cache@v4 + with: + path: target/vmlinuz-x86_64 + key: alpine-v3.22-x86_64-kernel + + - run: cargo xtask fetch --arch x86_64 + + - run: cargo xtask test-distro + + - name: The serial log + if: always() + uses: actions/upload-artifact@v4 + with: + name: serial-distro + path: target/test-distro-x86_64.log + if-no-files-found: ignore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ae248e..bd4dacc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,12 +24,17 @@ MSRV is stable minus two releases. No nightly features are used. **Tools.** `qemu-system-x86_64`, `qemu-system-aarch64` and `cpio`. Docker or podman as well, if you are running `cargo xtask container`. -**Boot artifacts.** A kernel and a static busybox per architecture, under -`target/`, where `xtask` looks for them by name — `vmlinuz-x86_64`, -`vmlinuz-aarch64`, `busybox-x86_64`, `busybox-aarch64`. Alpine publishes both: -a kernel under `releases//netboot/vmlinuz-lts` and a `busybox-static` -package under `main//`. `--kernel` and `--shell` override, and so do -`$OXINIT_KERNEL_` and `$OXINIT_SHELL_`. +**Boot artifacts.** One command per architecture: + +```bash +cargo xtask fetch --arch x86_64 +cargo xtask fetch --arch aarch64 +``` + +That downloads a kernel and a statically linked busybox from Alpine into +`target/`, where `xtask` looks for them by name. `--kernel` and `--shell` +override, and so do `$OXINIT_KERNEL_` and `$OXINIT_SHELL_`, if you +would rather use a kernel you already have. ```bash # Debian/Ubuntu diff --git a/README.md b/README.md index 171f046..7d0f202 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ A service manager and PID 1 for Linux, written in Rust. [![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](#license) [![Status](https://img.shields.io/badge/status-pre--alpha-orange)](ROADMAP.md) +[![CI](https://github.com/youhide/oxinit/actions/workflows/ci.yml/badge.svg)](https://github.com/youhide/oxinit/actions/workflows/ci.yml) **Pre-alpha. Nothing is stable.** See [ROADMAP.md](ROADMAP.md) for what works. @@ -202,7 +203,7 @@ getting it wrong strands the machine. ## Current state -Milestones 0 through 11 are done. oxinit boots under QEMU and runs as a +Milestones 0 through 12 are done. oxinit boots under QEMU and runs as a container's PID 1. | | | @@ -219,12 +220,13 @@ container's PID 1. | **M9** | aarch64 as a tested target, not an assumed one. Both boot the same suite. | | **M10** | A real distribution userspace, with dynamically linked services. | | **M11** | `after`, `requires` and `conflicts` enforced; `start-sec` bounds activation. | +| **M12** | CI: every suite above, on every push. | One `epoll` loop multiplexes the signalfd, the timerfd, the notify socket, the control socket, every socket unit's listening descriptor and every service cgroup's `cgroup.events`. One thread. No async runtime. -Nothing is scheduled after M11. [ROADMAP.md](ROADMAP.md) has the breakdown, +Nothing is scheduled after M12. [ROADMAP.md](ROADMAP.md) has the breakdown, including what each milestone was verified against and what was deferred out of it. @@ -250,11 +252,15 @@ Edit to boot takes a few seconds. `Ctrl-A X` exits QEMU. Setup details are in Two suites assert on that boot rather than asking you to watch it: ```bash +cargo xtask fetch --arch x86_64 # a kernel and a busybox, into target/ cargo xtask test-boot --arch all # boots x86_64 and aarch64, matches the log cargo xtask container # runs it in Docker, checks the exit code cargo xtask test-distro # boots a real Alpine userspace ``` +All four run in CI on every push, along with `fmt`, `clippy` against both musl +targets, and the host tests. + The library crates test on any host, with no VM and no kernel: ```bash diff --git a/ROADMAP.md b/ROADMAP.md index c172fa5..924cc8d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -710,7 +710,49 @@ Verified under QEMU, both architectures, and against a real Alpine userspace: 29 checks on each architecture, 31 against the distribution image, 146 host tests. -## Beyond M11 +## M12 — Continuous verification + +**Done.** + +Every milestone above ends with a paragraph beginning "Verified". Until this +one, each of those was a claim about something someone had run once, on their +own machine, and nothing re-ran it afterwards. + +- [x] `cargo xtask fetch`: download the kernel and the static busybox an + architecture needs, so the setup is a command rather than a paragraph + about where Alpine keeps things. +- [x] A workflow that runs `fmt`, `clippy` against both musl targets, the host + tests, both boot suites, the container suite, and the distribution + suite. +- [x] Serial logs kept when a boot fails. + +Split by cost. The lint and test job is seconds and gates the rest; the four +boot suites run in parallel with one another. aarch64 has no hardware +acceleration on an x86_64 runner, so its boot goes through QEMU's JIT and is +the slowest thing in the set by a wide margin — which is an argument for +running it, not against: it is the configuration least likely to be exercised +by hand. + +A boot suite that fails tells you which line was missing from a log you do not +have. So the log is uploaded, and a failure is readable without reproducing +it. + +Verified by the thing itself, which is the only way this milestone can be: + +| Job | Result | Time | +|-------------------------|---------|-------| +| fmt, clippy, host tests | success | 0m44s | +| boot (x86_64) | success | 1m33s | +| boot (aarch64) | success | 1m42s | +| container | success | 1m18s | +| a real userspace | success | 1m31s | + +The whole set is under two minutes wall-clock after the gate, which was the +surprise: an emulated ARM64 boot on an x86_64 runner costs nine seconds more +than a native one. The 300-second budget picked in M9 turns out to be an order +of magnitude of headroom rather than a tight fit. + +## Beyond M12 Not planned, not designed, listed only so the questions have an answer: diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index 1f0cd1f..5305782 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -98,6 +98,7 @@ fn main() { "test-boot" => test_boot_all(&rest), "container" => container(&rest), "test-distro" => test_distro(&rest), + "fetch" => fetch(&rest), "" | "help" | "--help" | "-h" => { usage(); Ok(()) @@ -122,6 +123,7 @@ commands: container build a container image, run it, and assert on its output test-distro boot a real distribution userspace and assert on the serial log + fetch download the kernel and busybox a boot needs, into target/ options: --arch NAME x86_64 (default), aarch64, or `all` for test-boot, which @@ -490,6 +492,108 @@ fn check( )) } +/// The distribution the boot artifacts come from. +/// +/// Pinned to a release rather than tracking edge: a kernel is half of what a +/// boot result is attributable to, and "whatever was current that week" is not +/// an attribution. +const ALPINE: &str = "v3.22"; +const MIRROR: &str = "https://dl-cdn.alpinelinux.org/alpine"; + +/// Download the kernel and the shell a boot needs, into `target/`. +/// +/// `xtask` looks for `target/vmlinuz-` and `target/busybox-`, and +/// before this existed every contributor and every CI run had to find them +/// themselves — which meant the documented setup was a paragraph of prose +/// about where Alpine keeps things. +/// +/// Alpine because it publishes both architectures in the same layout, ships a +/// statically linked busybox as a package, and builds a kernel small enough to +/// download on every cache miss. +fn fetch(args: &[String]) -> Result<(), String> { + let arch = find_arch(args)?; + let target = root().join("target"); + fs::create_dir_all(&target).map_err(|e| format!("create {}: {e}", target.display()))?; + + let kernel = target.join(format!("vmlinuz-{}", arch.name)); + if kernel.exists() { + println!("xtask: {} is already here", kernel.display()); + } else { + let url = format!( + "{MIRROR}/{ALPINE}/releases/{}/netboot/vmlinuz-lts", + arch.name + ); + println!("xtask: fetching {url}"); + download(&url, &kernel)?; + } + + let shell = target.join(format!("busybox-{}", arch.name)); + if shell.exists() { + println!("xtask: {} is already here", shell.display()); + return Ok(()); + } + + // The package name carries its version, and the version moves. Read the + // index rather than pinning a filename that goes stale on the next + // point release. + let index = format!("{MIRROR}/{ALPINE}/main/{}/", arch.name); + let listing = capture("curl", &["-sfL", &index])?; + + let package = listing + .match_indices("busybox-static-") + .find_map(|(at, _)| { + let rest = listing.get(at..)?; + let end = rest.find(".apk")? + ".apk".len(); + rest.get(..end) + }) + .ok_or_else(|| format!("no busybox-static package listed at {index}"))?; + + let url = format!("{index}{package}"); + println!("xtask: fetching {url}"); + + let apk = target.join("busybox-static.apk"); + download(&url, &apk)?; + + // An apk is a gzipped tar, and the one file wanted out of it is the + // statically linked binary. + run(Command::new("tar") + .args(["xzf", &apk.to_string_lossy(), "bin/busybox.static"]) + .current_dir(&target))?; + + fs::rename(target.join("bin/busybox.static"), &shell) + .map_err(|e| format!("move busybox.static: {e}"))?; + let _ = fs::remove_dir(target.join("bin")); + let _ = fs::remove_file(&apk); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&shell, fs::Permissions::from_mode(0o755)) + .map_err(|e| format!("chmod {}: {e}", shell.display()))?; + } + + println!("xtask: {} ready", shell.display()); + Ok(()) +} + +fn download(url: &str, to: &Path) -> Result<(), String> { + run(Command::new("curl").args(["-sfL".as_ref(), "-o".as_ref(), to.as_os_str(), url.as_ref()])) +} + +/// Run something and return its standard output. +fn capture(program: &str, args: &[&str]) -> Result { + let out = Command::new(program) + .args(args) + .output() + .map_err(|e| format!("run {program}: {e}"))?; + + if !out.status.success() { + return Err(format!("{program} failed with {}", out.status)); + } + + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + /// The distribution the userspace comes from. /// /// Pinned, because "whatever :latest is today" is not something a test result