diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8029b52..9e0cc3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,6 +160,31 @@ jobs: # beyond the build. - run: cargo xtask container + demo: + name: the README's demo + 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 + + # Every command the README's opening section tells a newcomer to type. + # The demo is the first thing anybody sees, so it is the worst thing to + # let rot — and M13 already found what happens to an artifact nothing + # runs. A README that tells someone to type something that no longer + # works is worse than a README with nothing in it. + - run: cargo xtask test-demo + distro: name: a real userspace runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ee1e882 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,159 @@ +# Cut by pushing a tag: +# +# git tag -a v0.1.0 -m "…" && git push origin v0.1.0 +# +# Nothing here runs on its own. A version number is a claim about stability, +# and the person making that claim should be the one typing it. +name: Release + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Tag to build, for a dry run against an existing tag" + required: true + +permissions: + contents: write + packages: write + +env: + CARGO_TERM_COLOR: always + +jobs: + binaries: + name: ${{ matrix.arch }} binaries + runs-on: ubuntu-latest + 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: Build + run: | + cargo build --release --target ${{ matrix.arch }}-unknown-linux-musl \ + -p oxinit -p oxctl -p oxlogd + + # Statically linked, so the tarball is the whole dependency: an init + # system that needed a loader from the filesystem it is about to mount + # would be a poor one. + - name: Pack + run: | + DIR=oxinit-${{ github.ref_name }}-${{ matrix.arch }}-linux-musl + mkdir -p "$DIR" + cp target/${{ matrix.arch }}-unknown-linux-musl/release/{oxinit,oxctl,oxlogd} "$DIR/" + cp README.md LICENSE-MIT LICENSE-APACHE docs/UNIT_FORMAT.md "$DIR/" + tar czf "$DIR.tar.gz" "$DIR" + sha256sum "$DIR.tar.gz" > "$DIR.tar.gz.sha256" + file "$DIR/oxinit" + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.arch }} + path: | + *.tar.gz + *.tar.gz.sha256 + + image: + name: demo image + runs-on: ubuntu-latest + 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 + + # The image the README's first section tells people to run. Built from + # demo/, not units/ — the latter is the test suite and contains services + # that fail on purpose. + - name: Build the demo image + run: cargo xtask demo --build-only --tag ghcr.io/${{ github.repository_owner }}/oxinit:demo + + - name: Log in to ghcr.io + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Push + run: | + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + docker tag ghcr.io/${{ github.repository_owner }}/oxinit:demo ghcr.io/$OWNER/oxinit:demo + docker tag ghcr.io/$OWNER/oxinit:demo ghcr.io/$OWNER/oxinit:${{ github.ref_name }} + docker push ghcr.io/$OWNER/oxinit:demo + docker push ghcr.io/$OWNER/oxinit:${{ github.ref_name }} + + release: + name: publish + runs-on: ubuntu-latest + needs: [binaries, image] + if: startsWith(github.ref, 'refs/tags/') + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v4 + with: + merge-multiple: true + + # `--generate-notes` writes the pull requests merged since the previous + # tag. The milestone titles are the changelog, which is the reason PR + # titles in this repository are written as sentences. + - name: Create the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cat > header.md <<'EOF' + **Pre-alpha.** Nothing is stable, and the unit format may change + between any two releases. + + ### Try it + + ```bash + docker run --rm --name oxinit-demo -p 8080:8080 ghcr.io/OWNER/oxinit:TAG + ``` + + ### Install + + Download the tarball for your architecture below, check it, and put + the binaries somewhere on the target system. They are statically + linked against musl and need nothing else. + + ```bash + sha256sum -c oxinit-TAG-x86_64-linux-musl.tar.gz.sha256 + tar xzf oxinit-TAG-x86_64-linux-musl.tar.gz + ``` + + `oxinit` is the supervisor and belongs at `/sbin/init`; `oxctl` is + the client and `oxlogd` the log writer. Units go in + `/etc/oxinit/units/`, and every key is specified in + [docs/UNIT_FORMAT.md](docs/UNIT_FORMAT.md). + + Verified on x86_64 and aarch64, under QEMU, in a container, and + against a real Alpine userspace. See [ROADMAP.md](ROADMAP.md) for + what each milestone was checked against. + + --- + EOF + + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + sed -i "s|OWNER|$OWNER|g; s|TAG|${{ github.ref_name }}|g" header.md + + gh release create "${{ github.ref_name }}" \ + --title "${{ github.ref_name }}" \ + --notes-file header.md \ + --generate-notes \ + --prerelease \ + *.tar.gz *.tar.gz.sha256 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 05ba050..3e62559 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -963,7 +963,8 @@ tested without a kernel should not be in it. 3. Boots it: ```bash -qemu-system-x86_64 -kernel bzImage -initrd oxinit.cpio.gz -nographic -append "console=ttyS0" +qemu-system-x86_64 -kernel target/vmlinuz-x86_64 \ + -initrd target/oxinit-x86_64.cpio.gz -nographic -append "console=ttyS0" ``` Edit to boot is a few seconds. `-nographic` puts the guest's serial console on diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd4dacc..04e3d88 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,49 +22,38 @@ rustup target add aarch64-unknown-linux-musl 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.** 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. +podman as well, for `cargo xtask container`, `test-distro` and `test-demo`. ```bash # Debian/Ubuntu -sudo apt install qemu-system-x86 cpio +sudo apt install qemu-system-x86 qemu-system-arm cpio # Fedora -sudo dnf install qemu-system-x86 cpio +sudo dnf install qemu-system-x86 qemu-system-aarch64 cpio # Arch -sudo pacman -S qemu-system-x86 cpio +sudo pacman -S qemu-system-x86 qemu-system-aarch64 cpio ``` -**Kernel.** You need a bootable `bzImage`. Any distribution kernel with cgroup -v2 and devtmpfs works; on most Linux systems the installed one is in `/boot`. +**Boot artifacts.** A kernel and a statically linked shell per architecture. +One command each: ```bash -cargo xtask boot --kernel /boot/vmlinuz-$(uname -r) +cargo xtask fetch --arch x86_64 +cargo xtask fetch --arch aarch64 ``` -Or set it once: +That downloads them from Alpine into `target/`, where `xtask` looks for them by +name — `vmlinuz-` and `busybox-`. To use a kernel you already have +instead, `--kernel` and `--shell` override, and so do `$OXINIT_KERNEL_` +and `$OXINIT_SHELL_`. On a Linux host the installed kernel usually works: ```bash -export OXINIT_KERNEL=/path/to/bzImage +cargo xtask boot --kernel /boot/vmlinuz-$(uname -r) ``` -`xtask` looks for `--kernel`, then `OXINIT_KERNEL`, then a `bzImage` in the -repository root. - -The kernel must have `CONFIG_DEVTMPFS`, `CONFIG_CGROUPS` with the v2 hierarchy, -and `CONFIG_BLK_DEV_INITRD`. Kernel 5.14 or later, for `cgroup.kill`. +Any kernel needs `CONFIG_DEVTMPFS`, `CONFIG_CGROUPS` with the v2 hierarchy, and +`CONFIG_BLK_DEV_INITRD`. Version 5.14 or later, for `cgroup.kill`. ## Development loop @@ -76,21 +65,18 @@ This builds a static `oxinit` for `x86_64-unknown-linux-musl`, packs it as `/init` into a cpio initramfs, and boots: ```bash -qemu-system-x86_64 -kernel bzImage -initrd oxinit.cpio.gz -nographic -append "console=ttyS0" +qemu-system-x86_64 -kernel target/vmlinuz-x86_64 \ + -initrd target/oxinit-x86_64.cpio.gz -nographic -append "console=ttyS0" ``` Edit to boot takes a few seconds. `-nographic` puts the guest serial console on your terminal. `Ctrl-A X` exits QEMU. `Ctrl-C` goes to the guest, not to QEMU. -The image holds only `/init` unless you give it a shell: - -```bash -cargo xtask boot --shell /path/to/busybox -``` - -It must be statically linked, since the image has no libraries. Without it the -boot still exercises the mounts, console, signalfd, and reap loop, but oxinit -has nothing to supervise and logs a spawn failure for `/bin/sh`. +The image holds only `/init` unless there is a shell to put in it, which is +what `cargo xtask fetch` provides. It must be statically linked, since the +image has no libraries. Without one the boot still exercises the mounts, the +console, the signalfd and the reap loop, but oxinit has nothing to supervise +and logs a spawn failure for `/bin/sh`. Use this loop rather than reasoning about whether the boot path works. Early boot has too many ways to fail silently. @@ -103,16 +89,14 @@ oxinit-graph` runs on any host with a Rust toolchain, and that is where most of Booting from macOS needs two extra pieces: -- **A cross linker.** `rustup target add x86_64-unknown-linux-musl -rustup target add aarch64-unknown-linux-musl` installs the - standard library but not something that can link it. Use - [`cargo-zigbuild`](https://github.com/rust-cross/cargo-zigbuild), which needs - only `zig`, or a `musl-cross` toolchain from Homebrew with the linker - configured in `.cargo/config.toml`. -- **Patience on Apple Silicon.** `qemu-system-x86_64` there is full emulation, - not virtualization, so the boot is a few seconds rather than under one. It - works. Building for `aarch64-unknown-linux-musl` and booting - `qemu-system-aarch64` is faster but is not the tested path. +- **A cross linker.** `rustup target add` installs the standard library but not + something that can link it. `.cargo/config.toml` in this repository points + both musl targets at `rust-lld`, which ships with every rustup toolchain, so + this is already handled — but that is why the file exists. +- **Patience for the foreign architecture.** Emulating the one your host is not + has no hardware acceleration behind it, and the whole boot runs through + QEMU's JIT. Both are tested — `cargo xtask test-boot --arch all` — and the + slower of the two takes about ninety seconds rather than about thirty. A Linux VM with a distribution kernel is the shorter route if you are doing kernel-facing work. @@ -137,12 +121,14 @@ cargo test -p oxinit-timer # M8: schedules and the calendar cargo xtask test-boot # M5: boots QEMU, asserts on serial cargo xtask test-boot --arch all # M9: x86_64 and aarch64, in turn cargo xtask container # M6: runs it in Docker, same +cargo xtask test-demo # M16: every command the README gives cargo xtask test-distro # M10: a real Alpine userspace ``` -`oxinit-unit` and `oxinit-graph` will have no Linux dependencies and will run -anywhere. Parser and graph logic belongs there, with tests, rather than in -`oxinit`. +The library crates have no Linux dependencies and run anywhere. Parser, graph, +restart policy, log format and calendar arithmetic belong there, with tests, +rather than in `oxinit` — which refuses to compile off Linux and so cannot be +tested on a host at all. `test-boot` boots with a timeout and matches expected lines in the serial log. A test that hangs fails on the timeout instead of blocking the run. @@ -152,6 +138,10 @@ packs the same staged root filesystem as a `FROM scratch` image, runs it, stops it the way an operator would, and asserts on the log and the exit code. `--privileged` adds a writable cgroupfs and the assertions that need one. +`test-demo` runs every command the README's opening section tells a newcomer to +type, against the image built from `demo/`. That README is the first thing +anybody reads, and this is what stops it quietly ceasing to be true. + ## Code policy Full rules are in [CLAUDE.md](CLAUDE.md). The ones that get PRs rejected: @@ -165,11 +155,14 @@ in PID 1 is a kernel panic. **No async in `crates/oxinit`.** One thread, one `epoll` loop. Other crates are unconstrained. -**Unsafe is quarantined.** Syscalls go through `rustix`. Anything `rustix` does -not cover lives in `oxinit::sys::raw`, with a `// SAFETY:` comment per block -stating the invariant and why it holds at that call site. A PR adding `unsafe` -elsewhere will be asked to move it. A PR adding `unsafe` without a `// SAFETY:` -comment will be asked to write one. +**Unsafe is quarantined, and the build enforces it.** Syscalls go through +`rustix`. Anything `rustix` does not cover lives in `oxinit::sys::raw`, with a +`// SAFETY:` comment per block stating the invariant and why it holds at that +call site. The `oxinit` crate denies `unsafe_code` with that one module +relaxing it, and every crate that should contain none carries `forbid`, so a PR +adding `unsafe` elsewhere does not compile rather than being asked to move it. +A PR adding one to `sys::raw` without a `// SAFETY:` comment will be asked to +write one. **Dependencies need a reason.** The dependency tree of PID 1 is part of its attack surface. State the reason in the commit message. diff --git a/README.md b/README.md index af3ac86..49733f9 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,88 @@ A service manager and PID 1 for Linux, written in Rust. **Pre-alpha. Nothing is stable.** See [ROADMAP.md](ROADMAP.md) for what works. +## What this is, in one picture + +An init system is the first program the kernel starts. It is process number 1, +and it is responsible for every other process on the machine: starting them in +the right order, restarting them when they die, and stopping them cleanly when +the machine goes down. If it exits, the kernel panics and the machine is gone. + +```mermaid +flowchart TD + K[Linux kernel] -->|starts as PID 1| O[oxinit] + O -->|starts, in order| A[database] + O -->|restarts when it dies| B[web server] + O -->|starts on a schedule| C[backup job] + O -->|starts on a connection| D[rarely-used service] + O -.->|stdout and stderr| L[oxlogd → /var/log/oxinit] + E[you] -->|oxctl| O +``` + +`oxctl` and `oxlogd` are separate programs that talk to oxinit over a socket. +That is deliberate: a bug in the log writer kills the log writer, and a bug in +PID 1 kills the machine. + +## Try it — one command + +You need [Docker](https://docs.docker.com/get-started/get-docker/) and nothing +else. No Rust, no virtual machine, no kernel. + +```bash +docker run --rm --name oxinit-demo -p 8080:8080 ghcr.io/youhide/oxinit:demo +``` + +oxinit boots as PID 1 inside the container and starts a handful of small +services. You will see it resolve them in order, report each one, and then sit +there supervising. + +The first few lines are oxinit saying what the container runtime will not let +it do — mount `/run`, set the hostname, write to the cgroup filesystem. That is +not a failure and not noise: a supervisor that pretended those worked would be +lying about the machine it is on. + +**Look at what it is running.** In a second terminal: + +```bash +docker exec oxinit-demo /bin/oxctl list +``` + +**Read a service's log.** `counter` writes a line every two seconds, into its +own file rather than onto the console: + +```bash +docker exec oxinit-demo /bin/oxctl logs counter +``` + +**Watch a service start because you asked for something.** Nothing is +listening on port 8080 — no process, that is, though the port is bound. The +request is what starts the service: + +```bash +curl localhost:8080 +``` + +**Stop it the way a real system would.** `docker stop` sends `SIGTERM`, which +is a request to shut down, not a kill: + +```bash +docker stop oxinit-demo +``` + +oxinit stops every service in the reverse of the order it started them, waits +for each to actually be gone, and exits `0`. Most container images have to be +killed after a ten-second grace period; that shows up as exit code 137. + +The units the demo runs are in [demo/](demo/), one small file each, and they +are meant to be read. + +To build and run it yourself instead of pulling the published image: + +```bash +cargo xtask fetch --arch x86_64 # a kernel and a busybox, into target/ +cargo xtask demo # build the image and run it +``` + ## Why Every init system in production use is written in C: systemd, OpenRC, runit, @@ -115,6 +197,40 @@ clock being corrected under it. A firing that arrives while the service is still running is skipped rather than queued, and a failed run does not stop the schedule. +## What oxinit does with a service + +Every service moves through the same states, and the edges are what the +`restart` policy and the readiness `type` decide between. + +```mermaid +stateDiagram-v2 + [*] --> Inactive + Inactive --> Activating: start requested,
and everything it is `after` has finished + Activating --> Active: ready — depends on `type` + Activating --> Failed: exec failed, or `start-sec` expired + Activating --> Inactive: a `oneshot` exited 0 + Active --> Deactivating: stop requested, or a watchdog miss + Active --> Failed: exited nonzero, or on a signal + Deactivating --> Inactive: its cgroup emptied in time + Deactivating --> Failed: `stop-sec` expired, killed by cgroup + Failed --> Restarting: the `restart` policy applies + Inactive --> Restarting: the `restart` policy applies + Restarting --> Activating: the backoff elapsed +``` + +"Ready" is the interesting one, and it is what `type` selects: + +| `type` | Becomes `Active` when | +|-----------|-----------------------------------------------------------------| +| `simple` | `exec` succeeded. No more accurate than that. | +| `oneshot` | The process exited with status 0 — and it goes `Inactive`, not `Active`. | +| `notify` | The service sent `READY=1` on the notify socket. | +| `forking` | The initial process exited and the cgroup still has something in it. | + +Backoff doubles per consecutive failure and resets once the service has stayed +up longer than its own current delay — without that, a service that crashes +once a day eventually takes hours to come back. + ## oxctl `oxctl` talks to PID 1 over `/run/oxinit/control.sock`. It is a separate @@ -226,12 +342,13 @@ container's PID 1. | **M13** | Coverage for seven behaviours the milestones claimed and nothing re-ran. | | **M14** | Calendar schedules on the wall clock; datagram sockets; the list closed. | | **M15** | The documents' invariants enforced by the build, not by discipline. | +| **M16** | A demo, a released binary, and a README that starts at the beginning. | 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 M15. [ROADMAP.md](ROADMAP.md) has the breakdown — +Nothing is scheduled after M16. [ROADMAP.md](ROADMAP.md) has the breakdown — what each milestone was verified against, what was deferred out of it, and which remaining ideas are deliberately not being taken. @@ -254,7 +371,8 @@ This builds a static binary for `x86_64-unknown-linux-musl`, packs it into a single-file cpio initramfs, and boots it: ```bash -qemu-system-x86_64 -kernel bzImage -initrd oxinit.cpio.gz -nographic -append "console=ttyS0" +qemu-system-x86_64 -kernel target/vmlinuz-x86_64 \ + -initrd target/oxinit-x86_64.cpio.gz -nographic -append "console=ttyS0" ``` Edit to boot takes a few seconds. `Ctrl-A X` exits QEMU. Setup details are in diff --git a/ROADMAP.md b/ROADMAP.md index 760d773..8f229ba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -894,6 +894,75 @@ CLAUDE.md and ARCHITECTURE.md and had never existed. The integration tests are the `xtask` suites, and the directory that does exist and was not listed is `units/`. +## M16 — A way in + +**Done.** + +Everything above is written for someone implementing an init system. Nothing +was written for someone deciding whether to look at one, and the shortest path +to running it required a Rust toolchain, a musl target, QEMU, `cpio` and a +kernel you had to go and find. + +- [x] `demo/`: eight units that show what oxinit does, none of which fail on + purpose. +- [x] `cargo xtask demo`, and a published image, so the first command needs + Docker and nothing else. +- [x] A README opening that explains what an init system *is* before saying + why this one exists, with diagrams. +- [x] `cargo xtask test-demo`, in CI, so the README cannot quietly stop being + true. +- [x] A release workflow: static binaries for both architectures, checksums, + generated notes, and the demo image on ghcr.io. + +**The unit set was the problem.** `units/` is the test suite. It contains a +service that fails forever, one that hangs, one that ignores `SIGTERM`, one +that requires something broken, and one that never signals readiness — all +deliberate, all necessary, and all of it reads as a crash to somebody seeing +oxinit for the first time. `demo/` is eight files that work, each one showing +exactly one idea: ordering, readiness, logs, a schedule, socket activation. + +**The demo is tested, and that is the point.** M13 found seven behaviours that +had been verified once and never again, and the worst of them had a unit file +whose comment described a test nothing ran. The README's opening is the same +hazard with a wider blast radius: `test-demo` runs every command it tells a +newcomer to type — `oxctl list`, `oxctl logs counter`, `curl localhost:8080`, +`docker stop` — and checks that `webhook` is *not* running before the request +that starts it. + +**No tag is cut here.** The workflow fires on a pushed tag and nothing else. A +version number is a claim about stability, and the person making that claim +should be the one typing it. + +Verified: the demo boots, `curl localhost:8080` returns from a service that +was inactive a moment earlier, `oxctl logs counter` shows timestamped ticks, +and `docker stop` exits 0 in under a second. + +**And CI found a bug in PID 1 while this was in review.** The distribution +suite failed one assertion — not on anything M16 touched, and it had passed on +`main`. It reproduced as a hang, not as the missing line it reported. + +A unit whose stop *completes* during a shutdown took its restart policy and +went to `Restarting`. `settled` waits for every unit that is not `Inactive` or +`Failed`, and nothing ever starts one again on the way down, so the machine sat +there until the ninety-second whole-shutdown deadline and then went down +anyway. `stop` had always cancelled a *pending* backoff for exactly this reason +— the comment saying so is still there — and the hole was the stop that +finishes afterwards. + +Two things came out of it. The clock that ends a test image is now anchored to +the default target rather than to the start of boot: counting from boot made +the budget the sum of how long the machine takes to come up, which varies with +the image and the host, and how long the assertions need, which does not. M11 +made boot wait for each unit to finish activating rather than merely to be +issued, and M13 and M14 added units to wait for; the number had not moved since +M5, and it went over on the slowest image on a loaded runner. + +And the bug got a unit of its own. `lingers` misses its watchdog thirteen +seconds after the target and ignores `SIGTERM`, so only `cgroup.kill` ends it +twelve seconds later — and the test image asks for a shutdown sixteen seconds +in, inside that window with margin on both sides. Reproducing it by luck once +is not a regression test. + ## Not doing, and why These were on the list. They are coming off it with a reason rather than diff --git a/crates/listen-probe/src/main.rs b/crates/listen-probe/src/main.rs index c89bd0a..181a493 100644 --- a/crates/listen-probe/src/main.rs +++ b/crates/listen-probe/src/main.rs @@ -7,6 +7,7 @@ //! Usage: //! //! ```text +//! listen-probe --http NAME # the activated service, answering HTTP //! listen-probe --serve NAME # the activated service, over a stream //! listen-probe --recv NAME # the activated service, over a datagram //! listen-probe --connect PATH # the client that triggers a stream @@ -25,6 +26,7 @@ use std::io::{Read as _, Write as _}; use std::net::Shutdown; +use std::net::TcpListener; use std::os::fd::FromRawFd as _; use std::os::unix::net::{UnixDatagram, UnixListener, UnixStream}; use std::process::ExitCode; @@ -35,7 +37,9 @@ const FIRST_LISTEN_FD: i32 = 3; fn main() -> ExitCode { let args: Vec = std::env::args().collect(); - let result = if let Some(name) = flag(&args, "--serve") { + let result = if let Some(name) = flag(&args, "--http") { + http(name) + } else if let Some(name) = flag(&args, "--serve") { serve(name) } else if let Some(name) = flag(&args, "--recv") { recv(name) @@ -44,7 +48,7 @@ fn main() -> ExitCode { } else if let Some(path) = flag(&args, "--send") { send(path) } else { - Err("usage: listen-probe --serve|--recv NAME | --connect|--send PATH".to_owned()) + Err("usage: listen-probe --http|--serve|--recv NAME | --connect|--send PATH".to_owned()) }; match result { @@ -122,6 +126,47 @@ fn inherited(name: &str) -> Result { Ok(index) } +/// Answer one HTTP request and exit. +/// +/// Not a web server. It exists so that the demo image can be poked with +/// `curl`, which is the shortest path from "what is socket activation" to +/// seeing it happen: nothing is running, the request arrives, oxinit starts +/// this, and the reply comes back on a connection that was never refused. +fn http(name: &str) -> Result<(), String> { + let index = inherited(name)?; + + // SAFETY: the descriptor was inherited from oxinit at this number, nothing + // else in this process owns it, and the protocol says it is a listening + // socket. A test fixture, not PID 1. + let listener = unsafe { TcpListener::from_raw_fd(FIRST_LISTEN_FD + index) }; + + let (mut stream, from) = listener.accept().map_err(|e| format!("accept: {e}"))?; + println!("listen-probe: connection from {from}"); + + // Read and discard the request line. Answering without reading would have + // the client see a reset before it finished writing. + let mut request = [0u8; 1024]; + let _ = stream.read(&mut request); + + const BODY: &str = "oxinit started this service because you connected.\n\ + It was not running a moment ago.\n"; + + let response = format!( + "HTTP/1.1 200 OK\r\n\ + Content-Type: text/plain\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n{BODY}", + BODY.len() + ); + + stream + .write_all(response.as_bytes()) + .map_err(|e| format!("write: {e}"))?; + + Ok(()) +} + /// The datagram half of `serve`. /// /// The datagram that started this service is already in the socket's queue — diff --git a/crates/oxinit/src/supervisor.rs b/crates/oxinit/src/supervisor.rs index 0fb0812..7093559 100644 --- a/crates/oxinit/src/supervisor.rs +++ b/crates/oxinit/src/supervisor.rs @@ -666,7 +666,15 @@ impl Supervisor { let deactivating = instance.state == State::Deactivating; let oneshot = ty == Some(ServiceType::Oneshot); + let going_down = self.shutdown.is_some(); + match instance.on_exit(exit) { + // Same as above: on the way down a restart is a unit `settled` + // waits for and nothing ever starts. + Some(_) if going_down => { + instance.cancel_restart(); + println!("oxinit: {name} {exit}; not restarting, the machine is going down"); + } Some(delay) => { println!( "oxinit: {name} {exit}, restarting in {:?} (restart {})", @@ -810,7 +818,19 @@ impl Supervisor { return; } + let going_down = self.shutdown.is_some(); + match instance.deactivated() { + // Nothing comes back on the way down, and a unit left `Restarting` + // is a unit `settled` waits for — so this would hold the machine + // open until the whole-shutdown deadline and then go down anyway. + // `stop` has always cancelled a pending backoff for exactly this + // reason; the hole was a stop that *completes* mid-shutdown and + // takes the policy on the way out. + Some(_) if going_down => { + instance.cancel_restart(); + println!("oxinit: {name} stopped; not restarting, the machine is going down"); + } Some(delay) => { println!( "oxinit: {name} stopped, restarting in {:?} (restart {})", diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index c707c26..bc5f629 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -99,6 +99,8 @@ fn main() { .map(|path| println!("{}", path.display())), "test-boot" => test_boot_all(&rest), "container" => container(&rest), + "demo" => demo(&rest), + "test-demo" => test_demo(&rest), "test-distro" => test_distro(&rest), "fetch" => fetch(&rest), "" | "help" | "--help" | "-h" => { @@ -123,6 +125,8 @@ commands: boot build, pack an initramfs, and boot it under QEMU test-boot boot with a timeout and assert on the serial log container build a container image, run it, and assert on its output + demo build the demo image and run it, the way the README says to + test-demo build the demo image and check every claim the README makes test-distro boot a real distribution userspace and assert on the serial log fetch download the kernel and busybox a boot needs, into target/ @@ -335,6 +339,11 @@ const EXPECTED: &[(&str, &str)] = &[ "M13: %N and %u expand against a running machine", ), ("reloaded", "M5: oxctl reload, which no boot had ever run"), + ( + "lingers stopped; not restarting, the machine is going down", + "M16: a stop that completes mid-shutdown does not take the restart \ + policy, which used to hang PID 1 until the whole-shutdown deadline", + ), ( "oxinit: shutting down to power off", "M5: SIGTERM is handled", @@ -889,6 +898,150 @@ const BOOT_TIMEOUT: u32 = 30; /// 137 no matter how long the wait was. const STOP_GRACE: u32 = 30; +/// The image the README tells a newcomer to run. +/// +/// Same build path as `container`, from `demo/` instead of `units/`, and it +/// runs in the foreground so that what a reader sees here is exactly what the +/// README says they will see. +fn demo(args: &[String]) -> Result<(), String> { + let engine = flag(args, "--engine")?.unwrap_or("docker").to_owned(); + let arch = find_arch(args)?; + let tag = flag(args, "--tag")?.unwrap_or(DEMO_IMAGE).to_owned(); + + let shell = find_shell(arch, args)?; + if shell.is_none() { + return Err(format!( + "the demo needs a shell in the image. Run `cargo xtask fetch --arch {}` first.", + arch.name + )); + } + + let binary = build(arch)?; + let staging = stage_units(arch, &binary, shell.as_deref(), false, "demo")?; + let dockerfile = write_dockerfile()?; + + println!("xtask: building {tag}"); + run(Command::new(&engine).args([ + "build".as_ref(), + "-q".as_ref(), + "-t".as_ref(), + tag.as_ref(), + "-f".as_ref(), + dockerfile.as_os_str(), + staging.as_os_str(), + ]))?; + + if args.iter().any(|arg| arg == "--build-only") { + println!("xtask: built {tag}"); + return Ok(()); + } + + remove_named(&engine, DEMO_NAME); + + println!("xtask: running it. Ctrl-C, or `{engine} stop {DEMO_NAME}`, ends it.\n"); + run(Command::new(&engine).args(["run", "--rm", "--name", DEMO_NAME, "-p", "8080:8080", &tag])) +} + +/// Every command the README's opening section tells a newcomer to run, and +/// what each one has to produce. +/// +/// The demo is the first thing anybody sees, so it is the worst thing to let +/// rot — and this project has already learned once, in M13, that an artifact +/// nothing runs stops being true quietly. A README that tells someone to type +/// something that no longer works is worse than a README with nothing in it. +fn test_demo(args: &[String]) -> Result<(), String> { + let engine = flag(args, "--engine")?.unwrap_or("docker").to_owned(); + let mut build = args.to_vec(); + build.push("--build-only".to_owned()); + demo(&build)?; + + remove_named(&engine, DEMO_NAME); + run(Command::new(&engine) + .args([ + "run", + "-d", + "--name", + DEMO_NAME, + "-p", + "8080:8080", + DEMO_IMAGE, + ]) + .stdout(Stdio::null()))?; + + let result = drive_demo(&engine); + remove_named(&engine, DEMO_NAME); + result +} + +fn drive_demo(engine: &str) -> Result<(), String> { + let mut failures = Vec::new(); + + println!("xtask: waiting for the demo to finish booting"); + if !wait_for_line_in(engine, DEMO_NAME, "oxinit: reached target default", 30)? { + return Err("the demo never reached its default target".to_owned()); + } + + // `docker exec oxinit-demo /bin/oxctl list` + let list = exec_in(engine, DEMO_NAME, &["/bin/oxctl", "list"])?; + for unit in ["clock", "counter", "logs", "webhook-socket"] { + if !list.contains(unit) { + failures.push(format!("`oxctl list` does not mention `{unit}`")); + } + } + + // `docker exec oxinit-demo /bin/oxctl logs counter` + let logs = exec_in(engine, DEMO_NAME, &["/bin/oxctl", "logs", "counter"])?; + if !logs.contains("tick ") { + failures.push("`oxctl logs counter` shows no ticks".to_owned()); + } + + // `curl localhost:8080` — the socket-activated service, which was not + // running until this line. + let before = exec_in(engine, DEMO_NAME, &["/bin/oxctl", "status", "webhook"])?; + if !before.contains("inactive") { + failures.push("`webhook` was already running before anything connected".to_owned()); + } + + let answer = capture("curl", &["-s", "--max-time", "10", "http://localhost:8080"])?; + if !answer.contains("oxinit started this service because you connected") { + failures.push(format!("curl localhost:8080 said {answer:?}")); + } + + // `docker stop` — the whole point, and the thing most images fail. + println!("xtask: stopping it the way the README says to"); + let started = std::time::Instant::now(); + run(Command::new(engine) + .args(["stop", "-t", "30", DEMO_NAME]) + .stdout(Stdio::null()))?; + let took = started.elapsed(); + + let status = inspect_named(engine, DEMO_NAME, "{{.State.ExitCode}}")?; + println!( + "xtask: stopped in {:.1}s, exit code {status}", + took.as_secs_f32() + ); + if status != "0" { + failures.push(format!( + "exit code {status}, not 0 — the README promises a clean stop" + )); + } + + if failures.is_empty() { + println!("xtask: every command the README gives a newcomer works"); + return Ok(()); + } + + Err(format!( + "{} README claim(s) failed:\n {}", + failures.len(), + failures.join("\n ") + )) +} + +/// The published demo image, and the container it runs as. +const DEMO_IMAGE: &str = "oxinit:demo"; +const DEMO_NAME: &str = "oxinit-demo"; + /// `test-boot` for a runtime that is not QEMU. /// /// Builds the same root filesystem the initramfs is packed from, as a @@ -907,7 +1060,7 @@ fn container(args: &[String]) -> Result<(), String> { // `false`, unlike `test-boot`: no unit that ends the test by signalling // PID 1, because what ends this one is the runtime's stop — which is the // thing being tested. - let staging = stage(arch, &binary, shell.as_deref(), false)?; + let staging = stage_units(arch, &binary, shell.as_deref(), false, "units")?; let dockerfile = write_dockerfile()?; println!("xtask: building {CONTAINER_IMAGE} with {engine}"); @@ -1042,8 +1195,12 @@ fn survives_a_shipper_restart(engine: &str) -> Result, String> { /// Run something inside the container and return its standard output. fn exec(engine: &str, argv: &[&str]) -> Result { + exec_in(engine, CONTAINER_NAME, argv) +} + +fn exec_in(engine: &str, name: &str, argv: &[&str]) -> Result { let mut command = Command::new(engine); - command.args(["exec", CONTAINER_NAME]).args(argv); + command.args(["exec", name]).args(argv); let out = command .output() @@ -1062,8 +1219,12 @@ fn exec(engine: &str, argv: &[&str]) -> Result { /// Poll the log until `needle` shows up. `false` on timeout. fn wait_for_line(engine: &str, needle: &str, seconds: u32) -> Result { + wait_for_line_in(engine, CONTAINER_NAME, needle, seconds) +} + +fn wait_for_line_in(engine: &str, name: &str, needle: &str, seconds: u32) -> Result { for _ in 0..seconds * 5 { - if logs(engine)?.contains(needle) { + if logs_of(engine, name)?.contains(needle) { return Ok(true); } std::thread::sleep(std::time::Duration::from_millis(200)); @@ -1077,8 +1238,12 @@ fn wait_for_line(engine: &str, needle: &str, seconds: u32) -> Result Result { + logs_of(engine, CONTAINER_NAME) +} + +fn logs_of(engine: &str, name: &str) -> Result { let out = Command::new(engine) - .args(["logs", CONTAINER_NAME]) + .args(["logs", name]) .output() .map_err(|e| format!("run {engine} logs: {e}"))?; @@ -1086,8 +1251,12 @@ fn logs(engine: &str) -> Result { } fn inspect(engine: &str, format: &str) -> Result { + inspect_named(engine, CONTAINER_NAME, format) +} + +fn inspect_named(engine: &str, name: &str, format: &str) -> Result { let out = Command::new(engine) - .args(["inspect", "-f", format, CONTAINER_NAME]) + .args(["inspect", "-f", format, name]) .output() .map_err(|e| format!("run {engine} inspect: {e}"))?; @@ -1096,8 +1265,12 @@ fn inspect(engine: &str, format: &str) -> Result { /// Best-effort, and called on every path out of a run. fn remove(engine: &str) { + remove_named(engine, CONTAINER_NAME); +} + +fn remove_named(engine: &str, name: &str) { let _ = Command::new(engine) - .args(["rm", "-f", CONTAINER_NAME]) + .args(["rm", "-f", name]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); @@ -1165,7 +1338,23 @@ fn pack_initramfs( /// `/bin/sh`. Without it the boot still proves the mount, console, signalfd, /// and reap paths, which is most of M0. fn stage(arch: Arch, binary: &Path, shell: Option<&Path>, test: bool) -> Result { - let staging = root().join(format!("target/initramfs-{}", arch.name)); + stage_units(arch, binary, shell, test, "units") +} + +/// As [`stage`], but from a named unit directory. +/// +/// `units/` is the test suite: it holds services that fail on purpose, hang on +/// purpose and refuse to stop, because that is what a test image is for. +/// `demo/` is the small clean set the published image runs — none of that +/// belongs in the first thing somebody sees. +fn stage_units( + arch: Arch, + binary: &Path, + shell: Option<&Path>, + test: bool, + units: &str, +) -> Result { + let staging = root().join(format!("target/initramfs-{}-{units}", arch.name)); let _ = fs::remove_dir_all(&staging); fs::create_dir_all(&staging).map_err(|e| format!("create {}: {e}", staging.display()))?; @@ -1210,7 +1399,7 @@ fn stage(arch: Arch, binary: &Path, shell: Option<&Path>, test: bool) -> Result< // Units, if any. `/etc` rather than `/usr/lib`, since a hand-assembled // test image is the operator's, not a package's. - let units_src = root().join("units"); + let units_src = root().join(units); if units_src.is_dir() { let units_dst = staging.join("etc/oxinit/units"); fs::create_dir_all(&units_dst) @@ -1229,7 +1418,7 @@ fn stage(arch: Arch, binary: &Path, shell: Option<&Path>, test: bool) -> Result< } } } - println!("xtask: installed {count} units from units/"); + println!("xtask: installed {count} units from {units}/"); if test { install_test_shutdown(&units_dst)?; @@ -1258,11 +1447,22 @@ fn is_busybox(path: &Path) -> bool { .is_some_and(|name| name.contains("busybox")) } -/// How long the test image runs before asking oxinit to shut down. +/// How long the test image runs *after boot finishes* before asking oxinit to +/// shut down. +/// +/// Long enough for the slowest thing being asserted on: the watchdog miss at +/// three seconds, its stop timeout, the escalation to `cgroup.kill` after +/// that, and a timer that has to fire more than once. /// -/// Long enough for the slowest thing being asserted on — the watchdog miss at -/// three seconds, its stop timeout, and the escalation to `cgroup.kill` after -/// that. +/// Measured from the default target rather than from the start of boot, and +/// that distinction is the whole of it. Counting from boot made the budget the +/// sum of two unrelated things — how long the machine takes to come up, which +/// varies with the image and the host, and how long the assertions need, which +/// does not. M11 then made boot wait for each unit to finish activating rather +/// than merely to be issued, and M13 and M14 added units to wait for; the +/// number here had not moved since M5. It went over on a loaded CI runner +/// against the distribution image, where the boot is slowest, and the unit +/// whose assertion lost the race was the last one in the queue. const TEST_UPTIME: u32 = 16; /// The unit that makes `test-boot` end. @@ -1270,10 +1470,15 @@ const TEST_UPTIME: u32 = 16; /// Only ever written into a test image. `cargo xtask boot` gives you a machine /// that stays up, which is the whole point of it. fn install_test_shutdown(units: &Path) -> Result<(), String> { + // `after = ["default"]` is what anchors the clock to the end of boot. The + // target is reached when everything ordered before it has finished + // activating, so the sleep below covers only what the assertions need and + // none of what the image takes to come up. let unit = format!( "# Written by `cargo xtask test-boot`. Not in units/.\n\ [unit]\n\ - description = \"End the test\"\n\n\ + description = \"End the test\"\n\ + after = [\"default\"]\n\n\ [service]\n\ exec = '/bin/sh -c \"/bin/sleep {TEST_UPTIME}; /bin/kill -TERM 1\"'\n" ); diff --git a/demo/clock.toml b/demo/clock.toml new file mode 100644 index 0000000..990bc7a --- /dev/null +++ b/demo/clock.toml @@ -0,0 +1,13 @@ +# A timer. Starts `stamp` five seconds in, then every fifteen seconds after +# that — measured from the previous firing, not from a clock. +# +# `on-calendar = "hourly"` would put it on the wall clock instead, so a +# correction to the system time moves it to the right hour rather than by the +# size of the correction. +[unit] +description = "Runs the stamp job on a schedule" + +[timer] +service = "stamp" +on-boot = "5s" +interval = "15s" diff --git a/demo/counter.toml b/demo/counter.toml new file mode 100644 index 0000000..8413896 --- /dev/null +++ b/demo/counter.toml @@ -0,0 +1,14 @@ +# A long-running service whose output goes to a file instead of the console. +# +# Read it with `oxctl logs counter`. Each line is timestamped by oxlogd, and +# the file is rotated by size so a chatty service cannot fill the disk. +[unit] +description = "Counts, into its own log" +after = ["logs"] +requires = ["logs"] + +[service] +exec = '/bin/sh -c "n=1; while /bin/true; do /bin/echo \"tick $n\"; n=$((n+1)); /bin/sleep 2; done"' +output = "log" +restart = "on-failure" +stop-sec = "2s" diff --git a/demo/default.toml b/demo/default.toml new file mode 100644 index 0000000..3effea1 --- /dev/null +++ b/demo/default.toml @@ -0,0 +1,16 @@ +# The demo. Everything reachable from here starts at boot. +# +# Deliberately small. The unit set under units/ is the test suite — it contains +# services that fail on purpose, hang on purpose, and refuse to stop, because +# that is what a test image is for. None of that belongs in the first thing +# somebody sees. +[target] + +[unit] +description = "Demo" +requires = ["logs"] +# `webhook-socket`, not `webhook`. The socket is what boot brings up; the +# service it names is deliberately unreachable from here, so the only thing +# that ever starts it is a connection. +wants = ["greeter", "clock", "webhook-socket", "counter"] +after = ["greeter"] diff --git a/demo/greeter.toml b/demo/greeter.toml new file mode 100644 index 0000000..cfc0fcb --- /dev/null +++ b/demo/greeter.toml @@ -0,0 +1,9 @@ +# Runs once and exits. `oneshot` means "started" is not enough — the unit is +# not finished until the process has exited successfully, and anything ordered +# after it waits for that. +[unit] +description = "Says hello, once" + +[service] +type = "oneshot" +exec = '/bin/sh -c "/bin/echo; /bin/echo \" oxinit is PID 1 here. Try: docker exec -it oxinit-demo /bin/oxctl list\"; /bin/echo"' diff --git a/demo/logs.toml b/demo/logs.toml new file mode 100644 index 0000000..f0fcf60 --- /dev/null +++ b/demo/logs.toml @@ -0,0 +1,13 @@ +# The log writer. Everything with `output = "log"` goes through this, and +# `oxctl logs NAME` reads what it wrote. +# +# `notify` means a unit ordered after this one does not start until oxlogd has +# actually connected — not merely until it has been exec'd. +[unit] +description = "Log writer" + +[service] +type = "notify" +exec = "/bin/oxlogd" +restart = "always" +restart-sec = "500ms" diff --git a/demo/stamp.toml b/demo/stamp.toml new file mode 100644 index 0000000..cef41e8 --- /dev/null +++ b/demo/stamp.toml @@ -0,0 +1,8 @@ +# Started only by the timer. Nothing wants this unit, so boot never starts it — +# which is the point of a schedule. +[unit] +description = "Started by the clock" + +[service] +type = "oneshot" +exec = "/bin/date -u" diff --git a/demo/webhook-socket.toml b/demo/webhook-socket.toml new file mode 100644 index 0000000..f25dd57 --- /dev/null +++ b/demo/webhook-socket.toml @@ -0,0 +1,9 @@ +# oxinit binds this port at boot and holds it. Nothing runs until something +# connects — and because the socket is bound before any service starts, +# a client is never refused while the service comes up. +[unit] +description = "Webhook socket" + +[socket] +listen = ["0.0.0.0:8080"] +service = "webhook" diff --git a/demo/webhook.toml b/demo/webhook.toml new file mode 100644 index 0000000..57414ae --- /dev/null +++ b/demo/webhook.toml @@ -0,0 +1,11 @@ +# Started by a connection, not by boot. oxinit hands it the already-bound +# listening socket as file descriptor 3, and tells it so through LISTEN_FDS. +# +# Try it from your own machine: curl localhost:8080 +[unit] +description = "Socket-activated service" + +[service] +type = "oneshot" +exec = "/bin/listen-probe --http webhook-socket" +restart = "no" diff --git a/units/default.toml b/units/default.toml index a3ad477..c6a81d5 100644 --- a/units/default.toml +++ b/units/default.toml @@ -24,6 +24,7 @@ wants = [ "flaky", "signalled", "hangs-back", + "lingers", "specifiers", "muted", "reloader", diff --git a/units/lingers.toml b/units/lingers.toml new file mode 100644 index 0000000..be48ba2 --- /dev/null +++ b/units/lingers.toml @@ -0,0 +1,24 @@ +# Still deactivating when the machine is asked to go down, with a restart +# policy that would apply. +# +# That combination hung PID 1's shutdown for the full ninety-second deadline: +# the watchdog stop completes *during* shutdown, the policy puts the unit in +# `Restarting`, and `settled` waits for every unit that is not `Inactive` or +# `Failed`. Nothing ever started it again — `start` refuses while going down — +# so the machine sat there until the backstop fired. +# +# The timing is deliberate, not incidental. It misses its watchdog about +# thirteen seconds after the default target, and ignores `SIGTERM` so that +# only `cgroup.kill` at `stop-sec` ends it, twelve seconds later. The test +# image asks oxinit to shut down sixteen seconds after the target — inside that +# window with margin on both sides. +[unit] +description = "Deactivates across a shutdown" + +[service] +type = "notify" +exec = "/bin/notify-probe --hang-after 10 --ignore-term" +watchdog-sec = "3s" +stop-sec = "12s" +restart = "on-failure" +restart-sec = "500ms"