diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62dfbd5..c1c967b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,24 @@ jobs: # The smallest build has to keep working, or the NAS target rots unnoticed. # Both features off, and then each on its own: the combination that ships is not # the only one that has to compile. + # **The budget that is actually binding: 0 bytes with the feature off.** Asserted on + # the *output*, never on the exit code — `cargo tree -i` exits 0 printing + # "nothing to print." when the package is an optional dependency whose feature is + # off, which is exactly the state being checked, so an exit-code guard would fire + # whether or not the crate is there. + - name: The default build must not reach the terminal interface + run: | + graph=$(cargo tree -e normal --prefix none --format '{p}') + if echo "$graph" | grep -qE '^(ratatui|crossterm) '; then + echo "ratatui or crossterm is in the default dependency graph"; exit 1 + fi + echo "absent, as it must be" + + # `--all-features` does not cover this: the terminal interface must build for a + # deployment that wants no boot media either. + - name: Build the interface without boot media + run: cargo build --release --no-default-features --features tui + - name: Build without SQLite or boot media run: cargo build --release --no-default-features @@ -196,11 +214,30 @@ jobs: run: command -v cargo-audit >/dev/null || cargo install cargo-audit --locked # `--deny warnings` also fails on an unmaintained or yanked crate, not only on a - # vulnerability. The tree is clean under it today; when something appears that has no - # fix, add `--ignore RUSTSEC-…` here with a line saying why, rather than dropping the - # flag and losing the rest. + # vulnerability. When something appears that has no fix, add `--ignore RUSTSEC-…` + # here **with a line saying why**, rather than dropping the flag and losing the rest. + # + # **`cargo audit` reads `Cargo.lock`, which carries the whole graph whatever the + # feature flags say.** All three below come from `ratatui`, reach the tree only + # through the optional `tui` feature, and are absent from the default dependency + # graph — which the `gates` job asserts separately, on the output of `cargo tree`. + # So the answer server that runs as root on a NAS links none of this code. + # + # * RUSTSEC-2024-0436 — `paste` is unmaintained. A proc-macro, so it runs at build + # time and is not in any shipped binary. No successor is offered. + # * RUSTSEC-2026-0002, RUSTSEC-2026-0253 — `lru` unsoundness (`IterMut` and a + # panic-safety hole in `pop`). ratatui 0.29 pins `lru 0.12`; there is no patched + # 0.12 to move to, and the interface is not in `default`, not in the `.spk`, and + # not in a release artifact. + # + # **Revisit when ratatui moves off `lru 0.12`** — these are unsoundness, not + # unmaintainedness, and they stay ignored only while nothing shipped links them. - name: Advisories - run: cargo audit --deny warnings + run: | + cargo audit --deny warnings \ + --ignore RUSTSEC-2024-0436 \ + --ignore RUSTSEC-2026-0002 \ + --ignore RUSTSEC-2026-0253 cross: name: Cross-compile for the NAS, and package it diff --git a/CLAUDE.md b/CLAUDE.md index d974edf..b8af76c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -416,6 +416,20 @@ spend into an apparent 293% overrun.** | `boot` only | 1,649,048 | | neither | 1,392,544 | +Re-measured 2026-08-30 with the terminal interface, same target and floor: + +| Build | Bytes | Against the default | +|---|---|---| +| default (`sqlite` + `boot`) | 2,900,408 | — | +| default + `tui` | 3,099,816 | **+199,408 (+6.9%)** | + +`tui` also costs **45 locked packages** (74 → 119), and that is the number that matters +more: `Cargo.lock` carries the whole graph whatever the feature flag says, so `cargo audit`, +`clippy --all-features` and `test --all-features` all see them. An advisory reachable only +through that graph gets `--ignore RUSTSEC-…` with that reason written beside it. **The +shipped answer server links none of it**, and CI asserts that on the *output* of +`cargo tree` rather than its exit code. + Re-measured 2026-08-29 on armv7-gnueabihf (floor 2.17), all four in one sitting. **Both tables that held these numbers were stale by roughly 200 KB** — this one and `docs/guide/reference/configuration`, which disagreed with each other as well. @@ -680,28 +694,43 @@ could not check. Note it needs `Resolution::format_name` (the extension), not - **`git checkout ` to undo a deliberately-broken test also undoes the work.** Copy the file aside before breaking it to watch a test fail; a `git checkout` here silently reverted a whole feature and its test, and only a `grep` afterwards caught it. -- **The listing cache made `admin::guarded` blind over the file store.** `version()` there - is the answers directory's mtime, which does not move when a document is written *inside* - an existing identity's directory — so the guard compared a write against itself, found no - difference, and kept something that broke the answer set. It forces both reads through - `Answers::invalidate` now. Note which side matters: a stale `after` is a rollback that - never runs and fails **open**; a stale `before` merely blames this write for a - pre-existing problem and fails closed. -- **A `pid`-only temporary filename disambiguates processes and not threads.** Two writes - to one document inside one process share the path, and one silently takes the other's - content. `store::file::write_atomic` has a counter for this. -- **`log::init` ran before subcommand dispatch, and an unopenable log file is fatal.** On a - packaged deployment `RESCRIPTUM_LOG_FILE` belongs to the service user, so any subcommand - run by anybody else died on `cannot be opened` before its subcommand was looked at. The - destination is now a property of *what is being run*: a server logs where the variable - says, a subcommand logs to stderr. `Config::validate` deliberately stayed fatal for - both — `tests/tftp.rs` pins that. +- **`cargo tree -i ` exits 0, printing `warning: nothing to print.`, when the package + is an optional dependency whose feature is off.** It exits 101 when the package is absent + from the manifest entirely. So a guard written as `cargo tree -i x >/dev/null && exit 1` + fires in *both* states and reddens CI forever. **Assert on the output, never on the exit + code** — `cargo tree -e normal --prefix none --format '{p}' | grep -q '^x '`. Measured + against `rusqlite` in all three feature states. +- **`/bin/true` and `/bin/false` do not exist on macOS**; they are `/usr/bin`. `/bin/sleep` + exists on macOS and `/usr/bin/sleep` does not. A test that spawns a standard tool must + resolve it from a candidate list, or it is the "passed locally, failed in CI for a reason + unrelated to the change" trap with a new hat on. +- **A `pid`-only temporary name disambiguates processes and not threads.** `store::file`'s + `write_atomic` had it, and `edit::scratch_path` reintroduced it two hours after it was + fixed — three tests passed alone and failed in the full suite, because they edited the + same document at once. Add a counter. +- **A `"#` inside `r#"…"#` ends the raw string.** `"#ComputerSystem.Reset"` in a Redfish + fixture does exactly that, and the error is `prefix ... is unknown`, which points + nowhere near it. Use `r##"…"##`. +- **curl's `--fail` discards the response body on an error status** — which for Redfish is + `error.@Message.ExtendedInfo[].Message`, the sentence the vendor wrote about what went + wrong. Read the status with `--write-out` instead, and note that `-w` writes to *stdout* + where the body already is: a newline plus the code, split from the right, keeps both. + And `--data` sends a form content type, which Redfish answers with 415. +- **A Redfish `PATCH` can answer `204 No Content` and do nothing at all.** PiKVM's does, + verified in kvmd's source, while reporting `BootSourceOverrideEnabled: "Disabled"`. So a + boot override must be **read back**; the status code is not evidence. Its `@odata.id` + also says `/redfish/v1/...` while the service is mounted at `/api/redfish/v1`, so **never + follow a URL out of a response body** — take the last segment and compose from `base`. +- **A log follower must notice two kinds of rotation.** `logrotate --create` replaces the + file so the inode changes; `copytruncate` keeps it and empties it, so only the length + going backwards gives it away. Checking one and not the other stops the screen updating + for half the deployments, silently. - **A group's `.ipxe` arms machines that can never be disarmed.** `installed::disarm` moves a *machine's* own document and never a group's, so a machine armed only by its group installs, reports success, is not disarmed, and installs again on its next network boot — while the webhook logs `nothing was claiming it`, which reads like it worked. The project's own `examples/groups/edge-router/boot.ipxe` did this and `check` called it - green; `check` reports it now. + green. `install` refuses it now and `check` reports it. ## Layout on disk, and the core algorithm @@ -801,6 +830,7 @@ and the TOML file wins over the env file**: | `RESCRIPTUM_TIMEOUT_SECS` | `10` | Header-read timeout **and** whole-connection deadline | | `RESCRIPTUM_LOG` | `all` | `all` \| `problems` (drops the requests that worked) \| `off` | | `RESCRIPTUM_LOG_FILE` | unset | A file to append to, or `stdout`/`stderr`. Unopenable is fatal | +| `RESCRIPTUM_CONTROLLERS_FILE` | unset | Out-of-band controllers (`src/controllers.rs`), for `power` and `install`. **The server never reads it** — a broken one must not stop the answer listener. Refused at use if group-readable | | `RESCRIPTUM_MEDIA_DIR` | unset | Installer images. **Unset is the whole off switch for boot media** | | `RESCRIPTUM_MEDIA_ADDR` | `0.0.0.0:8001` | The media listener, when there is a media directory | | `RESCRIPTUM_MEDIA_TIMEOUT_SECS` | `600` | Whole-transfer deadline — deliberately not the answer listener's 10 | @@ -939,7 +969,7 @@ is the procedure*), which `AGENTS.md` also points at. ## Testing expectations -619 tests, plus the package's own harnesses (see *The DSM package*, and note that +734 tests, plus the package's own harnesses (see *The DSM package*, and note that `cargo test` does not run those). `docs/development/testing.md` has the per-suite table; the rules that decide where a test goes: diff --git a/Cargo.lock b/Cargo.lock index 6b8a06d..f20145b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -26,6 +32,21 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.4.4" @@ -42,6 +63,51 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "equivalent" version = "1.0.2" @@ -55,7 +121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -76,6 +142,12 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -97,13 +169,24 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -112,7 +195,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -124,6 +207,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "1.5.0" @@ -171,9 +260,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -205,14 +294,42 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", "hashbrown 0.17.1", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b23a0c8dfe501baac4adf6ebbfa6eddf8f0c07f56b058cc1288017e32397846c" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -246,6 +363,36 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "memchr" version = "2.8.3" @@ -259,8 +406,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", + "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -269,6 +417,35 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -308,15 +485,47 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "rescriptum" version = "0.3.0" dependencies = [ + "crossterm", "http-body-util", "hyper", "hyper-util", "libc", "quick-xml", + "ratatui", "rescriptum", "rusqlite", "serde_json", @@ -350,6 +559,19 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -362,6 +584,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.229" @@ -388,7 +616,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -423,6 +651,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -446,7 +695,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -461,6 +710,34 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + [[package]] name = "syn" version = "2.0.119" @@ -474,9 +751,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -500,7 +777,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -516,7 +793,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -527,7 +804,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -573,6 +850,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -636,12 +942,43 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -651,6 +988,63 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index a795992..56ecff6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,20 @@ serde_yaml_ng = "0.10.0" tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "time", "io-util", "signal", "macros"] } toml_edit = "0.25.13" +# The terminal interface, and **not in `default`**. A terminal UI is layout, wrapping, +# unicode widths, resize, key decoding and mouse escapes — none of which is this project's +# subject, and all of which is a decade of other people's bug reports. This project writes +# its own cpio and SHA-256 because those are twenty-line formats with a specification; +# this is not one of those. +# +# The state it draws lives in `src/tui.rs`, which depends on neither and is compiled and +# tested in every build. What the feature adds is the painting. +# Pinned together: ratatui 0.29 requires crossterm ^0.28.1, and asking for 0.29 here puts +# two incompatible majors in the graph — which cargo reports as an unresolvable `mio` +# conflict, naming neither. +crossterm = { version = "0.28", optional = true } +ratatui = { version = "0.29", optional = true } + [features] default = ["sqlite", "boot"] # SQLite backs the admin API. Turn it off for the smallest possible binary when the @@ -47,6 +61,10 @@ boot = [] # business in a release binary — the resolver keeps it out of one, because nothing but # the dev-dependency below asks for it. test-support = [] +# The terminal interface. Deliberately **not** in `default`: the answer server a NAS runs +# must stay byte-identical without it, and CI asserts that the default dependency graph +# does not reach ratatui. +tui = ["dep:ratatui", "dep:crossterm"] [profile.release] opt-level = "z" diff --git a/docs/development/architecture.fr.md b/docs/development/architecture.fr.md index d03a677..14ee5c7 100644 --- a/docs/development/architecture.fr.md +++ b/docs/development/architecture.fr.md @@ -49,10 +49,17 @@ flowchart TB | `format/` | une interface par format de document. `Doc` parse, fusionne, rend, et signale ses clés de contrôle | | `merge.rs` | la fusion profonde TOML, utilisée par `format` | | `store/` | d'où viennent les documents, derrière un trait de lecture à deux méthodes | -| `admin.rs` | son propre listener, l'auth bearer, le garde-fou d'échecs, et l'annulation qui empêche une écriture de casser le jeu de réponses | +| `guard.rs` | la règle qu'une écriture ne peut pas laisser le jeu de réponses cassé — appliquer, relire, annuler ce qui a cassé. **Ce n'est pas une propriété HTTP**, donc elle vit hors d'`admin` et plusieurs appelants l'utilisent | +| `admin.rs` | son propre listener, l'auth bearer, le garde-fou d'échecs, et la traduction de l'issue d'une écriture gardée en code de statut | | `config.rs` | l'environnement, et la validation qui transforme une configuration dangereuse en erreur de démarrage | | `envfile.rs` | le fichier que nomme `RESCRIPTUM_ENV_FILE` : parsé, jamais découvert, et fatal s'il est illisible | -| `cli.rs` | `render`, `check`, `import`, `export` | +| `cli.rs` | `render`, `check`, `import`, `export`, `status`/`machines`/`groups`, `power`, `install` | +| `controllers.rs` | où le BMC, PiKVM ou PDU d'une machine est décrit. **Le serveur ne le lit jamais** — `power` et `install` le lisent | +| `redfish.rs` | parler à un service Redfish via `curl`, puisqu'il n'y a pas de TLS dans ce binaire | +| `power.rs` | piloter un contrôleur, pour les deux sortes : ce que `on`, `off`, `pxe` et `status` font vraiment, et le sondage borné et concurrent | +| `tail.rs` | suivre le log du serveur depuis un autre processus : rotation, tampon borné, analyse des lignes | +| `edit.rs` | confier un document à `$EDITOR` et stocker ce qui revient, à travers le garde | +| `tui.rs` | ce qu'une interface terminal retient et quand elle a le droit de travailler. **Sans dessin** : ratatui est en mode immédiat, donc l'état est le nôtre, et le garder ici le fait tester dans toutes les builds | | `capture.rs` | l'enregistrement des corps de requête | | `log.rs` | une ligne par événement, des horodatages UTC calculés sans crate de date, et les deux réglages au-dessus : ce qui est gardé, et où cela va | diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 74fd67d..b7cae9f 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -49,10 +49,17 @@ flowchart TB | `format/` | one interface per document format. `Doc` parses, merges, renders, and reports its control keys | | `merge.rs` | the TOML deep merge, used by `format` | | `store/` | where documents come from, behind a two-method read trait | -| `admin.rs` | its own listener, bearer auth, the failure guard, and the rollback that keeps a write from breaking the answer set | +| `guard.rs` | the rule that a write cannot leave the answer set broken — apply, re-read, roll back what broke. **Not an HTTP property**, so it lives outside `admin` and more than one caller uses it | +| `admin.rs` | its own listener, bearer auth, the failure guard, and the mapping from a guarded write's outcome onto a status code | | `config.rs` | the environment, and the validation that turns a dangerous configuration into a startup error | | `envfile.rs` | the file `RESCRIPTUM_ENV_FILE` names: parsed, never discovered, and fatal when it cannot be read | -| `cli.rs` | `render`, `check`, `import`, `export` | +| `cli.rs` | `render`, `check`, `import`, `export`, `status`/`machines`/`groups`, `power`, `install` | +| `controllers.rs` | where a machine's BMC, PiKVM or PDU is described. **The server never reads it** — `power` and `install` do | +| `redfish.rs` | talking to a Redfish service through `curl`, because there is no TLS in this binary | +| `power.rs` | driving a controller, for either kind: what `on`, `off`, `pxe` and `status` actually do, and the bounded concurrent probe | +| `tail.rs` | following the server's log from another process: rotation, a bounded buffer, and the line parser | +| `edit.rs` | handing a document to `$EDITOR` and storing what comes back, through the guard | +| `tui.rs` | what a terminal interface keeps and when it may do work. **No drawing**: ratatui is immediate-mode, so the state is ours, and keeping it here means it is tested in every build | | `capture.rs` | recording request bodies | | `log.rs` | one line per event, UTC timestamps computed without a date crate, and the two knobs over both: what is kept, and where it goes | diff --git a/docs/development/testing.fr.md b/docs/development/testing.fr.md index 86488b2..fbdaa6f 100644 --- a/docs/development/testing.fr.md +++ b/docs/development/testing.fr.md @@ -8,7 +8,7 @@ sidebar: # Tests -619 tests. `cargo test` les fait tous tourner en une vingtaine de secondes — dont +734 tests. `cargo test` les fait tous tourner en une vingtaine de secondes — dont l'essentiel dans `tests/tftp.rs`, qui attend de vrais délais UDP parce que c'est précisément ce qu'il teste. @@ -28,15 +28,23 @@ cargo test --all-features # ce que lance la CI | Suite | Cas | Pour | |---|---|---| -| `tests/integration.rs` | 52 | le vrai binaire sur une vraie socket | -| `tests/cli.rs` | 65 | `render`, `check`, `import`, `export`, `config` et le fichier d'environnement — contre le vrai binaire | +| `tests/integration.rs` | 53 | le vrai binaire sur une vraie socket | +| `tests/cli.rs` | 89 | `render`, `check`, `import`, `export`, `config` et le fichier d'environnement — contre le vrai binaire | | `tests/media.rs` | 45 | les médias de démarrage contre le vrai binaire, les deux listeners debout | | `src/config.rs` | 56 | l'environnement, ce qui refuse de démarrer, et qui l'emporte du fichier ou de l'environnement | | `tests/stores.rs` | 48 | **chaque comportement, contre les deux stores** | +| `tests/power.rs` | 12 | le client Redfish, contre un service factice auquel **le vrai curl** parle | | `tests/tftp.rs` | 30 | le TFTP sur de l'UDP réel : les tours de parole, et ce qu'une liaison ratée ne doit pas coûter | | `src/select.rs` | 29 | normalisation, scoring, superposition, remplissage de templates | | `src/format/mod.rs` | 28 | parsing, fusion, clés de contrôle, alias d'endpoint | -| `tests/admin.rs` | 26 | l'API d'administration de bout en bout, formats compris | +| `src/tui.rs` | 13 | ce qu'une interface terminal retient, et quand elle a le droit de travailler — sans dessin | +| `src/tail.rs` | 12 | suivre le log : rotation, troncature, et la surface d'analyse | +| `src/edit.rs` | 8 | l'aller-retour $EDITOR, à travers le garde | +| `src/power.rs` | 9 | piloter un contrôleur, et ce qu'un hook tué rapporte | +| `src/guard.rs` | 5 | l'écriture gardée, et le rollback sur le store fichier | +| `src/controllers.rs` | 18 | the controllers file: what it refuses, and why each refusal exists | +| `src/redfish.rs` | 12 | curl's option file, the quoting, and reading a vendor's error | +| `tests/admin.rs` | 28 | l'API d'administration de bout en bout, formats compris | | `src/envfile.rs` | 23 | le parseur et l'écrivain du fichier d'environnement, et ce que chacun refuse | | `src/facts.rs` | 22 | parsing de query, aplatissement JSON, globbing | | `src/format/xml.rs` | 18 | l'arbre XML — appariement, entités, fidélité | diff --git a/docs/development/testing.md b/docs/development/testing.md index d6eaadf..330079f 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -8,7 +8,7 @@ sidebar: # Testing -619 tests. `cargo test` runs all of them in about twenty seconds — most of that is +734 tests. `cargo test` runs all of them in about twenty seconds — most of that is `tests/tftp.rs`, which waits on real UDP timeouts because that is what it is testing. **`cargo test` does not run the harnesses that matter most**: the boot rig, the DSM @@ -26,15 +26,23 @@ cargo test --all-features # what CI runs | Suite | Cases | For | |---|---|---| -| `tests/integration.rs` | 52 | the real binary over a real socket | -| `tests/cli.rs` | 65 | `render`, `check`, `import`, `export`, `config` and the env file — against the real binary | +| `tests/integration.rs` | 53 | the real binary over a real socket | +| `tests/cli.rs` | 89 | `render`, `check`, `import`, `export`, `config` and the env file — against the real binary | | `tests/media.rs` | 45 | boot media against the real binary, with both listeners up | | `src/config.rs` | 56 | the environment, what refuses to start, and which of the file and the environment wins | | `tests/stores.rs` | 48 | **every behaviour, against both stores** | +| `tests/power.rs` | 12 | the Redfish client, against a fake service that **real curl** talks to | | `tests/tftp.rs` | 30 | TFTP over real UDP: the turn-taking, and what a failed bind must not cost | | `src/select.rs` | 29 | normalization, scoring, layering, template filling | | `src/format/mod.rs` | 28 | parsing, merging, control keys, endpoint aliases | -| `tests/admin.rs` | 26 | the admin API end to end, formats included | +| `src/tui.rs` | 13 | what a terminal interface keeps, and when it may do work — no drawing | +| `src/tail.rs` | 12 | following the log: rotation, truncation, and the parse surface | +| `src/edit.rs` | 8 | the $EDITOR round trip, through the guard | +| `src/power.rs` | 9 | driving a controller, and what a killed hook is reported as | +| `src/guard.rs` | 5 | the guarded write, and the rollback over the file store | +| `src/controllers.rs` | 18 | the controllers file: what it refuses, and why each refusal exists | +| `src/redfish.rs` | 12 | curl's option file, the quoting, and reading a vendor's error | +| `tests/admin.rs` | 28 | the admin API end to end, formats included | | `src/envfile.rs` | 23 | the env-file parser and writer, and what each refuses | | `src/facts.rs` | 22 | query parsing, JSON flattening, globbing | | `src/format/xml.rs` | 18 | the XML tree — pairing, entities, fidelity | diff --git a/docs/development/traps.fr.md b/docs/development/traps.fr.md index 4365376..9e78fc2 100644 --- a/docs/development/traps.fr.md +++ b/docs/development/traps.fr.md @@ -138,6 +138,33 @@ TOML valide, un texte différent. l'histoire de ce projet n'ont silencieusement rien fait et n'ont été attrapées qu'en vérifiant le nombre de tests ensuite. Vérifiez que l'ancien texte a été trouvé avant d'écrire. +**`cargo tree -i ` sort en 0 quand le paquet est une dépendance optionnelle dont la +feature est désactivée**, en affichant `warning: nothing to print.`. Il sort en 101 quand le +paquet est absent du manifeste. Un garde écrit `cargo tree -i x >/dev/null 2>&1 && exit 1` +se déclenche donc dans les deux cas et rougit la CI indéfiniment. Testez la *sortie* : + +```sh +cargo tree -e normal --prefix none --format '{p}' | grep -q '^ratatui ' && exit 1 +``` + +Mesuré contre `rusqlite` dans les trois états — présent, absent, optionnel-et-désactivé. + +**`/bin/true` et `/bin/false` n'existent pas sur macOS**, où ils sont dans `/usr/bin` ; et +`/bin/sleep` existe sur macOS alors que `/usr/bin/sleep` non. Un test qui lance un outil +standard doit le résoudre depuis une liste de candidats. C'est le même mode d'échec que les +tests de port TFTP : ça passe en local et ça casse en CI pour une raison sans rapport avec +le changement. + +**Un nom de fichier temporaire fondé sur le seul `pid` distingue les processus, pas les +threads.** Deux écritures d'un même document dans un processus partagent alors le chemin, et +l'une prend silencieusement le contenu de l'autre. `store::file::write_atomic` avait ce +défaut ; `edit::scratch_path` l'a réintroduit deux heures après sa correction, et trois +tests qui passaient seuls échouaient dans la suite complète. Ajoutez un compteur. + +**Un `"#` dans une chaîne brute `r#"…"#` la termine.** Une fixture Redfish contenant +`"#ComputerSystem.Reset"` fait exactement ça, et le compilateur dit `prefix ... is unknown`, +ce qui ne pointe pas du tout vers la cause. Utilisez `r##"…"##`. + ## Empaqueter pour DSM **Un script shell qui marche sur macOS n'est pas un script qui marche en CI.** Deux cas @@ -406,6 +433,49 @@ nom, et tout ce qu'elle va chercher elle-même porte `?v=` ; `check-spk.sh` vér bouge toujours. deux. +## Contrôle hors bande + +**Un `PATCH` Redfish peut répondre `204 No Content` sans rien faire.** Celui de PiKVM le +fait — vérifié dans la source de `kvmd`, pas deviné — tout en continuant à rapporter +`BootSourceOverrideEnabled: "Disabled"`. Un client qui croit le code de statut pense avoir +armé un démarrage réseau qui n'aura jamais lieu, et la machine n'installe rien tout en +ayant l'air correcte. **Relisez l'override** ; le code de statut n'est pas une preuve. + +**Ne suivez jamais une URL sortie d'un corps de réponse.** `@odata.id` est relatif à la +racine du service selon la spécification, et PiKVM enfreint cela : le manuel sert Redfish +sous `/api/redfish/v1` tandis que kvmd émet `"@odata.id": "/redfish/v1/Systems/0"`. Le +joindre à l'origine donne un 404. Prenez le **dernier segment** et composez +`/Systems/` — la seule forme qui marche sur un BMC conforme et sur celui-là. + +**`Members[0]` est une supposition.** Un châssis lame, un Dell FX2 et un PiKVM avec switch +exposent tous plusieurs systèmes ; sur un PiKVM dont l'ATX est désactivé, le premier membre +est un port de switch — une autre machine. Refusez en les nommant, et laissez l'opérateur +écrire `system = "…"`. + +**Le `--fail` de curl jette la phrase d'erreur du vendeur**, qui est la chose la plus utile +que produise un appel Redfish raté. Lisez le statut avec `--write-out` — et notez que `-w` +écrit sur *stdout*, là où se trouve déjà le corps, donc un `%{http_code}` naïf soude trois +chiffres à un document JSON. Un saut de ligne puis le code, découpé par la droite, garde les +deux. `--data` envoie aussi un content-type de formulaire, auquel Redfish répond 415. + +**Un délai dépassé n'est pas un échec, c'est un inconnu.** Un `ComputerSystem.Reset` qui a +expiré a peut-être allumé la baie, et un `PATCH` de `Boot` a peut-être pris. Ne réessayez +jamais une écriture automatiquement : dites que l'issue est inconnue et relisez l'état. + +**Le `.ipxe` d'un groupe arme des machines qu'on ne peut jamais désarmer.** +`installed::disarm` déplace le document *propre à la machine* et jamais celui d'un groupe, +délibérément, pour qu'une machine qui termine ne désarme pas une baie. La conséquence : une +machine armée uniquement par son groupe s'installe, signale son succès, n'est pas désarmée, +et se réinstalle au démarrage réseau suivant — pendant que le webhook journalise +`nothing was claiming it`, ce qui se lit comme si tout avait marché. Le propre +`examples/groups/edge-router/boot.ipxe` de ce projet le faisait, et `check` déclarait le +répertoire vert. + +**Un suiveur de log doit remarquer deux sortes de rotation.** `logrotate --create` remplace +le fichier, donc l'inode change ; `copytruncate` garde le même fichier et le vide, donc +l'inode ne change pas et seule la longueur qui recule le trahit. N'en vérifier qu'une seule +arrête la mise à jour de l'écran pour la moitié des déploiements, en silence. + ## Changements de comportement à retenir **Les documents de réponse doivent maintenant être valides.** Avant la fusion, ils étaient diff --git a/docs/development/traps.md b/docs/development/traps.md index 240aa13..a809067 100644 --- a/docs/development/traps.md +++ b/docs/development/traps.md @@ -126,6 +126,33 @@ key's original decor, so the output can read `value= 3` — valid TOML, differen project's history silently no-opped and were only caught by checking test counts afterwards. Assert the old text was found before writing. +**`cargo tree -i ` exits 0 when the package is an optional dependency whose feature is +off**, printing `warning: nothing to print.`. It exits 101 when the package is absent from +the manifest entirely. So a guard written as `cargo tree -i x >/dev/null 2>&1 && exit 1` +fires in both states and reddens CI forever. Assert on the *output*: + +```sh +cargo tree -e normal --prefix none --format '{p}' | grep -q '^ratatui ' && exit 1 +``` + +Measured against `rusqlite` in all three feature states — present, absent, optional-and-off. + +**`/bin/true` and `/bin/false` do not exist on macOS**, where they live in `/usr/bin`; and +`/bin/sleep` exists on macOS while `/usr/bin/sleep` does not. A test that spawns a standard +tool must resolve it from a candidate list. This is the same failure mode as the TFTP port +tests: it passes locally and fails in CI for a reason that has nothing to do with the +change. + +**A `pid`-only temporary filename disambiguates processes and not threads.** Two writes to +one document inside one process then share a path, and one silently takes the other's +content. `store::file::write_atomic` had this; `edit::scratch_path` reintroduced it two +hours after it was fixed, and three tests that passed alone failed in the full suite. Add a +counter. + +**A `"#` inside a `r#"…"#` raw string ends it.** A Redfish fixture containing +`"#ComputerSystem.Reset"` does exactly that, and the compiler says `prefix ... is unknown`, +which points nowhere near the cause. Use `r##"…"##`. + ## Packaging for DSM **A shell script that works on macOS is not a shell script that works on CI.** Two found by @@ -376,6 +403,46 @@ on running the **old** JavaScript against the new backend, through a reinstall a reload. The application's file is therefore named after the version and everything it fetches itself carries `?v=`; `check-spk.sh` asserts the name still moves. +## Out-of-band control + +**A Redfish `PATCH` can answer `204 No Content` and do nothing at all.** PiKVM's does — +verified in `kvmd`'s source, not guessed — while continuing to report +`BootSourceOverrideEnabled: "Disabled"`. A client that trusts the status code believes it +armed a network boot that will never happen, and the machine then installs nothing while +looking correct. **Read the override back**; the status code is not evidence. + +**Never follow a URL out of a response body.** `@odata.id` is service-root relative by the +specification, and PiKVM breaks that: the handbook serves Redfish at `/api/redfish/v1` +while kvmd emits `"@odata.id": "/redfish/v1/Systems/0"`. Joining it to the origin 404s. +Take the **last segment** and compose `/Systems/` — the only form that works on +both a conformant BMC and that one. + +**`Members[0]` is a guess.** A blade chassis, a Dell FX2 and a PiKVM with a switch all +expose several systems; on a PiKVM with ATX disabled the first member is a switch port — a +different machine. Refuse and name them, and let the operator say `system = "…"`. + +**curl's `--fail` discards the vendor's error sentence**, which is the most useful thing a +failed Redfish call produces. Read the status with `--write-out` instead — and note `-w` +writes to *stdout*, where the body already is, so a naive `%{http_code}` welds three digits +onto a JSON document. A newline plus the code, split from the right, keeps both. +`--data` also sends a form content type, which Redfish answers with 415. + +**A timeout is not a failure; it is an unknown.** A `ComputerSystem.Reset` that timed out +may have powered the rack on, and a `Boot` PATCH may or may not have taken. Never retry a +write automatically — say the outcome is unknown and read the state back. + +**A group's `.ipxe` arms machines that can never be disarmed.** `installed::disarm` moves a +*machine's* own document and never a group's, deliberately, so that one machine finishing +cannot disarm a rack. The consequence: a machine armed only by its group installs, reports +success, is not disarmed, and installs again on its next network boot — while the webhook +logs `nothing was claiming it`, which reads like everything worked. This project's own +`examples/groups/edge-router/boot.ipxe` did it and `check` called the directory green. + +**A log follower must notice two kinds of rotation.** `logrotate --create` replaces the +file, so the inode changes; `copytruncate` keeps the same file and empties it, so the inode +is unchanged and only the length going backwards gives it away. Checking one and not the +other stops the screen updating for half the deployments, and it is silent. + ## Behaviour changes worth remembering **Answer documents must now be valid.** Before merging they were served as opaque bytes, so diff --git a/docs/guide/operations/admin-api.fr.md b/docs/guide/operations/admin-api.fr.md index d23720f..e4c500b 100644 --- a/docs/guide/operations/admin-api.fr.md +++ b/docs/guide/operations/admin-api.fr.md @@ -47,6 +47,7 @@ d'administration sur le store fichiers, omettez le jeton, ou définissez un jeto | `PUT /machines/{id}`, `PUT /groups/{name}`, `PUT /default` | stocker un document (le corps est le document) | | `DELETE /machines/{id}`, `DELETE /groups/{name}`, `DELETE /default` | en supprimer un | | `GET /resolve/{id}` | la réponse **fusionnée** que cette machine recevrait | +| `GET /fleet` | chaque machine, ce qui lui répond et comment elle est armée — **octet pour octet ce qu'affiche `machines --json`**, depuis le même producteur, pour qu'une vue distante ne puisse pas diverger d'une vue locale | | `GET /check` | les problèmes actuels, le même jeu que la sous-commande `check` | | `GET /health` | vivacité — le seul endpoint sans jeton, et jamais bloqué | diff --git a/docs/guide/operations/admin-api.md b/docs/guide/operations/admin-api.md index 6fe1dbc..e3567e0 100644 --- a/docs/guide/operations/admin-api.md +++ b/docs/guide/operations/admin-api.md @@ -46,6 +46,7 @@ at the file store, leave the token out, or set a token shorter than 16 character | `DELETE /machines/{id}`, `DELETE /groups/{name}`, `DELETE /default` | remove one | | `GET /resolve/{id}` | the **merged** answer that machine would receive | | `GET /check` | current problems, the same set as the `check` subcommand | +| `GET /fleet` | every machine, what answers it and how it is armed — **byte for byte what `machines --json` prints**, from the same producer, so a remote view cannot drift from a local one | | `GET /health` | liveness — the only endpoint needing no token, and never blocked | Every endpoint that names a document takes **`?format=`** — the extension the document is diff --git a/docs/guide/operations/index.fr.md b/docs/guide/operations/index.fr.md index cb8c3d7..f438028 100644 --- a/docs/guide/operations/index.fr.md +++ b/docs/guide/operations/index.fr.md @@ -27,6 +27,7 @@ le droit de servir, et à qui. - **[Dépannage](./troubleshooting.md)** — la ligne de log est tout le diagnostic disponible. - [Servir les médias de démarrage](./media.md) — le noyau, l'initrd et l'image de l'installeur, depuis le même serveur. - [Démarrer une machine par le réseau](./netboot.md) — TFTP, le chargeur, le menu, et les deux lignes de leur DHCP. +- [Allumer des machines](./power.md) — contrôle hors bande via Redfish ou un script que vous fournissez, et les vérifications qu'`install` fait avant que quoi que ce soit ne bouge. ## La forme d'un déploiement diff --git a/docs/guide/operations/index.md b/docs/guide/operations/index.md index f116a89..d8edeb2 100644 --- a/docs/guide/operations/index.md +++ b/docs/guide/operations/index.md @@ -27,6 +27,7 @@ it is allowed to serve and to whom. - **[Troubleshooting](./troubleshooting.md)** — the log line is the whole diagnostic - [Serving boot media](./media.md) — the installer's own kernel, initrd and image, from the same server. - [Netbooting a machine](./netboot.md) — TFTP, the loader, the menu, and their DHCP server's two lines. +- [Powering machines on](./power.md) — out-of-band control over Redfish or a script you supply, and the checks `install` runs before anything moves. story. ## The shape of a deployment diff --git a/docs/guide/operations/power.fr.md b/docs/guide/operations/power.fr.md new file mode 100644 index 0000000..c74ab16 --- /dev/null +++ b/docs/guide/operations/power.fr.md @@ -0,0 +1,185 @@ +--- +title: Allumer des machines +description: Dire à une machine de démarrer sur le réseau et de s'allumer, via Redfish ou par un script que vous fournissez — et la commande qui vérifie tout avant que quoi que ce soit ne bouge. +sidebar: + label: Allumer des machines + order: 10 +--- + +# Allumer des machines + +Tout le reste ici répond aux machines qui demandent. Cette partie-ci **émet** : elle dit à +un BMC d'armer un démarrage réseau et d'appuyer sur le bouton, pour qu'une installation +puisse être lancée depuis un terminal plutôt que depuis une chaise devant la baie. + +C'est **éteint tant que `RESCRIPTUM_CONTROLLERS_FILE` ne nomme pas de fichier**. Non +défini, il n'y a aucun identifiant, aucune connexion sortante et aucun chemin de code qui y +mène. Un déploiement qui veut un pur serveur de réponses retrouve exactement ce qu'il +avait. + +C'est aussi **synchrone et déclenché par un opérateur, toujours**. Rien ici ne réconcilie, +ne réessaie ni ne décide de son propre chef qu'une machine devrait être réinstallée. Chaque +action est une personne ou un script, une fois. + +## Le fichier de contrôleurs + +Un fichier TOML, indexé par le même identifiant que le répertoire des réponses — donc +`98:fa:9b:50:d8:10` et `98fa9b50d810` sont une seule machine des deux côtés. + +```toml +# mode 0600. Pas dans le répertoire des réponses : c'est un .toml, et tout .toml servable à +# la racine de ce répertoire est un document de réponse. + +["98-fa-9b-50-d8-10"] +kind = "redfish" +url = "https://10.0.0.51" # schéma et hôte seulement — un chemin va dans `base` +base = "/redfish/v1" # PiKVM sert "/api/redfish/v1" +user = "root" +pass = "…" +pinnedpubkey = "sha256//…" # ou cacert = "…", ou verify = false. Un des trois est exigé + +["aa-bb-cc-dd-ee-ff"] +kind = "command" # tout ce que Redfish ne peut pas atteindre +on = ["/usr/local/bin/pdu", "outlet", "7", "on"] +off = ["/usr/local/bin/pdu", "outlet", "7", "off"] +pxe = [] # rien à faire — l'ordre de boot est réseau en permanence +timeout = 30 # secondes ; un script bloqué bloquerait sinon `install` +``` + +Quatre règles méritent d'être connues avant d'en écrire un. + +**Le serveur ne lit jamais ce fichier.** `power` et `install` le lisent. Un fichier +d'identifiants malformé ne peut pas arrêter l'écoute des réponses : les installations +d'une flotte tombant pour une raison sans rapport avec le fait de répondre, c'est +exactement l'échec qui rendrait cette fonctionnalité non rentable. + +**Dites comment le certificat doit être vérifié.** Une entrée ne portant ni +`verify = false`, ni `cacert`, ni `pinnedpubkey` est refusée en nommant les trois. Les BMC +livrent des certificats auto-signés, donc « ne pas vérifier » est le défaut *commode* et +n'est donc pas celui que vous obtenez — la même règle que `media add`, où une URL exige +`--sha256` sauf si `--unverified` est passé. `pinnedpubkey` est la bonne réponse pour un +BMC auto-signé, et ne coûte rien. + +**Un fichier lisible par le groupe est refusé à l'usage.** Pas averti : celui-ci porte des +identifiants capables de couper l'alimentation d'une baie. `chmod 600`. (Notez que les bits +de mode sous-estiment sur DSM, où une ACL peut accorder un accès dont `st_mode` ne parle +jamais — c'est donc un contrôle du mode, pas une preuve de confidentialité.) + +**`on`, `off` et `pxe` sont des vecteurs d'arguments, jamais des lignes de commande.** Rien +ne passe par un shell, aucun découpage de mots n'a lieu, et rien de ce qu'une machine a +envoyé sur le réseau ne peut y arriver. Une chaîne y est refusée avec une explication +plutôt que découpée. + +## Les commandes + +```bash +rescriptum power list # ce qui est configuré, joint au jeu de réponses +rescriptum power list --state # ...et demander à chacun s'il est allumé +rescriptum power status +rescriptum power on +rescriptum power off # gracieux ; --hard force +rescriptum power pxe # armer un démarrage réseau unique, là où il y en a un +rescriptum install # vérifier, armer, pxe, allumer — le geste complet +rescriptum install --dry-run # tout sauf l'allumage +``` + +`power list` **ne sonde pas**. Lire l'état, c'est un aller-retour HTTPS par contrôleur, +chacun jusqu'à son délai ; avec deux cents contrôleurs dont quelques-uns injoignables, une +liste qui demanderait prendrait des minutes et aurait l'air bloquée. `--state` est la +version qui demande, et elle est bornée et concurrente. + +## `install`, et ce qu'il refuse + +`install` est la commande pour laquelle le reste existe, et l'essentiel consiste à +vérifier. Allumer une machine qui démarre sur le réseau, enchaîne l'installateur et tombe +ensuite sur un 404 laisse un installateur planté à une invite dans une baie — l'échec que +tout ce projet existe pour empêcher. + +Dans l'ordre : + +1. **Chaque format que cette machine résout se rend**, gabarits remplis, aucun fait + manquant. Pas une supposition sur celui vers lequel pointe le script de boot : tous. +2. **La politique est vérifiée**, et c'est là que ça s'arrête le plus souvent. Voir plus + bas. +3. **Son script de boot est remis en place**, si une installation précédente l'a archivé + dans un répertoire frère `installed-/`. Votre document revient octet pour octet. +4. **Un démarrage réseau unique est armé**, là où le contrôleur en a un — et **relu pour + confirmer qu'il a pris**. +5. **Elle est allumée, ou redémarrée**, selon l'état d'alimentation lu. + +Trois refus, chacun pour une raison différente : + +| Il dit | Parce que | +|---|---| +| *rien ne l'arme, elle resterait sur le menu de boot* | Avec `RESCRIPTUM_BOOT_UNCLAIMED=menu`, une machine non armée attend quelqu'un qui ne viendra pas, et brûle un cycle de démarrage | +| *…démarrerait son propre disque sans rien signaler* | Avec `local`, la même machine **ressemble exactement à une installation réussie**. C'est le cas dangereux | +| *son script de boot vient d'un groupe, et un groupe n'est jamais désarmé* | Voir plus bas | + +### Pourquoi un groupe ne peut pas armer une installation + +`POST /installed` déplace le `.ipxe` **propre à la machine** quand elle signale son succès +— jamais celui d'un groupe, délibérément, pour qu'une machine qui termine ne puisse pas +désarmer une baie entière. + +La conséquence est facile à manquer. Une machine armée uniquement par son groupe +s'installe, signale son succès, n'est pas désarmée, et retrouve le même script de boot au +démarrage réseau suivant. Avec un ordre de boot réseau permanent, c'est une boucle de +réinstallation — et le webhook journalise `nothing was claiming it`, ce qui se lit comme si +tout avait fonctionné. + +Donc `install` le refuse, `check` le signale par une note, et la correction consiste à +donner à la machine son propre document `.ipxe`. Gardez un `.ipxe` de groupe pour ce qui +est censé être servi indéfiniment : démarrer le disque local, ou un menu. + +## Ce que chaque sorte de contrôleur sait faire + +| Contrôleur | Alimentation | Démarrage réseau unique | Remarques | +|---|---|---|---| +| BMC serveur (iDRAC, iLO, Redfish générique) | oui | **oui** | | +| PiKVM | oui | **non** — il appuie sur des boutons, ce n'est pas le firmware | Son `PATCH` répond `204` sans rien changer ; la relecture l'attrape | +| JetKVM, PDU commutée, Wake-on-LAN | oui | non | Via `kind = "command"` | +| Intel AMT | oui | oui | Voir le piège plus bas | + +**L'absence d'override de boot n'est pas une lacune.** Là où il n'y en a pas, laissez +l'ordre de boot sur le réseau en permanence et laissez le serveur décider si la machine +s'installe — ce que `RESCRIPTUM_BOOT_UNCLAIMED` et le désarmement `installed-` font déjà. +Un PiKVM plus rescriptum est une solution complète ; un BMC avec démarrage unique est une +ceinture en plus des bretelles. + +## Ce qui mordra + +**Un délai dépassé n'est pas un échec, c'est un inconnu.** Un reset qui a expiré a +peut-être allumé la baie. Rien ici ne réessaie une écriture automatiquement, et le message +dit que l'issue est inconnue plutôt que de laisser croire qu'il ne s'est rien passé. Relisez +l'état avec `power status`. + +**Il n'y a pas de TLS dans ce binaire**, donc les appels Redfish passent par `curl`. +Contrairement à `media add`, il n'y a pas de repli sur `wget` : un appel Redfish demande un +POST avec un corps JSON, des en-têtes personnalisés et un identifiant tenu hors de la table +des processus, et wget ne fait rien de cette combinaison. L'identifiant est passé sur +l'entrée standard de curl, donc `ps` n'affiche que `curl --config -`. + +**Un BMC devant plusieurs systèmes est refusé plutôt que deviné.** Un châssis lame, un Dell +FX2 et un PiKVM avec switch en exposent tous plusieurs ; prendre le premier allumerait la +machine de quelqu'un d'autre. Ajoutez `system = "…"` à l'entrée pour dire lequel. + +**Intel AMT sur une carte réseau partagée peut affamer le DHCP de l'hôte.** Avec le +Management Engine qui tient l'interface sur une adresse statique pendant que l'hôte demande +un bail, le `dhclient` de l'installateur Proxmox abandonne au bout d'une dizaine de +secondes et l'installation échoue sur `Network is unreachable` — alors que +`dhclient -v eno1` depuis le shell de l'installateur réussit instantanément juste après. +Mettez AMT en DHCP. Rien ici ne peut élargir cette fenêtre. + +## Ce que ce n'est pas + +- **Pas un gestionnaire de configuration.** La frontière est le moment où SSH répond. + C'est le terrain d'Ansible. +- **Pas une boucle de réconciliation.** Aucun agent ne décide qu'une machine devrait être + réinstallée. +- **Pas un fan-out.** Il n'y a pas de forme « groupe », et si elle arrive un jour elle sera + séquentielle avec un délai réglable — allumer quarante machines d'un coup est un + événement électrique avant d'être un événement logiciel, et les datacenters échelonnent + les mises sous tension à cause du courant d'appel. +- **Jamais une requête.** Aucun point d'entrée HTTP n'allume quoi que ce soit. L'endpoint + de réponses est non authentifié par nécessité, et câbler le contrôle d'alimentation à + proximité, c'est ainsi qu'un serveur de provisioning devient une arme. diff --git a/docs/guide/operations/power.md b/docs/guide/operations/power.md new file mode 100644 index 0000000..e97fc6f --- /dev/null +++ b/docs/guide/operations/power.md @@ -0,0 +1,172 @@ +--- +title: Powering machines on +description: Telling a machine to boot from the network and turn on, over Redfish or through a script you supply — and the one command that checks everything before anything moves. +sidebar: + label: Powering machines on + order: 10 +--- + +# Powering machines on + +Everything else here answers machines that ask. This is the one part that *emits*: it +tells a BMC to arm a network boot and press the power button, so an install can be started +from a terminal instead of from a chair in front of the rack. + +It is **off unless `RESCRIPTUM_CONTROLLERS_FILE` names a file**. Unset, there are no +credentials, no outbound connections and no code path that reaches any. A deployment that +wants a pure answer server gets exactly what it had. + +It is also **synchronous and operator-triggered, always**. Nothing here reconciles, retries +or decides on its own that a machine should be reinstalled. Every action is a person or a +script, once. + +## The controllers file + +A TOML file, keyed by the same identifier the answers directory uses — so +`98:fa:9b:50:d8:10` and `98fa9b50d810` are one machine on both sides. + +```toml +# mode 0600. Not in the answers directory: it is a .toml, and every servable .toml at the +# top of that directory is an answer document. + +["98-fa-9b-50-d8-10"] +kind = "redfish" +url = "https://10.0.0.51" # scheme and host only — a path belongs in `base` +base = "/redfish/v1" # PiKVM serves "/api/redfish/v1" +user = "root" +pass = "…" +pinnedpubkey = "sha256//…" # or cacert = "…", or verify = false. One is required + +["aa-bb-cc-dd-ee-ff"] +kind = "command" # anything Redfish cannot reach +on = ["/usr/local/bin/pdu", "outlet", "7", "on"] +off = ["/usr/local/bin/pdu", "outlet", "7", "off"] +pxe = [] # nothing to do — the boot order is permanently network +timeout = 30 # seconds; a hung script would otherwise hang `install` +``` + +Four rules are worth knowing before you write one. + +**The server never reads this file.** `power` and `install` do. A malformed credentials +file cannot stop the answer listener, because a fleet's installs going down for a reason +unrelated to answering is exactly the failure that would not be worth this feature. + +**Say how the certificate is to be trusted.** An entry carrying none of `verify = false`, +`cacert` or `pinnedpubkey` is refused, naming all three. BMCs ship self-signed +certificates, so "do not verify" is the *convenient* default and therefore not the one you +get — the same rule `media add` already has, where a URL requires `--sha256` unless +`--unverified` is passed. `pinnedpubkey` is the right answer for a self-signed BMC and +costs nothing. + +**A group-readable file is refused at use.** Not warned about: this one holds credentials +that can power-cycle a rack. `chmod 600` it. (Note that mode bits under-report on DSM, +where an ACL can grant access `st_mode` never mentions — so this is a check on the mode, +not a proof of privacy.) + +**`on`, `off` and `pxe` are argument vectors, never command lines.** Nothing is passed +through a shell, no word splitting happens, and nothing a machine sent over the network can +reach them. A string there is refused with an explanation rather than split. + +## The commands + +```bash +rescriptum power list # what is configured, joined to the answer set +rescriptum power list --state # ...and ask each one whether it is on +rescriptum power status +rescriptum power on +rescriptum power off # graceful; --hard forces it +rescriptum power pxe # arm a one-time network boot, where there is one +rescriptum install # check, arm, pxe, power on — the whole gesture +rescriptum install --dry-run # everything except the powering +``` + +`power list` **does not probe**. Reading state is one HTTPS round trip per controller, each +up to its deadline; with two hundred controllers and a handful unreachable, a listing that +asked would take minutes and look hung. `--state` is the version that asks, and it is +bounded and concurrent. + +## `install`, and what it refuses + +`install` is the command the rest exists for, and most of it is checking. Powering on a +machine that network-boots, chains the installer and then meets a 404 leaves an installer +sitting at a prompt in a rack — which is the failure this whole project exists to prevent. + +In order: + +1. **Every format this machine resolves for renders**, templates filled, no missing fact. + Not a guess at which one the boot script leads to — all of them. +2. **The policy is checked**, and this is where it most often stops. See below. +3. **Its boot script is put back**, if a previous install archived it into an + `installed-/` sibling. Your own document comes back byte for byte. +4. **A one-time network boot is armed**, where the controller has one — and **read back to + confirm it took**. +5. **It is powered on, or restarted**, decided by reading the current power state. + +Three refusals, each for a different reason: + +| It says | Because | +|---|---| +| *nothing arms it, so it would sit on the boot menu* | With `RESCRIPTUM_BOOT_UNCLAIMED=menu`, an unarmed machine waits for a human who is not coming, and burns a boot cycle | +| *…would boot its own disk and report nothing* | With `local`, the same machine looks **exactly like a successful install**. That is the dangerous one | +| *its boot script comes from a group, and a group is never disarmed* | See below | + +### Why a group cannot arm an install + +`POST /installed` moves a **machine's own** `.ipxe` aside when it reports success — never a +group's, deliberately, so that one machine finishing cannot disarm a whole rack. + +The consequence is easy to miss. A machine armed only by its group installs, reports +success, is not disarmed, and finds the same boot script waiting on its next network boot. +With a permanently network-first boot order that is a reinstall loop, and the webhook logs +`nothing was claiming it`, which reads like everything worked. + +So `install` refuses it, `check` reports it as a note, and the fix is to give the machine +its own `.ipxe` document. Keep a group `.ipxe` for what is meant to be served forever — +booting the local disk, or a menu. + +## What each kind of controller can do + +| Controller | Power | One-time network boot | Notes | +|---|---|---|---| +| Server BMC (iDRAC, iLO, generic Redfish) | yes | **yes** | | +| PiKVM | yes | **no** — it presses buttons, it is not the firmware | Its `PATCH` answers `204` and changes nothing; the read-back catches that | +| JetKVM, switched PDU, Wake-on-LAN | yes | no | Through `kind = "command"` | +| Intel AMT | yes | yes | See the trap below | + +**A missing boot override is not a gap.** Where one does not exist, leave the boot order on +the network permanently and let the server decide whether the machine installs — which is +exactly what `RESCRIPTUM_BOOT_UNCLAIMED` and the `installed-` disarm already do. A PiKVM +plus rescriptum is a complete solution; a BMC with one-time boot is belt and braces. + +## Things that will bite + +**A timeout is not a failure — it is an unknown.** A reset that timed out may have powered +the rack on. Nothing here retries a write automatically, and the message says the outcome +is unknown rather than implying nothing happened. Read the state back with `power status`. + +**There is no TLS in this binary**, so Redfish calls go through `curl`. Unlike `media add`, +there is no `wget` fallback: a Redfish call needs a POST with a JSON body, custom headers +and a credential kept out of the process table, and wget does none of that combination. The +credential is passed on curl's stdin, so `ps` shows only `curl --config -`. + +**A BMC in front of several systems is refused rather than guessed at.** A blade chassis, a +Dell FX2, and a PiKVM with a switch all expose more than one; picking the first would power +somebody else's machine. Add `system = "…"` to the entry to say which. + +**Intel AMT on a shared NIC can starve the host's DHCP.** With the Management Engine holding +the interface on a static address while the host asks for a lease, the Proxmox installer's +`dhclient` gives up after about eleven seconds and the install aborts on +`Network is unreachable` — while `dhclient -v eno1` from the installer's own shell succeeds +instantly afterwards. Set AMT to DHCP. Nothing here can widen that window. + +## What this is not + +- **Not a configuration manager.** The boundary is the moment SSH answers. That is + Ansible's ground. +- **Not a reconciliation loop.** No agent decides a machine should be reinstalled. +- **Not a fan-out.** There is no group form, and if one is ever added it will be sequential + with a settable delay — powering forty machines at once is an electrical event before it + is a software one, and datacenters stagger power-on for inrush current. +- **Never a request.** No HTTP endpoint powers anything. The answer endpoint is + unauthenticated by necessity, and wiring power control anywhere near it is how a + provisioning server becomes a weapon. diff --git a/docs/guide/reference/cli.fr.md b/docs/guide/reference/cli.fr.md index 21c84a4..7e17354 100644 --- a/docs/guide/reference/cli.fr.md +++ b/docs/guide/reference/cli.fr.md @@ -26,6 +26,13 @@ Sans argument, `rescriptum` lance le serveur. Tout le reste est une sous-command | `rescriptum config --value CLÉ` | une valeur, pour un script — jamais un identifiant | | `rescriptum config set C=V …` | éditer le fichier que `RESCRIPTUM_CONFIG` ou `RESCRIPTUM_ENV_FILE` nomme | | `rescriptum config unset CLÉ …` | y retirer un réglage | +| `rescriptum status [--json]` | la flotte en un écran : compteurs, ce qui est armé, problèmes | +| `rescriptum machines [--json]` | chaque machine, ce qui lui répond, et comment elle est armée | +| `rescriptum groups [--json]` | membres, chaîne `extends`, ce que chacun revendique | +| `rescriptum power …` | [contrôle hors bande](../operations/power.md), éteint sans fichier de contrôleurs | +| `rescriptum install ` | vérifier, armer, démarrage réseau, allumer — le geste complet | +| `rescriptum tui` | la flotte sur un écran — build avec `--features tui` | +| `rescriptum tui --remote URL` | la même, via l'API admin d'un déploiement — lecture seule, et n'allume rien | | `rescriptum --help` | usage et variables d'environnement | Toutes lisent les mêmes [variables d'environnement](./configuration.md), dont @@ -49,6 +56,7 @@ $ rescriptum render --body /var/log/rescriptum-captures/2026…-0000.body | `` | l'identifiant comme botte de foin, et rien d'autre — assez pour correspondre par nom, pas assez pour un sélecteur sur `serial` | | `--query "k=v&k2=v2"` | ces étiquettes, décodées. `path=` fournit aussi `file` et `segment`, et contraint le format comme le ferait une vraie URL | | `--body FICHIER` | le fichier verbatim : botte de foin, plus le JSON aplati s'il parse comme du JSON | +| ` --format ` | l'identifiant, contraint à un seul format — pour une machine qui détient `proxmox.toml` **et** `debian.preseed`, le cas pour lequel la disposition existe | - Le **document** part sur **stdout** ; la ligne `# format=… machine=… group=…` expliquant comment il a été obtenu part sur **stderr**. Donc `render … > answer.toml` ne donne que le @@ -164,6 +172,32 @@ Contrairement à toutes les autres sous-commandes, celle-ci fonctionne quand la est trop cassée pour démarrer un serveur — un fichier qui ne parse pas, un jeton d'un caractère trop court. C'est l'état dont on se sert d'elle pour *sortir*. +## `status` / `machines` / `groups` + +```console +$ rescriptum status +$ rescriptum machines --json +$ rescriptum groups +``` + +La flotte comme donnée, depuis le même producteur dans les deux rendus — `--json` est ce +que consomment un panneau de réglages ou un script, et le `GET /fleet` de l'API admin +renvoie les *mêmes octets* que `machines --json`, pour qu'une vue distante ne puisse pas +diverger d'une vue locale. + +- **« Armée » est une propriété de la résolution, pas d'un répertoire.** Une machine sans + document propre est armée si le groupe qui la revendique détient un `.ipxe`, et + `machines` le dit — avec l'avertissement qu'une telle machine [ne peut pas se + désarmer](../operations/power.md#pourquoi-un-groupe-ne-peut-pas-armer-une-installation). +- **Une machine qu'un groupe se contente de nommer reste une machine.** Elle n'a pas de + document, donc elle serait invisible autrement, et une baie armée entièrement depuis un + groupe aurait l'air d'une flotte vide. +- **`installed-` est un état, pas une identité.** Il est replié dans la machine qu'il + nomme et signalé comme *désarmée par une installation précédente*. +- `status` sort en **0** même quand le jeu de réponses a des problèmes : zéro problème est + l'état normal, et crier au loup ici le rendrait inutile. C'est [`check`](#check) qui + conditionne un code de sortie là-dessus. + ## `media` Les médias de démarrage : les images d'installation que ce serveur détient. Chacune de diff --git a/docs/guide/reference/cli.md b/docs/guide/reference/cli.md index e09a92b..4329bc2 100644 --- a/docs/guide/reference/cli.md +++ b/docs/guide/reference/cli.md @@ -26,6 +26,13 @@ With no arguments, `rescriptum` runs the server. Everything else is a subcommand | `rescriptum config --value KEY` | one value, for a script — never a credential | | `rescriptum config set K=V …` | edit the file `RESCRIPTUM_CONFIG` or `RESCRIPTUM_ENV_FILE` names | | `rescriptum config unset KEY …` | take a setting back out of it | +| `rescriptum status [--json]` | the fleet in one screen: counts, what is armed, problems | +| `rescriptum machines [--json]` | every machine, what answers it, and how it is armed | +| `rescriptum groups [--json]` | members, the `extends` chain, what each one claims | +| `rescriptum power …` | [out-of-band control](../operations/power.md), off unless a controllers file is named | +| `rescriptum install ` | check, arm, network-boot, power on — the whole gesture | +| `rescriptum tui` | the fleet on one screen — a build with `--features tui` | +| `rescriptum tui --remote URL` | the same, over a deployment's admin API — read-only, and it powers nothing | | `rescriptum --help` | usage and the environment variables | All of them read the same [environment variables](./configuration.md), including @@ -49,6 +56,7 @@ $ rescriptum render --body /var/log/rescriptum-captures/2026…-0000.body | `` | the identifier as a haystack, and nothing else — enough to match by name, not enough for a selector on `serial` | | `--query "k=v&k2=v2"` | those labels, percent-decoded. `path=` also yields `file` and `segment`, and constrains the format the way a real URL would | | `--body FILE` | the file verbatim: haystack, plus flattened JSON if it parses as JSON | +| ` --format ` | the identifier, constrained to one format — for a machine that holds `proxmox.toml` **and** `debian.preseed`, which is the case the layout exists for | - The **document** goes to **stdout**; the `# format=… machine=… group=…` line explaining how it was reached goes to **stderr**. So `render … > answer.toml` gives you just the @@ -160,6 +168,31 @@ Unlike every other subcommand, this one works when the configuration is too brok a server — a file that will not parse, a token one character short. That is the state people run it *to get out of*. +## `status` / `machines` / `groups` + +```console +$ rescriptum status +$ rescriptum machines --json +$ rescriptum groups +``` + +The fleet as data, from the same producer in both renderings — `--json` is what a settings +panel or a script consumes, and the admin API's `GET /fleet` returns the *same bytes* as +`machines --json` so a remote view cannot drift from a local one. + +- **"Armed" is a property of resolution, not of a directory.** A machine with no document + of its own is armed if the group claiming it holds an `.ipxe`, and `machines` says so — + including the warning that such a machine [cannot disarm + itself](../operations/power.md#why-a-group-cannot-arm-an-install). +- **A machine a group only names is still a machine.** It has no document, so it would + otherwise be invisible, and a rack armed entirely from a group would look like an empty + fleet. +- **`installed-` is a state, not an identity.** It is folded into the machine it names + and reported as *disarmed by a previous install*. +- `status` exits **0** even when the answer set has problems: zero problems is the normal + state, and crying wolf here would make it useless. [`check`](#check) is what keys an exit + code on that. + ## `media` Boot media: the installer images this server holds. Every one of these needs diff --git a/docs/guide/reference/configuration.fr.md b/docs/guide/reference/configuration.fr.md index 07bcfa3..3bfbb71 100644 --- a/docs/guide/reference/configuration.fr.md +++ b/docs/guide/reference/configuration.fr.md @@ -31,6 +31,7 @@ quelque chose que l'environnement ne dirait pas. | `RESCRIPTUM_ADMIN_ADDR` | non défini | Listener de l'API d'administration. Non défini = API désactivée | | `RESCRIPTUM_ADMIN_TOKEN` | non défini | Jeton d'administration, 16+ caractères. Obligatoire avec `RESCRIPTUM_ADMIN_ADDR` | | `RESCRIPTUM_CAPTURE_DIR` | non défini | Enregistre les corps de requête ici. Non défini = pas de capture | +| `RESCRIPTUM_CONTROLLERS_FILE` | non défini | Contrôleurs hors bande, pour `power` et `install`. **Le serveur ne le lit jamais.** Non défini, il n'y a aucun contrôle d'alimentation | | `RESCRIPTUM_LOG` | `all` | `all`, `problems` ou `off` — voir [plus bas](#journalisation) | | `RESCRIPTUM_LOG_FILE` | non défini | Un fichier où ajouter, ou `stdout` / `stderr`. Non défini = stderr | | `RESCRIPTUM_MEDIA_DIR` | non défini | Images d'installation. **Non défini = pas de média et pas de listener média** | @@ -162,6 +163,7 @@ orthographes. | `server.workers`, `server.max_connections`, `server.timeout_secs` | `RESCRIPTUM_WORKERS`, `RESCRIPTUM_MAX_CONNECTIONS`, `RESCRIPTUM_TIMEOUT_SECS` | | `admin.addr`, `admin.token` | `RESCRIPTUM_ADMIN_ADDR`, `RESCRIPTUM_ADMIN_TOKEN` | | `answer.token`, `answer.capture_dir` | `RESCRIPTUM_ANSWER_TOKEN`, `RESCRIPTUM_CAPTURE_DIR` | +| `power.controllers_file` | `RESCRIPTUM_CONTROLLERS_FILE` | | `media.dir`, `media.addr`, `media.timeout_secs`, `media.max_connections` | les quatre `RESCRIPTUM_MEDIA_*` | | `boot.dir`, `boot.allow`, `boot.unclaimed`, `boot.timeout_secs`, `boot.logo`, `boot.title` | les six `RESCRIPTUM_BOOT_*` | | `tftp.addr`, `tftp.port_range`, `tftp.blksize` | les trois `RESCRIPTUM_TFTP_*` | diff --git a/docs/guide/reference/configuration.md b/docs/guide/reference/configuration.md index 6702790..8852b1b 100644 --- a/docs/guide/reference/configuration.md +++ b/docs/guide/reference/configuration.md @@ -30,6 +30,7 @@ rules, so nothing you can write in a file means anything the environment could n | `RESCRIPTUM_ADMIN_ADDR` | unset | Admin API listener. Unset means the admin API is off | | `RESCRIPTUM_ADMIN_TOKEN` | unset | Admin bearer token, 16+ characters. Required with `RESCRIPTUM_ADMIN_ADDR` | | `RESCRIPTUM_CAPTURE_DIR` | unset | Record request bodies here. Unset means no capture | +| `RESCRIPTUM_CONTROLLERS_FILE` | unset | Out-of-band controllers, for `power` and `install`. **The server never reads it.** Unset, there is no power control at all | | `RESCRIPTUM_LOG` | `all` | `all`, `problems` or `off` — see [below](#logging) | | `RESCRIPTUM_LOG_FILE` | unset | A file to append to, or `stdout` / `stderr`. Unset means stderr | | `RESCRIPTUM_MEDIA_DIR` | unset | Installer images. **Unset means no media and no media listener** | @@ -154,6 +155,7 @@ the variable of the same name, and `rescriptum config` prints both spellings. | `server.workers`, `server.max_connections`, `server.timeout_secs` | `RESCRIPTUM_WORKERS`, `RESCRIPTUM_MAX_CONNECTIONS`, `RESCRIPTUM_TIMEOUT_SECS` | | `admin.addr`, `admin.token` | `RESCRIPTUM_ADMIN_ADDR`, `RESCRIPTUM_ADMIN_TOKEN` | | `answer.token`, `answer.capture_dir` | `RESCRIPTUM_ANSWER_TOKEN`, `RESCRIPTUM_CAPTURE_DIR` | +| `power.controllers_file` | `RESCRIPTUM_CONTROLLERS_FILE` | | `media.dir`, `media.addr`, `media.timeout_secs`, `media.max_connections` | the `RESCRIPTUM_MEDIA_*` four | | `boot.dir`, `boot.allow`, `boot.unclaimed`, `boot.timeout_secs`, `boot.logo`, `boot.title` | the `RESCRIPTUM_BOOT_*` six | | `tftp.addr`, `tftp.port_range`, `tftp.blksize` | the `RESCRIPTUM_TFTP_*` three | diff --git a/src/admin.rs b/src/admin.rs index afca84e..8897dc8 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -16,6 +16,7 @@ use crate::config::Config; use crate::facts::Facts; +use crate::guard; use crate::log; use crate::select::Answers; use crate::store::{StoreWrite, valid_format, valid_id}; @@ -236,7 +237,7 @@ fn json(status: StatusCode, body: String) -> Response { /// Minimal JSON string escaping — enough for identifiers and error messages, which is /// all this API emits. Not a general encoder. -fn json_string(s: &str) -> String { +pub(crate) fn json_string(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('"'); for c in s.chars() { @@ -254,7 +255,7 @@ fn json_string(s: &str) -> String { out } -fn json_list(key: &str, items: &[String]) -> String { +pub(crate) fn json_list(key: &str, items: &[String]) -> String { let body: Vec = items.iter().map(|s| json_string(s)).collect(); format!("{{{}:[{}]}}", json_string(key), body.join(",")) } @@ -322,6 +323,16 @@ async fn handle( let segments: Vec<&str> = path.trim_matches('/').split('/').collect(); let response = match (&method, segments.as_slice()) { + // **Exactly one new endpoint, and it serves the CLI's model byte for byte.** + // `GET /machines` returns bare identifiers, so a remote fleet view would need one + // `GET /resolve/{id}` per machine — two thousand round trips on the fleet this + // project measures itself against, which is not "the same screens over the wire" + // but a different and much worse program. One producer, so the command and the + // API cannot drift. + (&Method::GET, ["fleet"]) => match crate::cli::fleet::machines(&admin.answers) { + Ok(machines) => json(StatusCode::OK, crate::cli::fleet::machines_json(&machines)), + Err(e) => error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + }, (&Method::GET, ["machines"]) => list(&admin, Kind::Machine), (&Method::GET, ["groups"]) => list(&admin, Kind::Group), (&Method::GET, ["check"]) => match admin.answers.problems() { @@ -508,12 +519,12 @@ fn delete(admin: &Admin, kind: Kind, id: &str, format: &str) -> Response { guarded(admin, kind, id, format, None) } -/// Apply a write, then check that it did not break the answer set. If it did, put -/// things back and say what broke. +/// Apply a write through the guard, and turn what it did into a response. /// -/// A cycle between groups, or a machine pointing at a group that no longer exists, does -/// not fail at write time — it fails when a rack tries to install. Catching it here is -/// the difference between a red response now and a failed provisioning run later. +/// **This is a mapping and nothing else.** The rule — apply, re-read, roll back what +/// broke — lives in `crate::guard`, because it is not an HTTP property and more than one +/// caller needs it. What belongs here is the choice of status code and envelope, which is +/// this API's business alone. fn guarded( admin: &Admin, kind: Kind, @@ -521,81 +532,17 @@ fn guarded( format: &str, body: Option<&str>, ) -> Response { - // **Both reads go back to the store.** The listing is cached behind the store's - // `version`, and over files that version is the answers directory's mtime — which does - // not move when a document is written inside an existing identity's directory. Without - // this the guard would compare a write against itself and keep it. See - // `Answers::invalidate`. - admin.answers.invalidate(); - let before = match admin.answers.problems() { - Ok(p) => p, - Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), - }; - - // What was there before, so it can be restored. - let previous = match admin.store.snapshot() { - Ok(s) => match kind { - Kind::Machine => s - .machines - .iter() - .find(|m| m.id == id && m.format == format) - .map(|m| (m.format.clone(), m.body.clone())), - Kind::Group => s - .groups - .iter() - .find(|g| g.name == id && g.format == format) - .map(|g| (g.format.clone(), g.body.clone())), - Kind::Default => s - .fallbacks - .iter() - .find(|d| d.format == format) - .map(|d| (d.format.clone(), d.body.clone())), - }, - Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), - }; - - let applied = match (kind, body) { - (Kind::Machine, Some(b)) => admin.store.put_machine(id, format, b).map(|()| true), - (Kind::Group, Some(b)) => admin.store.put_group(id, format, b).map(|()| true), - (Kind::Default, Some(b)) => admin.store.put_default(format, b).map(|()| true), - (Kind::Machine, None) => admin.store.delete_machine(id, format), - (Kind::Group, None) => admin.store.delete_group(id, format), - (Kind::Default, None) => admin.store.delete_default(format), - }; - let existed = match applied { - Ok(v) => v, - Err(e) => return error(StatusCode::BAD_REQUEST, &e.to_string()), + let target = match kind { + Kind::Machine => guard::Target::Machine(id.to_string()), + Kind::Group => guard::Target::Group(id.to_string()), + Kind::Default => guard::Target::Default, }; - if body.is_none() && !existed { - return error(StatusCode::NOT_FOUND, "not found"); - } - - admin.answers.invalidate(); - let after = admin.answers.problems().unwrap_or_default(); - let introduced: Vec = after - .iter() - .filter(|p| !before.contains(p)) - .cloned() - .collect(); - - if !introduced.is_empty() { - // Undo, so the store is never left in a state that breaks installs. - let restored = match (kind, &previous) { - (Kind::Machine, Some((f, b))) => admin.store.put_machine(id, f, b), - (Kind::Group, Some((f, b))) => admin.store.put_group(id, f, b), - (Kind::Default, Some((f, b))) => admin.store.put_default(f, b), - (Kind::Machine, None) => admin.store.delete_machine(id, format).map(drop), - (Kind::Group, None) => admin.store.delete_group(id, format).map(drop), - (Kind::Default, None) => admin.store.delete_default(format).map(drop), - }; - if let Err(e) = restored { - log::server(&format!( - "admin: could not roll back {} {id:?}: {e} — the store may be inconsistent", - kind.label() - )); - } - return json( + match guard::write(&admin.answers, admin.store.as_ref(), &target, format, body) { + guard::Outcome::Unavailable(e) => error(StatusCode::INTERNAL_SERVER_ERROR, &e), + guard::Outcome::Rejected(e) => error(StatusCode::BAD_REQUEST, &e), + guard::Outcome::NotFound => error(StatusCode::NOT_FOUND, "not found"), + guard::Outcome::Refused { introduced } => json( StatusCode::CONFLICT, format!( "{{{}:{},{}}}", @@ -605,18 +552,22 @@ fn guarded( .trim_start_matches('{') .trim_end_matches('}') ), - ); + ), + // Anything still broken is reported on a success too, so a caller is not misled + // into thinking all is well just because their own write was clean. + guard::Outcome::Stored { problems } => stored(StatusCode::OK, "stored", &problems), + guard::Outcome::Deleted { problems } => stored(StatusCode::OK, "deleted", &problems), } +} - // Report anything already broken, so a caller is not misled into thinking all is - // well just because their own write was clean. +fn stored(status: StatusCode, what: &str, problems: &[String]) -> Response { json( - StatusCode::OK, + status, format!( "{{{}:{},{}}}", json_string("status"), - json_string(if body.is_some() { "stored" } else { "deleted" }), - json_list("problems", &after) + json_string(what), + json_list("problems", problems) .trim_start_matches('{') .trim_end_matches('}') ), diff --git a/src/cli.rs b/src/cli.rs index a0c33cf..813f5c0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,7 +20,11 @@ USAGE: rescriptum render print the answer a machine would receive rescriptum render --body FILE print the answer for a captured request body rescriptum render --query Q ...for labels, e.g. \"mac=aa:bb&serial=7ABC1\" + rescriptum render --format that machine's answer in one format rescriptum check validate the configured store + rescriptum status [--json] one screen: counts, problems, listeners + rescriptum machines [--json] every machine, what answers it, and how + rescriptum groups [--json] members, extends chain, what each one claims rescriptum import load a directory of TOML into the store rescriptum export write the store out as a directory of TOML rescriptum migrate show what a flat answers directory would become @@ -30,6 +34,15 @@ USAGE: rescriptum config --value K one value, for a script (never a credential) rescriptum config set K=V edit the configuration file, whichever one is named rescriptum config unset K take a setting back out of it + rescriptum power list out-of-band controllers, joined to the answer set + rescriptum power list --state ...and ask each one whether it is on (slow) + rescriptum power status one machine's power state + rescriptum power on power it on + rescriptum power off graceful shutdown; --hard forces it off + rescriptum power pxe arm a one-time network boot, where supported + rescriptum install check, arm, pxe, power on — the whole gesture + rescriptum tui the fleet on one screen (builds with --features tui) + rescriptum tui --remote URL ...of a deployment's admin API, read-only rescriptum media list the installer images this server holds rescriptum media add FILE register one already in the media directory rescriptum media add URL fetch one into it, then register it @@ -58,6 +71,9 @@ ADMIN API (requires RESCRIPTUM_STORE=sqlite; off unless RESCRIPTUM_ADMIN_ADDR is RESCRIPTUM_ADMIN_ADDR admin listener, e.g. 127.0.0.1:9000 RESCRIPTUM_ADMIN_TOKEN bearer token, 16 characters or more (required) +OUT-OF-BAND CONTROL (off unless RESCRIPTUM_CONTROLLERS_FILE is set): + RESCRIPTUM_CONTROLLERS_FILE BMCs, PiKVMs and PDUs. The server never reads it + BOOT MEDIA (off unless RESCRIPTUM_MEDIA_DIR is set): RESCRIPTUM_MEDIA_DIR directory of installer images RESCRIPTUM_MEDIA_ADDR media listener (default 0.0.0.0:8001) @@ -90,12 +106,27 @@ pub fn render(cfg: &Config, args: &[String]) -> ExitCode { let path = query.split('&').find_map(|p| p.strip_prefix("path=")); Facts::from_request(path, Some(query), b"") } - // A bare identifier claims nothing about what kind of identifier it is. + // A bare identifier claims nothing about what kind of identifier it is, so the + // format is unconstrained and whichever document resolves is the one you get. [id] if !id.starts_with('-') => Facts::from_identity(id), + // **Naming the format is the only way to ask for one**, and there was no + // first-class way to do it: `check` reaches a specific one internally through a + // synthesised path, and `--query "path=/proxmox&mac=…"` worked but was documented + // nowhere. A machine holding `proxmox.toml` and `debian.preseed` is the case the + // layout exists for, so asking for one of them should not be a trick. + [id, flag, format] if flag == "--format" && !id.starts_with('-') => { + if crate::format::Kind::for_extension(format).is_none() { + eprintln!("{format:?} is not a format this program serves"); + return ExitCode::FAILURE; + } + let path = fleet::path_for(format, id); + Facts::from_request(Some(&path), None, id.as_bytes()) + } _ => { eprintln!( "usage: rescriptum render \n\ \x20 rescriptum render --body FILE\n\ + \x20 rescriptum render --format \n\ \x20 rescriptum render --query \"mac=…&serial=…\"" ); return ExitCode::FAILURE; @@ -353,6 +384,899 @@ pub fn check(cfg: &Config) -> ExitCode { } } +/// `power list` — the controllers configured, joined to the answer set. +/// +/// **It must not probe.** Reading a machine's state means one HTTPS round trip per +/// controller, each up to the request deadline; with two hundred controllers and a handful +/// unreachable, a listing that probed would take minutes and look hung. Asking is +/// `--state`, it is bounded and concurrent, and it is never what a redrawing screen does. +pub fn power(cfg: &Config, args: &[String]) -> ExitCode { + let Some(path) = &cfg.controllers_file else { + eprintln!( + "there are no controllers: RESCRIPTUM_CONTROLLERS_FILE names a file, and nothing does" + ); + return ExitCode::FAILURE; + }; + + let controllers = match crate::controllers::load(path) { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + + match args.split_first() { + Some((cmd, rest)) if cmd == "list" && rest.is_empty() => { + power_list(cfg, path, &controllers, false) + } + Some((cmd, rest)) if cmd == "list" && rest == ["--state"] => { + power_list(cfg, path, &controllers, true) + } + Some((cmd, rest)) if cmd == "status" && rest.len() == 1 => { + power_status(&controllers, &rest[0]) + } + Some((cmd, rest)) if cmd == "on" && rest.len() == 1 => { + act(&controllers, &rest[0], "power on", crate::power::on) + } + Some((cmd, rest)) if cmd == "off" && rest.len() == 1 => { + act(&controllers, &rest[0], "shut down", |c| { + crate::power::off(c, false) + }) + } + // Named rather than defaulted: pulling power from a machine mid-write is how a + // filesystem gets repaired by hand later. + Some((cmd, rest)) if cmd == "off" && rest.len() == 2 && rest[1] == "--hard" => { + act(&controllers, &rest[0], "force off", |c| { + crate::power::off(c, true) + }) + } + Some((cmd, rest)) if cmd == "pxe" && rest.len() == 1 => power_pxe(&controllers, &rest[0]), + _ => { + eprintln!( + "usage: rescriptum power list [--state]\n\ + \x20 rescriptum power status \n\ + \x20 rescriptum power on \n\ + \x20 rescriptum power off [--hard]\n\ + \x20 rescriptum power pxe " + ); + ExitCode::FAILURE + } + } +} + +/// Find one controller, or say what is configured instead of "not found". +fn controller_for<'a>( + controllers: &'a crate::controllers::Controllers, + id: &str, +) -> Result<&'a crate::controllers::Controller, ExitCode> { + match controllers.find(id) { + Some(c) => Ok(c), + None => { + eprintln!( + "no controller for {id:?} — {} configured: {}", + controllers.len(), + controllers + .iter() + .map(|c| c.id.as_str()) + .collect::>() + .join(", ") + ); + Err(ExitCode::FAILURE) + } + } +} + +fn act( + controllers: &crate::controllers::Controllers, + id: &str, + what: &str, + run: impl Fn(&crate::controllers::Controller) -> Result<(), String>, +) -> ExitCode { + let c = match controller_for(controllers, id) { + Ok(c) => c, + Err(code) => return code, + }; + match run(c) { + Ok(()) => { + println!("{}: {what} sent", c.id); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("{}: {e}", c.id); + ExitCode::FAILURE + } + } +} + +fn power_status(controllers: &crate::controllers::Controllers, id: &str) -> ExitCode { + let c = match controller_for(controllers, id) { + Ok(c) => c, + Err(code) => return code, + }; + match crate::power::status(c) { + Ok(s) => { + print!("{} [{}] {}", c.id, c.kind.label(), s.state.label()); + match s.boot_override { + Some(o) => println!(" — next boot: {o}"), + None => println!(), + } + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("{}: {e}", c.id); + ExitCode::FAILURE + } + } +} + +fn power_pxe(controllers: &crate::controllers::Controllers, id: &str) -> ExitCode { + let c = match controller_for(controllers, id) { + Ok(c) => c, + Err(code) => return code, + }; + match crate::power::pxe(c) { + Ok(crate::power::Armed::Confirmed) => { + println!( + "{}: one-time network boot armed, and confirmed by reading it back", + c.id + ); + ExitCode::SUCCESS + } + // Not a failure. The boot order stays on PXE and the server decides whether to + // install — which is what RESCRIPTUM_BOOT_UNCLAIMED and the `installed-` disarm + // already do. + Ok(crate::power::Armed::NotSupported) => { + println!( + "{}: this controller has no boot override — leave the boot order on the \ + network and let the server decide whether it installs", + c.id + ); + ExitCode::SUCCESS + } + // The request succeeded and changed nothing. PiKVM answers a PATCH 204 and + // ignores it, so a client trusting the status code would report success for a + // boot that will never happen. + Ok(crate::power::Armed::Ignored) => { + eprintln!( + "{}: the request was accepted and the override is still not set — this \ + controller reports success without doing anything. Leave the boot order \ + on the network instead", + c.id + ); + ExitCode::FAILURE + } + Err(e) => { + eprintln!("{}: {e}", c.id); + ExitCode::FAILURE + } + } +} + +fn power_list( + cfg: &Config, + path: &std::path::Path, + controllers: &crate::controllers::Controllers, + probe: bool, +) -> ExitCode { + // **Both sides of the join.** A controller for a machine nothing answers is not an + // error — you may well be able to power a machine you have no answer for — and a + // machine with an answer and no controller is not one either. The two sets drifting + // apart is the ordinary way this gets confusing, so both are shown. + // + // Asked of the resolver rather than of `machine_ids`, because a machine with no + // document of its own is still answered when a group claims it — and "no answer + // document" would then be true and misleading. Local work against a cached listing; + // nothing here touches the network, which is the whole point of `list` not probing. + let answers = match cfg.open_store() { + Ok(store) => Some(Answers::new(store)), + Err(e) => { + // Worth carrying on: the controllers are still worth listing, and an empty + // "answer" column with no explanation would be a lie. + eprintln!("warning: cannot open the answer store, so nothing is joined: {e}"); + None + } + }; + let answered = |id: &str| -> &'static str { + let Some(answers) = &answers else { + return "answer unknown"; + }; + match answers.resolve(&Facts::from_identity(id)) { + Ok(Some(r)) if r.machine.is_some() => "answered by its own document", + // Worth distinguishing, and this is the first place it shows: a group is + // never disarmed, so a machine armed only by one installs again on every + // network boot. `install` refuses it; here it is simply named. + Ok(Some(_)) => "answered by a group", + Ok(None) => "nothing answers it", + Err(_) => "answer does not render", + } + }; + + println!("{}", path.display()); + if controllers.is_empty() { + println!(" (no controllers)"); + return ExitCode::SUCCESS; + } + + // **A plain listing never probes.** One HTTPS round trip per controller, each up to + // its deadline, is minutes on a rack with a few unreachable BMCs — and it looks hung. + // Asking is `--state`, and it is bounded and concurrent. + let all: Vec<&crate::controllers::Controller> = controllers.iter().collect(); + let states = if probe { + let worst = crate::power::worst_case(&all).as_secs(); + eprintln!( + "asking {} controller(s), {} at a time — up to {worst}s each for one that is \ + unreachable", + all.len(), + crate::power::PROBE_CONCURRENCY + ); + Some(crate::power::probe(&all)) + } else { + None + }; + + let mut claimed = 0usize; + for (n, c) in controllers.iter().enumerate() { + let answer = answered(&c.id); + if !answer.starts_with("nothing") { + claimed += 1; + } + let pxe = if c.kind.can_pxe() { + "one-time PXE" + } else { + // Not a gap. Where one-time boot does not exist the boot order stays on PXE + // and the server decides whether to install — which is what + // RESCRIPTUM_BOOT_UNCLAIMED and the `installed-` disarm already do. + "no PXE override — the server decides" + }; + match states.as_ref().and_then(|s| s.get(n)) { + None => println!(" {} [{}] {answer}, {pxe}", c.id, c.kind.label()), + Some(Ok(s)) => println!( + " {} [{}] {} — {answer}, {pxe}", + c.id, + c.kind.label(), + s.state.label() + ), + // One unreachable controller is a line, never the end of the listing: the + // other two hundred are still worth seeing. + Some(Err(e)) => println!(" {} [{}] unreachable — {e}", c.id, c.kind.label()), + } + if let crate::controllers::Kind::Redfish(r) = &c.kind { + // Said once per controller, every time, because it is the thing somebody + // meant to fix later and did not. + if r.tls == crate::controllers::Tls::Insecure { + println!(" warning: verify = false — this connection is not authenticated"); + } + } + } + println!( + " {} controller(s), {claimed} of them with something to install", + controllers.len() + ); + ExitCode::SUCCESS +} + +/// The fleet, as data. +/// +/// **This is a command before it is a screen**, and deliberately so. Building the model as +/// part of a terminal UI would make it untestable, unscriptable, unavailable to anybody +/// who did not install a build with that feature, and gone if the UI is never finished. +/// Built this way it is covered against both stores, it is what the DSM panel can consume, +/// and a UI becomes a renderer of it rather than a second implementation. +pub mod fleet { + #![allow(clippy::struct_excessive_bools)] + use super::{Answers, Config, Facts, endpoint_segment}; + use crate::admin::{json_list, json_string}; + + /// What answers one machine, and from where. + pub struct Machine { + pub id: String, + /// Formats this machine has documents of, sorted. + pub formats: Vec, + /// The group whose document it layers on, when one claims it. + pub group: Option, + /// Whether an `.ipxe` resolves for it — **asked of the resolver**, because a + /// machine with no document of its own is still armed when a group holds one. + pub armed: bool, + /// And whether that arming is a group's, which is the case that can never disarm + /// itself. + pub armed_by_group: bool, + /// A previous install archived its boot script here. + pub disarmed: bool, + } + + pub struct Group { + pub name: String, + pub format: String, + pub origin: String, + pub members: Vec, + pub matchers: Vec<(String, String)>, + pub extends: Vec, + } + + /// Machines, with `installed-` archives folded into the machine they name rather than + /// listed as machines of their own — they are a *state*, not an identity. + pub fn machines(answers: &Answers) -> std::io::Result> { + let documents = answers.machine_documents()?; + let prefix = crate::installed::DISARMED; + + let mut ids: Vec = documents + .iter() + .map(|(id, _)| id.clone()) + .filter(|id| !id.starts_with(prefix)) + .collect(); + ids.sort(); + ids.dedup(); + + let archived: Vec = documents + .iter() + .filter_map(|(id, _)| id.strip_prefix(prefix).map(str::to_string)) + .collect(); + // A machine whose every document is archived still exists, and leaving it out + // would be how somebody concludes it was deleted. + for id in &archived { + if !ids.contains(id) { + ids.push(id.clone()); + } + } + + // **And a machine a group names is a machine.** It has no document of its own, so + // `machine_documents` cannot see it — but it is answered, it may be armed, and + // leaving it out is how a rack armed entirely from a group shows up as an empty + // fleet. Normalized before comparing, because a group's member list and a + // directory name need not share a separator style. + for group in answers.group_names()? { + for member in answers.group_members(&group.0)? { + let normalized = crate::select::normalize(member.as_bytes()); + if !ids + .iter() + .any(|id| crate::select::normalize(id.as_bytes()) == normalized) + { + ids.push(member); + } + } + } + ids.sort(); + ids.dedup(); + + let mut out = Vec::with_capacity(ids.len()); + for id in ids { + let mut formats: Vec = documents + .iter() + .filter(|(m, _)| m == &id) + .map(|(_, f)| f.clone()) + .collect(); + formats.sort(); + + let resolved = answers.resolve(&Facts::from_identity(&id)).ok().flatten(); + let boot = answers + .resolve(&Facts::from_request( + Some("/ipxe/boot"), + None, + id.as_bytes(), + )) + .ok() + .flatten(); + + out.push(Machine { + group: resolved.as_ref().and_then(|r| r.group.clone()), + armed: boot.is_some(), + armed_by_group: boot.as_ref().is_some_and(|r| r.machine.is_none()), + disarmed: archived.contains(&id), + formats, + id, + }); + } + Ok(out) + } + + pub fn groups(answers: &Answers) -> std::io::Result> { + let mut out = Vec::new(); + for (name, origin) in answers.group_names()? { + out.push(Group { + format: answers + .group_format(&name)? + .unwrap_or_else(|| "toml".to_string()), + members: answers.group_members(&name)?, + matchers: answers.group_matchers(&name)?, + extends: answers.group_extends(&name)?, + origin, + name, + }); + } + Ok(out) + } + + /// Hand-written, because **there is no `serde` derive anywhere in this project** and + /// this does not introduce one. The two helpers come from `admin`, so the API and the + /// command cannot disagree about escaping. + pub fn machines_json(machines: &[Machine]) -> String { + let rows: Vec = machines + .iter() + .map(|m| { + format!( + "{{{}:{},{}:{},{}:{},{}:{},{}:{},{}:{}}}", + json_string("id"), + json_string(&m.id), + json_string("formats"), + array(&m.formats), + json_string("group"), + m.group.as_deref().map_or("null".to_string(), json_string), + json_string("armed"), + m.armed, + json_string("armed_by_group"), + m.armed_by_group, + json_string("disarmed"), + m.disarmed, + ) + }) + .collect(); + format!("{{{}:[{}]}}", json_string("machines"), rows.join(",")) + } + + pub fn groups_json(groups: &[Group]) -> String { + let rows: Vec = groups + .iter() + .map(|g| { + let matchers: Vec = g + .matchers + .iter() + .map(|(k, v)| format!("{}:{}", json_string(k), json_string(v))) + .collect(); + format!( + "{{{}:{},{}:{},{}:{},{}:{},{}:{{{}}},{}:{}}}", + json_string("name"), + json_string(&g.name), + json_string("format"), + json_string(&g.format), + json_string("origin"), + json_string(&g.origin), + json_string("members"), + array(&g.members), + json_string("match"), + matchers.join(","), + json_string("extends"), + array(&g.extends), + ) + }) + .collect(); + format!("{{{}:[{}]}}", json_string("groups"), rows.join(",")) + } + + pub fn problems_json(problems: &[String]) -> String { + json_list("problems", problems) + } + + fn array(items: &[String]) -> String { + let quoted: Vec = items.iter().map(|s| json_string(s)).collect(); + format!("[{}]", quoted.join(",")) + } + + /// A machine's answer for one named format, which the CLI otherwise has no way to ask + /// for: `render ` builds facts with no path, so the format is unconstrained. + pub fn path_for(format: &str, id: &str) -> String { + format!("/{}/{id}", endpoint_segment(format)) + } + + /// So `status` can say what the store is without repeating itself. + pub fn describe(cfg: &Config, answers: &Answers) -> String { + let _ = cfg; + answers.describe() + } +} + +/// `status`, `machines`, `groups` — the fleet, rendered as text or as JSON. +/// +/// `--json` follows `config --json`, which the DSM panel already consumes. One producer +/// for both renderings, so a screen and a script can never disagree about what is true. +pub fn fleet_command(cfg: &Config, what: &str, args: &[String]) -> ExitCode { + let json = match args { + [] => false, + [flag] if flag == "--json" => true, + _ => { + eprintln!("usage: rescriptum {what} [--json]"); + return ExitCode::FAILURE; + } + }; + + let answers = match cfg.open_store() { + Ok(store) => Answers::new(store), + Err(e) => { + eprintln!("cannot open the answer store: {e}"); + return ExitCode::FAILURE; + } + }; + + match what { + "machines" => match fleet::machines(&answers) { + Ok(m) if json => { + println!("{}", fleet::machines_json(&m)); + ExitCode::SUCCESS + } + Ok(machines) => { + for m in &machines { + let answered = match (&m.group, m.formats.is_empty()) { + (Some(g), _) => format!("group {g}"), + (None, false) => "its own documents".to_string(), + (None, true) => "nothing".to_string(), + }; + let armed = match (m.armed, m.armed_by_group, m.disarmed) { + // Named every time, because it is the state that reinstalls + // forever and looks like a success while doing it. + (true, true, _) => " armed by a group, which cannot disarm itself", + (true, false, _) => " armed", + (false, _, true) => " disarmed by a previous install", + (false, _, false) => "", + }; + println!(" {} [{}] {answered}{armed}", m.id, m.formats.join(",")); + } + println!(" {} machine(s)", machines.len()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("cannot read the answer set: {e}"); + ExitCode::FAILURE + } + }, + "groups" => match fleet::groups(&answers) { + Ok(g) if json => { + println!("{}", fleet::groups_json(&g)); + ExitCode::SUCCESS + } + Ok(groups) => { + for g in &groups { + println!(" {} [{}] {}", g.name, g.format, g.origin); + if !g.extends.is_empty() { + println!(" extends {}", g.extends.join(" -> ")); + } + if !g.members.is_empty() { + println!(" {} member(s)", g.members.len()); + } + for (k, v) in &g.matchers { + println!(" match {k}={v}"); + } + } + println!(" {} group(s)", groups.len()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("cannot read the answer set: {e}"); + ExitCode::FAILURE + } + }, + _ => status_command(cfg, &answers, json), + } +} + +fn status_command(cfg: &Config, answers: &Answers, as_json: bool) -> ExitCode { + let machines = fleet::machines(answers).unwrap_or_default(); + let groups = fleet::groups(answers).unwrap_or_default(); + let problems = answers.problems().unwrap_or_default(); + let armed = machines.iter().filter(|m| m.armed).count(); + let by_group = machines.iter().filter(|m| m.armed_by_group).count(); + let disarmed = machines.iter().filter(|m| m.disarmed).count(); + + if as_json { + println!( + "{{{}:{},{}:{},{}:{},{}:{},{}:{},{}:{},{}}}", + crate::admin::json_string("store"), + crate::admin::json_string(&fleet::describe(cfg, answers)), + crate::admin::json_string("machines"), + machines.len(), + crate::admin::json_string("groups"), + groups.len(), + crate::admin::json_string("armed"), + armed, + crate::admin::json_string("armed_by_group"), + by_group, + crate::admin::json_string("disarmed"), + disarmed, + fleet::problems_json(&problems) + .trim_start_matches('{') + .trim_end_matches('}') + ); + return ExitCode::SUCCESS; + } + + println!("{}", fleet::describe(cfg, answers)); + println!(" {} machine(s), {} group(s)", machines.len(), groups.len()); + println!(" {armed} armed, {disarmed} disarmed by a previous install"); + if by_group > 0 { + // Worth its own line rather than a footnote: these machines will reinstall on + // every network boot, and their webhook reports success while doing it. + println!(" {by_group} of those armed by a group, which `POST /installed` cannot disarm"); + } + if problems.is_empty() { + println!(" no problems"); + } else { + for problem in &problems { + println!(" problem: {problem}"); + } + } + // Zero problems is the normal state, so a non-zero exit here would cry wolf. `check` + // is what keys an exit code on this. + ExitCode::SUCCESS +} + +/// `tui` — the fleet on one screen. +/// +/// A **renderer over the read model**, never a second implementation: everything it shows +/// has a command that prints the same thing, because scripts, `deploy.sh` and CI cannot +/// press keys. +#[cfg(feature = "tui")] +pub fn tui(cfg: &Config, args: &[String]) -> ExitCode { + let source = match args { + [] => match cfg.open_store() { + Ok(store) => crate::tui::draw::Source::Local(Answers::new(store)), + Err(e) => { + eprintln!("cannot open the answer store: {e}"); + return ExitCode::FAILURE; + } + }, + // **Remote mode reads and nothing else.** It inherits SQLite-only from the API it + // speaks to, shows the three screens that API has, and powers nothing — refused in + // the state machine so no screen can forget. + [flag, url] if flag == "--remote" => { + let token = cfg.admin_token.clone().unwrap_or_default(); + match crate::tui::remote::Remote::new(url, &token) { + Ok(r) => crate::tui::draw::Source::Remote(r), + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + } + } + _ => { + eprintln!("usage: rescriptum tui [--remote URL]"); + return ExitCode::FAILURE; + } + }; + tui_run(cfg, source) +} + +#[cfg(feature = "tui")] +fn tui_run(cfg: &Config, source: crate::tui::draw::Source) -> ExitCode { + // The server logs where RESCRIPTUM_LOG_FILE says; this does not. A screen that + // reloads writes a `warning:` line per problem per reload, and burying the log an + // operator uses to diagnose installs would be a poor trade for a status bar. + match crate::tui::draw::run(cfg, source) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("tui: {e}"); + ExitCode::FAILURE + } + } +} + +/// Without the feature the command still exists and says why it can do nothing — the rule +/// `media` and `boot` already follow, so the surface stays described everywhere and only +/// the binary that cannot honour it objects. +#[cfg(not(feature = "tui"))] +pub fn tui(_cfg: &Config, _args: &[String]) -> ExitCode { + eprintln!( + "this binary was built without the `tui` feature, so it has no terminal interface. \ + `status`, `machines` and `groups` show the same model as text or JSON." + ); + ExitCode::FAILURE +} + +/// `install ` — the command the whole out-of-band feature exists for. +/// +/// In order, and **the first step is the one that makes it safe**: powering on a machine +/// that PXE-boots, chains the installer and then meets a 404 leaves an installer sitting +/// at a prompt in a rack, which is the exact failure this project exists to prevent. +/// +/// 1. **Resolve.** Every format this machine resolves for renders cleanly — templates +/// filled, no missing fact. Not a guess at which one the boot script leads to: render +/// them all, which is `check` scoped to one machine. +/// 2. **Check the policy**, including the group-arming refusal below. +/// 3. **Arm**, by putting back what a previous install archived. +/// 4. **PXE**, where the controller has one. +/// 5. **On, or restart**, decided by reading the power state. +pub fn install(cfg: &Config, args: &[String]) -> ExitCode { + let (id, dry_run) = match args { + [id] if !id.starts_with('-') => (id.as_str(), false), + [id, flag] if flag == "--dry-run" => (id.as_str(), true), + _ => { + eprintln!("usage: rescriptum install [--dry-run]"); + return ExitCode::FAILURE; + } + }; + + // Two handles rather than one: `Answers` reads and `StoreWrite` writes, and the + // reader's trait object is not the writer's. Over files this is free; over SQLite it + // is a second connection, which WAL is there for. + let store = match cfg.open_store() { + Ok(store) => store, + Err(e) => { + eprintln!("cannot open the answer store: {e}"); + return ExitCode::FAILURE; + } + }; + let answers = match cfg.open_store() { + Ok(store) => Answers::new(store), + Err(e) => { + eprintln!("cannot open the answer store: {e}"); + return ExitCode::FAILURE; + } + }; + + // ---- 1. would this machine actually be answered, and does it render? ---- + let normalized = crate::select::normalize(id.as_bytes()); + let documents: Vec = match answers.machine_documents() { + Ok(docs) => docs + .into_iter() + .filter(|(machine, _)| crate::select::normalize(machine.as_bytes()) == normalized) + .map(|(_, format)| format) + .collect(), + Err(e) => { + eprintln!("cannot read the answer set: {e}"); + return ExitCode::FAILURE; + } + }; + + let mut failures = 0usize; + for format in &documents { + let path = format!("/{}/{id}", endpoint_segment(format)); + let facts = Facts::from_request(Some(&path), None, id.as_bytes()); + match answers.resolve(&facts) { + Ok(Some(_)) => println!(" ok {format}"), + Ok(None) => { + println!(" FAIL {format}: nothing resolves on {path}"); + failures += 1; + } + Err(e) => { + println!(" FAIL {format}: {e}"); + failures += 1; + } + } + } + if failures > 0 { + eprintln!( + "{id}: {failures} document(s) would not render — nothing has been powered on. \ + A machine that boots into a broken answer sits at a prompt in a rack" + ); + return ExitCode::FAILURE; + } + + // ---- 2. is it armed, and by what? ---- + let boot = Facts::from_request(Some("/ipxe/boot"), None, id.as_bytes()); + let armed = match answers.resolve(&boot) { + Ok(r) => r, + Err(e) => { + eprintln!("{id}: the boot script does not render: {e}"); + return ExitCode::FAILURE; + } + }; + + let archived = crate::installed::archived(store.as_ref(), id).unwrap_or(false); + match &armed { + Some(r) if r.machine.is_some() => println!(" armed by its own document"), + // **A group is never disarmed.** `installed::disarm` moves a machine's own + // document and never a group's, deliberately, so that one machine finishing + // cannot disarm a rack. A machine armed only by its group therefore installs, + // reports success, is not disarmed, and installs again on its next network boot — + // and `BootSourceOverrideEnabled=Once` does not help, because the second boot is + // the machine's own boot order rather than the override. + Some(r) => { + eprintln!( + "{id}: its boot script comes from group {:?}, and a group is never \ + disarmed — this machine would reinstall itself on every network boot. \ + Give it its own .ipxe document instead, which `POST /installed` can move \ + aside when it reports success", + r.group.as_deref().unwrap_or("?") + ); + return ExitCode::FAILURE; + } + None if archived => { + println!(" disarmed by a previous install; its document will be put back") + } + None => { + // Refused in both modes, for different reasons. + if cfg.unclaimed_boots_local() { + eprintln!( + "{id}: nothing arms it, and RESCRIPTUM_BOOT_UNCLAIMED=local means it \ + would boot its own disk and report nothing — which looks exactly \ + like a successful install" + ); + } else { + eprintln!( + "{id}: nothing arms it, so it would sit on the boot menu waiting for \ + somebody who is not coming, and burn a boot cycle" + ); + } + return ExitCode::FAILURE; + } + } + + let controllers = match cfg.controllers_file.as_deref() { + Some(path) => match crate::controllers::load(path) { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }, + None => { + eprintln!( + "there are no controllers: RESCRIPTUM_CONTROLLERS_FILE names a file, and \ + nothing does" + ); + return ExitCode::FAILURE; + } + }; + let Some(controller) = controllers.find(id) else { + eprintln!("{id}: no controller, so nothing here can power it on"); + return ExitCode::FAILURE; + }; + + if dry_run { + println!("{id}: everything renders and it is armed — nothing was powered on"); + return ExitCode::SUCCESS; + } + + // ---- 3. arm ---- + if archived { + match crate::installed::rearm(store.as_ref(), id) { + Ok(Some(under)) => println!(" its boot script is back, as {under}"), + Ok(None) => { + eprintln!( + "{id}: its archived boot script vanished between the check and the write" + ); + return ExitCode::FAILURE; + } + Err(e) => { + eprintln!("{id}: cannot put its boot script back: {e}"); + return ExitCode::FAILURE; + } + } + } + + // ---- 4. one-time PXE, where there is one ---- + match crate::power::pxe(controller) { + Ok(crate::power::Armed::Confirmed) => println!(" one-time network boot armed"), + Ok(crate::power::Armed::NotSupported) => { + println!(" no boot override on this controller — the server decides instead") + } + Ok(crate::power::Armed::Ignored) => { + println!( + " this controller accepted the override and did not apply it — \ + relying on the boot order instead" + ) + } + Err(e) => { + eprintln!("{id}: {e}"); + return ExitCode::FAILURE; + } + } + + // ---- 5. on, or restart ---- + let running = matches!( + crate::power::status(controller).map(|s| s.state), + Ok(crate::power::State::On) + ); + let outcome = if running { + // `ResetType: "On"` sent to a system already on is refused by many services and a + // no-op on others — either way nothing happens while it looks like it did. A + // machine being reinstalled is usually running. + println!(" it is already on, so restarting rather than powering on"); + crate::power::restart(controller) + } else { + crate::power::on(controller) + }; + + match outcome { + Ok(()) => { + println!("{id}: installing. Watch the log, or wait for POST /installed"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("{id}: {e}"); + ExitCode::FAILURE + } + } +} + /// `import ` — copy a directory of answer files into the configured store. pub fn import(cfg: &Config, args: &[String]) -> ExitCode { let [dir] = args else { diff --git a/src/config.rs b/src/config.rs index d9bd2ca..63256dd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -77,6 +77,14 @@ pub struct Config { pub answer_token: Option, /// Where to record what machines actually send. Off unless set. pub capture_dir: Option, + /// Out-of-band controllers, keyed by the same identity as the answers directory. + /// + /// **The server never reads this file** — `power` and `install` do. Naming a file the + /// answer listener would die on would take a fleet's installs down for a reason + /// unrelated to answering, which is the failure the "unset, it does not exist" rule + /// exists to prevent. Startup only says whether it is there and whether its mode is + /// alarming. + pub controllers_file: Option, /// How much to log. `All` keeps every request; `Problems` keeps everything except the /// requests that worked, which is the only high-volume thing here. pub log_level: crate::log::Level, @@ -257,6 +265,7 @@ impl Config { admin_token: optional("RESCRIPTUM_ADMIN_TOKEN"), answer_token: optional("RESCRIPTUM_ANSWER_TOKEN"), capture_dir: optional("RESCRIPTUM_CAPTURE_DIR").map(PathBuf::from), + controllers_file: optional("RESCRIPTUM_CONTROLLERS_FILE").map(PathBuf::from), log_level: match optional("RESCRIPTUM_LOG") { Some(value) => crate::log::Level::parse(&value).unwrap_or_else(|| { // A typo must not be the reason nobody can see why a rollout failed. @@ -758,7 +767,7 @@ pub struct Known { /// Every variable, in the order a person would want to meet them: what answers come /// from, where the server listens, how much it says, then the two credentials. -pub const KNOWN: [Known; 30] = [ +pub const KNOWN: [Known; 31] = [ Known { key: "RESCRIPTUM_STORE", default: Some("files"), @@ -821,6 +830,12 @@ pub const KNOWN: [Known; 30] = [ secret: false, help: "Record what installers actually send, for when nothing is answered.", }, + Known { + key: "RESCRIPTUM_CONTROLLERS_FILE", + default: None, + secret: false, + help: "Out-of-band controllers, for `power` and `install`. The server never reads it.", + }, Known { key: "RESCRIPTUM_ANSWER_TOKEN", default: None, diff --git a/src/controllers.rs b/src/controllers.rs new file mode 100644 index 0000000..39163b3 --- /dev/null +++ b/src/controllers.rs @@ -0,0 +1,603 @@ +//! Where a machine's out-of-band controller is described — a BMC, a PiKVM, a PDU. +//! +//! **Not in the answer document.** The tempting design is a `[controller]` control key +//! beside `extends` and `members`, stripped before the answer is sent. It is refused: a +//! control key holding a BMC password is one strip bug away from handing that credential +//! to the machine currently being installed, and that machine is by definition in an +//! untrusted state. The blast radius is the whole fleet's power control. +//! +//! **Not in SQLite either.** `export ` writes the store out as a directory and the +//! round trip is byte-identical by contract, so a controllers table would either be +//! exported — writing fleet power credentials into a directory somebody is about to copy +//! somewhere — or silently dropped, breaking the round trip that makes the database safe +//! to leave. Neither is acceptable, so it is a file. +//! +//! **Named, never discovered.** There is no `./controllers.toml`. A file picked up from +//! the working directory would hand fleet power to whoever can write there — the same +//! reasoning `RESCRIPTUM_ENV_FILE` already has. +//! +//! **The server never reads it.** `power` and `install` do. A malformed BMC credentials +//! file that stopped the answer listener would take a fleet's installs down for a reason +//! entirely unrelated to answering, which is the failure the "unset, it does not exist" +//! rule exists to prevent. Startup says only whether the file is there and whether its +//! mode is alarming; it never parses it. + +use crate::select::normalize; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use toml_edit::{DocumentMut, Item, Value}; + +/// What a Redfish service is rooted at when nobody says otherwise. PiKVM serves +/// `/api/redfish/v1` instead — and note its response bodies say `/redfish/v1` regardless, +/// which is why URLs are composed from this value and an id rather than followed out of +/// the body. +pub const DEFAULT_BASE: &str = "/redfish/v1"; + +/// A hung `pdu` script would otherwise hang `install` forever: `std::process::Command` +/// has no deadline of its own. +pub const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(30); + +/// How this controller's certificate is to be trusted. +/// +/// **One of these is required**, and an entry carrying none is refused. Self-signed is +/// the normal case for a BMC, so defaulting to "do not verify" would be the convenient +/// choice — and it is the same convenience `media add` already refuses, where a URL +/// requires `--sha256` unless `--unverified` is passed. This link can power-cycle a rack; +/// one line of friction is the right price. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Tls { + /// `verify = false`. Said out loud, once per controller, wherever this is used. + Insecure, + /// A fleet with its own CA, which large ones have. + CaCert(PathBuf), + /// The right answer for a self-signed BMC: free, because curl implements it, and the + /// same shape as the `cert_fingerprint` Proxmox's own `auto-installer-mode.toml` + /// already takes. + PinnedPubKey(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Redfish { + pub url: String, + pub base: String, + pub user: String, + pub pass: String, + /// Set only where discovery cannot decide — a chassis, or a PiKVM with a switch, + /// exposes several systems and picking the first would power somebody else's machine. + pub system: Option, + pub tls: Tls, +} + +/// The escape hatch: a switched PDU, `ipmitool`, `amtterm`, `wakeonlan`, and whatever +/// hardware exists in five years — without this project learning any of them. +/// +/// Three rules keep it from being a hole, and all three are enforced here or at the call +/// site: **argv only, never a shell**; **no substitution from request facts**, so nothing +/// a machine sent over the network can reach an argument vector; and **a deadline**. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandHook { + pub on: Vec, + pub off: Vec, + /// Often empty, and that is not a gap: where one-time boot does not exist the boot + /// order stays on PXE permanently and the *server* decides whether to install. + pub pxe: Vec, + pub timeout: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Kind { + Redfish(Redfish), + Command(CommandHook), +} + +impl Kind { + pub fn label(&self) -> &'static str { + match self { + Kind::Redfish(_) => "redfish", + Kind::Command(_) => "command", + } + } + + /// Whether this controller can arm a one-time network boot at all. + /// + /// `false` is not a defect. Where one-time boot does not exist the boot order stays on + /// PXE and `RESCRIPTUM_BOOT_UNCLAIMED` plus the `installed-` disarm decide whether a + /// machine installs — a PiKVM plus rescriptum is a complete solution, and a BMC with + /// one-time boot is belt and braces. + pub fn can_pxe(&self) -> bool { + match self { + Kind::Redfish(_) => true, + Kind::Command(c) => !c.pxe.is_empty(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Controller { + /// The key as written, for anything a person reads. + pub id: String, + /// The same key normalized, which is how it joins to the answers directory. + pub identity: String, + pub kind: Kind, +} + +#[derive(Debug, Clone, Default)] +pub struct Controllers { + entries: Vec, +} + +impl Controllers { + pub fn iter(&self) -> impl Iterator { + self.entries.iter() + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// The controller for a machine, matched the way the answers directory matches. + /// + /// Both sides are normalized, so `98:fa:9b:50:d8:10` and `98fa9b50d810` are one + /// machine on both sides. A second identity space would have been a mistake. + pub fn find(&self, id: &str) -> Option<&Controller> { + let wanted = normalize(id.as_bytes()); + self.entries.iter().find(|c| c.identity == wanted) + } +} + +/// Read and parse the file, or say why not. +/// +/// **Group- or world-readable is refused here**, where `envfile` only warns. The +/// divergence is deliberate: refusing an env file would stop a server that is otherwise +/// healthy, while refusing this one costs a single interactive command. Note the blind +/// spot already recorded for the answers directory — mode bits lie on DSM, where an ACL +/// can grant access `st_mode` never mentions — so this is a warning about the *mode* plus +/// documentation, not a proof of privacy. +pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|e| format!("RESCRIPTUM_CONTROLLERS_FILE={} : {e}", path.display()))?; + + if let Some(mode) = crate::envfile::readable_by_others(path) { + return Err(format!( + "{} is mode {mode:04o} — it holds credentials that can power-cycle a rack; chmod 600 it", + path.display() + )); + } + + parse(&text).map_err(|e| format!("{}: {e}", path.display())) +} + +/// The parser, separated from the file so it can be tested without one. +pub fn parse(text: &str) -> Result { + let doc = text + .parse::() + .map_err(|e| e.to_string().replace('\n', " "))?; + + let mut entries: Vec = Vec::new(); + let mut seen: BTreeSet = BTreeSet::new(); + + for (key, item) in doc.as_table().iter() { + let table = item + .as_table() + .ok_or_else(|| format!("{key}: expected a table, `[\"{key}\"]`"))?; + + let identity = normalize(key.as_bytes()); + if identity.is_empty() { + return Err(format!("{key}: this name normalizes to nothing")); + } + // Two entries that differ only in separator style are one machine here, exactly + // as they are in the answers directory, so the second is a mistake rather than an + // override nobody would see applied. + if !seen.insert(identity.clone()) { + return Err(format!("{key}: a second entry for the same machine")); + } + + let kind = match string(table, key, "kind")?.as_deref() { + Some("redfish") => Kind::Redfish(redfish(table, key)?), + Some("command") => Kind::Command(command(table, key)?), + Some(other) => { + return Err(format!( + "{key}: kind = {other:?} is not one this program drives — \ + \"redfish\" or \"command\"" + )); + } + None => return Err(format!("{key}: no `kind` — \"redfish\" or \"command\"")), + }; + + entries.push(Controller { + id: key.to_string(), + identity, + kind, + }); + } + + entries.sort_by(|a, b| a.identity.cmp(&b.identity)); + Ok(Controllers { entries }) +} + +fn redfish(table: &toml_edit::Table, key: &str) -> Result { + let url = required(table, key, "url")?; + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(format!("{key}: url = {url:?} needs a scheme")); + } + // A URL, not a host — and a path here would be silently appended to every request. + if url.trim_end_matches('/').matches('/').count() > 2 { + return Err(format!( + "{key}: url = {url:?} carries a path; put it in `base` instead" + )); + } + + let base = string(table, key, "base")?.unwrap_or_else(|| DEFAULT_BASE.to_string()); + let tls = tls(table, key)?; + + Ok(Redfish { + url: url.trim_end_matches('/').to_string(), + base: format!("/{}", base.trim_matches('/')), + user: required(table, key, "user")?, + pass: required(table, key, "pass")?, + system: string(table, key, "system")?, + tls, + }) +} + +/// Exactly one of the three, and an entry with none is refused naming all three. +fn tls(table: &toml_edit::Table, key: &str) -> Result { + let verify = match table.get("verify") { + None => None, + Some(item) => Some(item.as_bool().ok_or_else(|| { + format!("{key}: verify must be true or false, and only `false` means anything here") + })?), + }; + let cacert = string(table, key, "cacert")?; + let pinned = string(table, key, "pinnedpubkey")?; + + if verify == Some(true) { + // Checked before the count below, so that a deliberate `verify = true` gets the + // answer to what it was reaching for rather than the generic "say something". + // Not an error because the system trust store is meaningless — it is a real + // answer, just not one a BMC's self-signed certificate can satisfy. + return Err(format!( + "{key}: verify = true is the default for a public certificate authority, which \ + a BMC does not have. Use `cacert` for your own CA, or `pinnedpubkey`" + )); + } + + let chosen = usize::from(verify == Some(false)) + + usize::from(cacert.is_some()) + + usize::from(pinned.is_some()); + if chosen == 0 { + return Err(format!( + "{key}: say how this controller's certificate is to be trusted — one of \ + `verify = false`, `cacert = \"…\"` or `pinnedpubkey = \"sha256//…\"`. \ + A BMC ships a self-signed certificate, so there is no safe default to pick \ + for you" + )); + } + if chosen > 1 { + return Err(format!( + "{key}: `verify`, `cacert` and `pinnedpubkey` are three answers to one \ + question; give exactly one" + )); + } + Ok(match (cacert, pinned) { + (Some(path), _) => Tls::CaCert(PathBuf::from(path)), + (_, Some(k)) => Tls::PinnedPubKey(k), + _ => Tls::Insecure, + }) +} + +fn command(table: &toml_edit::Table, key: &str) -> Result { + let hook = CommandHook { + on: argv(table, key, "on")?, + off: argv(table, key, "off")?, + pxe: argv(table, key, "pxe")?, + timeout: match table.get("timeout") { + None => DEFAULT_COMMAND_TIMEOUT, + Some(item) => { + let secs = item + .as_integer() + .ok_or_else(|| format!("{key}: timeout is a number of seconds"))?; + if secs <= 0 { + return Err(format!("{key}: timeout = {secs} would never run anything")); + } + Duration::from_secs(secs as u64) + } + }, + }; + if hook.on.is_empty() && hook.off.is_empty() { + return Err(format!( + "{key}: a command controller that can neither power on nor off does nothing" + )); + } + Ok(hook) +} + +/// An argument vector, **never a string to be split**. +/// +/// No `sh -c`, no word splitting, no interpolation: the array is handed to `Command` as +/// written. Accepting a string here would be the moment a quoting bug became a shell +/// injection, on a server that runs as root. +fn argv(table: &toml_edit::Table, key: &str, field: &str) -> Result, String> { + match table.get(field) { + None => Ok(Vec::new()), + Some(Item::Value(Value::Array(array))) => { + let mut out = Vec::with_capacity(array.len()); + for element in array { + let s = element + .as_str() + .ok_or_else(|| format!("{key}: every element of `{field}` must be a string"))?; + out.push(s.to_string()); + } + Ok(out) + } + Some(Item::Value(Value::String(_))) => Err(format!( + "{key}: `{field}` is an argument vector, not a command line — \ + [\"/usr/local/bin/pdu\", \"outlet\", \"7\", \"on\"]. Nothing here is passed \ + through a shell, so a string could not be split safely" + )), + Some(_) => Err(format!("{key}: `{field}` must be an array of strings")), + } +} + +fn string(table: &toml_edit::Table, key: &str, field: &str) -> Result, String> { + match table.get(field) { + None => Ok(None), + Some(item) => match item.as_str() { + Some(s) => Ok(Some(s.to_string())), + None => Err(format!("{key}: `{field}` must be a string")), + }, + } +} + +fn required(table: &toml_edit::Table, key: &str, field: &str) -> Result { + string(table, key, field)?.ok_or_else(|| format!("{key}: no `{field}`")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn one(text: &str) -> Controller { + let c = parse(text).expect("should parse"); + assert_eq!(c.len(), 1); + c.iter().next().expect("one entry").clone() + } + + fn err(text: &str) -> String { + parse(text).expect_err("should be refused") + } + + const REDFISH: &str = r#" +["98-fa-9b-50-d8-10"] +kind = "redfish" +url = "https://10.0.0.51" +user = "root" +pass = "calvin" +verify = false +"#; + + #[test] + fn a_redfish_entry_reads_back() { + let c = one(REDFISH); + assert_eq!(c.id, "98-fa-9b-50-d8-10"); + assert_eq!(c.identity, "98fa9b50d810"); + let Kind::Redfish(r) = &c.kind else { + panic!("expected redfish, got {:?}", c.kind) + }; + assert_eq!(r.url, "https://10.0.0.51"); + // The base is what everything else is composed from, so its default matters. + assert_eq!(r.base, DEFAULT_BASE); + assert_eq!(r.tls, Tls::Insecure); + assert!(r.system.is_none()); + } + + #[test] + fn separator_style_does_not_make_a_second_machine() { + // The same identity space as the answers directory, or the two sides of the join + // drift apart in exactly the way that is hardest to notice. + let c = parse(REDFISH).expect("parse"); + for spelling in ["98:fa:9b:50:d8:10", "98fa9b50d810", "98-FA-9B-50-D8-10"] { + assert!(c.find(spelling).is_some(), "{spelling} should match"); + } + assert!(c.find("aa:bb:cc:dd:ee:ff").is_none()); + } + + #[test] + fn two_spellings_of_one_machine_are_refused() { + let text = format!("{REDFISH}\n[\"98fa9b50d810\"]\nkind = \"redfish\"\n"); + assert!(err(&text).contains("a second entry"), "{}", err(&text)); + } + + /// The rule `media add` already has, applied here: the unsafe path is a deliberate + /// act, because this link can power-cycle a rack. + #[test] + fn an_entry_that_says_nothing_about_the_certificate_is_refused() { + let text = r#" +["98fa9b50d810"] +kind = "redfish" +url = "https://10.0.0.51" +user = "root" +pass = "calvin" +"#; + let e = err(text); + assert!(e.contains("verify"), "{e}"); + assert!(e.contains("cacert"), "{e}"); + assert!(e.contains("pinnedpubkey"), "{e}"); + } + + #[test] + fn three_answers_to_one_question_are_refused() { + let text = r#" +["98fa9b50d810"] +kind = "redfish" +url = "https://10.0.0.51" +user = "root" +pass = "calvin" +verify = false +pinnedpubkey = "sha256//abc" +"#; + assert!(err(text).contains("exactly one"), "{}", err(text)); + } + + #[test] + fn a_pinned_key_and_a_ca_are_both_understood() { + let pinned = one(&REDFISH.replace("verify = false", "pinnedpubkey = \"sha256//abc\"")); + let Kind::Redfish(r) = &pinned.kind else { + panic!("redfish") + }; + assert_eq!(r.tls, Tls::PinnedPubKey("sha256//abc".to_string())); + + let ca = one(&REDFISH.replace("verify = false", "cacert = \"/etc/bmc-ca.pem\"")); + let Kind::Redfish(r) = &ca.kind else { + panic!("redfish") + }; + assert_eq!(r.tls, Tls::CaCert(PathBuf::from("/etc/bmc-ca.pem"))); + } + + /// `verify = true` is a real intention and a wrong one here, so it is named rather + /// than quietly treated as "not false". + #[test] + fn verify_true_says_what_it_would_mean() { + let e = err(&REDFISH.replace("verify = false", "verify = true")); + assert!(e.contains("certificate authority"), "{e}"); + } + + /// A password is written by a person into a file and read back by a program. If those + /// two disagree about backslashes and quotes, the BMC answers 401 and that reads as a + /// wrong password rather than as a bug. + #[test] + fn a_password_carrying_a_quote_and_a_backslash_survives() { + let text = r#" +["98fa9b50d810"] +kind = "redfish" +url = "https://10.0.0.51" +user = "root" +pass = 'a"b\c' +verify = false +"#; + let Kind::Redfish(r) = &one(text).kind else { + panic!("redfish") + }; + assert_eq!(r.pass, r#"a"b\c"#); + } + + #[test] + fn the_base_is_normalized_so_pikvm_can_be_written_either_way() { + for written in ["/api/redfish/v1", "api/redfish/v1", "/api/redfish/v1/"] { + let text = REDFISH.replace( + "kind = \"redfish\"", + &format!("kind = \"redfish\"\nbase = \"{written}\""), + ); + let Kind::Redfish(r) = &one(&text).kind else { + panic!("redfish") + }; + assert_eq!(r.base, "/api/redfish/v1", "{written}"); + } + } + + #[test] + fn a_url_carrying_a_path_is_refused_rather_than_silently_prefixed() { + let e = err(&REDFISH.replace("https://10.0.0.51", "https://10.0.0.51/redfish/v1")); + assert!(e.contains("base"), "{e}"); + } + + #[test] + fn a_command_hook_reads_back_with_its_deadline() { + let text = r#" +["aa-bb-cc-dd-ee-ff"] +kind = "command" +on = ["/usr/local/bin/pdu", "outlet", "7", "on"] +off = ["/usr/local/bin/pdu", "outlet", "7", "off"] +pxe = [] +timeout = 45 +"#; + let c = one(text); + let Kind::Command(h) = &c.kind else { + panic!("expected command, got {:?}", c.kind) + }; + assert_eq!(h.on, ["/usr/local/bin/pdu", "outlet", "7", "on"]); + assert_eq!(h.timeout, Duration::from_secs(45)); + // Empty `pxe` is the ordinary case, not a gap: the boot order stays on PXE and + // the server decides whether to install. + assert!(h.pxe.is_empty()); + assert!(!c.kind.can_pxe()); + } + + #[test] + fn a_command_written_as_a_line_is_refused_rather_than_split() { + // Splitting it would be the moment a quoting bug became a shell injection, on a + // server that runs as root. + let text = r#" +["aa-bb-cc-dd-ee-ff"] +kind = "command" +on = "/usr/local/bin/pdu outlet 7 on" +"#; + let e = err(text); + assert!(e.contains("argument vector"), "{e}"); + } + + #[test] + fn a_command_that_can_do_nothing_is_refused() { + assert!(err("[\"aa-bb-cc-dd-ee-ff\"]\nkind = \"command\"\n").contains("does nothing")); + } + + #[test] + fn an_unknown_kind_is_named() { + let e = err("[\"aa\"]\nkind = \"ipmi\"\n"); + assert!(e.contains("ipmi"), "{e}"); + assert!(e.contains("redfish"), "{e}"); + } + + #[test] + fn entries_come_back_in_a_stable_order() { + let text = + format!("{REDFISH}\n[\"aa-bb-cc-dd-ee-ff\"]\nkind = \"command\"\non = [\"x\"]\n"); + let ids: Vec = parse(&text) + .expect("parse") + .iter() + .map(|c| c.identity.clone()) + .collect(); + assert_eq!(ids, ["98fa9b50d810", "aabbccddeeff"]); + } + + /// Refused at use, where `envfile` only warns. Refusing an env file would stop a + /// server that is otherwise healthy; refusing this one costs a single interactive + /// command, and the file holds credentials that can power-cycle a rack. + #[cfg(unix)] + #[test] + fn a_file_others_can_read_is_refused_rather_than_warned_about() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("rescriptum-ctl-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("scratch"); + let path = dir.join("controllers.toml"); + std::fs::write(&path, REDFISH).expect("write"); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod"); + assert!(load(&path).is_ok(), "0600 must be accepted"); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).expect("chmod"); + let e = load(&path).expect_err("0640 must be refused"); + assert!(e.contains("0640"), "{e}"); + assert!(e.contains("chmod 600"), "{e}"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_missing_file_names_the_variable_that_asked_for_it() { + let e = load(Path::new("/nonexistent-root/controllers.toml")).expect_err("missing"); + assert!(e.contains("RESCRIPTUM_CONTROLLERS_FILE"), "{e}"); + } + + #[test] + fn a_file_that_will_not_parse_says_so_on_one_line() { + let e = err("this is not toml"); + assert!(!e.contains('\n'), "{e}"); + } +} diff --git a/src/edit.rs b/src/edit.rs new file mode 100644 index 0000000..3e27c39 --- /dev/null +++ b/src/edit.rs @@ -0,0 +1,307 @@ +//! Editing an answer document in the operator's own editor. +//! +//! Writing a TOML editor inside a terminal UI is months of work to arrive somewhere worse +//! than vim. Shelling out is also how the operator keeps their comments and their +//! formatting: the file store round-trips them, and the admin API returns documents as +//! written. +//! +//! Four things decide whether it works, and all four are here rather than in a screen: +//! +//! - **Through `StoreWrite` and the guard, never in place.** Editing the real path +//! directly bypasses the rollback entirely — which is the one thing standing between a +//! keystroke and a rack that cannot install. +//! - **An unchanged buffer is a no-op**, not a write. Quitting an editor must not touch an +//! mtime, bump a version, or run the guard. +//! - **The document's filename survives.** `format::canonical_stem` only names a document +//! nobody has named; an existing one is overwritten where it stands, so an operator's +//! own name is kept. That is the property they will notice if it breaks. +//! - **`$EDITOR` unset is named, not guessed.** Fall back to `vi` — busybox has one on +//! DSM — and say which one is being launched. + +use crate::guard::{self, Outcome, Target}; +use crate::select::Answers; +use crate::store::StoreWrite; +use std::path::{Path, PathBuf}; + +/// What an edit did. +#[derive(Debug)] +pub enum Edited { + /// The buffer came back identical. **Nothing was written**, so no mtime moved, no + /// version was bumped and the guard did not run. + Unchanged, + /// It was written, and the guard was happy. Carries whatever is still wrong with the + /// answer set — including what was already wrong. + Stored(Vec), + /// It would have broken the answer set, so it was put back. + Refused(Vec), + /// The editor could not be run, the temporary file could not be handled, or the store + /// refused it. + Failed(String), +} + +/// Which editor, and why that one. +/// +/// Named rather than guessed: an operator whose `$EDITOR` is unset should be told what is +/// about to open, not surprised by it. +pub fn editor(from_env: Option) -> (String, Option) { + match from_env + .map(|e| e.trim().to_string()) + .filter(|e| !e.is_empty()) + { + Some(e) => (e, None), + None => ( + "vi".to_string(), + Some("$EDITOR is not set, so vi is being used — busybox has one on DSM".to_string()), + ), + } +} + +/// Where the scratch copy goes. +/// +/// **Not in the answers directory.** Every servable file at the top of that directory is +/// an answer document, and a `.toml` dropped there would be reported as a misplaced one — +/// the same rule a configuration file has. The system temporary directory is where this +/// belongs, and the file is removed whatever happens. +/// +/// **The process id alone is not enough**, which is the same trap `store::file` had: two +/// edits of one document in one process would share the path, and one would silently take +/// the other's buffer. A counter closes it. +static SCRATCH_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +pub fn scratch_path(target: &Target, format: &str) -> PathBuf { + let id = match target { + Target::Machine(id) | Target::Group(id) => id.as_str(), + Target::Default => "default", + }; + let safe: String = id + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + std::env::temp_dir().join(format!( + "rescriptum-edit-{}-{}-{safe}.{format}", + std::process::id(), + SCRATCH_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )) +} + +/// Hand `before` to an editor, and store whatever comes back. +/// +/// `launch` is the thing that actually runs the editor, taking the scratch path and +/// returning whether it exited cleanly. It is a parameter so that every rule above can be +/// tested without a terminal — which is the whole reason this is a module rather than part +/// of a screen. +pub fn round_trip( + answers: &Answers, + store: &dyn StoreWrite, + target: &Target, + format: &str, + before: &str, + launch: impl FnOnce(&Path) -> Result<(), String>, +) -> Edited { + let path = scratch_path(target, format); + if let Err(e) = std::fs::write(&path, before) { + return Edited::Failed(format!("cannot write {}: {e}", path.display())); + } + + let launched = launch(&path); + let after = std::fs::read_to_string(&path); + // Removed whatever happened: this is somebody's answer document, and it holds a root + // password hash. + let _ = std::fs::remove_file(&path); + + if let Err(e) = launched { + return Edited::Failed(e); + } + let after = match after { + Ok(text) => text, + Err(e) => return Edited::Failed(format!("cannot read the edited file back: {e}")), + }; + + // Quitting an editor must not touch an mtime, bump a version, or run the guard. + if after == before { + return Edited::Unchanged; + } + + match guard::write(answers, store, target, format, Some(&after)) { + Outcome::Stored { problems } => Edited::Stored(problems), + Outcome::Refused { introduced } => Edited::Refused(introduced), + Outcome::Rejected(e) | Outcome::Unavailable(e) => Edited::Failed(e), + // A put never reports this; naming it beats a wildcard that would hide a change. + Outcome::NotFound | Outcome::Deleted { .. } => { + Edited::Failed("the store reported a delete for a write".to_string()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::FileStore; + use std::sync::Arc; + + fn scratch(name: &str) -> PathBuf { + static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "rescriptum-edit-t-{}-{name}-{n}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("scratch"); + dir + } + + fn subject(dir: &Path) -> (Answers, Arc) { + ( + Answers::new(Arc::new(FileStore::new(dir))), + Arc::new(FileStore::new(dir)), + ) + } + + #[test] + fn an_unset_editor_is_named_rather_than_guessed() { + let (which, note) = editor(None); + assert_eq!(which, "vi"); + assert!(note.expect("a note").contains("$EDITOR is not set")); + + let (which, note) = editor(Some(" nvim ".to_string())); + assert_eq!(which, "nvim"); + assert!(note.is_none()); + // An exported-but-empty variable counts as unset, the same rule the configuration + // has everywhere else. + assert_eq!(editor(Some(String::new())).0, "vi"); + } + + /// Every servable `.toml` at the top of the answers directory is an answer document, + /// so a scratch copy dropped there would be reported as a misplaced one. + #[test] + fn the_scratch_copy_is_not_in_the_answers_directory() { + let p = scratch_path(&Target::Machine("98:fa:9b:50:d8:10".to_string()), "toml"); + assert!(p.starts_with(std::env::temp_dir()), "{}", p.display()); + assert!(p.to_string_lossy().ends_with(".toml")); + // The identifier is not pasted into a path as written. + assert!(!p.to_string_lossy().contains(':')); + + // And two edits of one document never share a path: the process id alone would + // let one silently take the other's buffer. + let again = scratch_path(&Target::Machine("98:fa:9b:50:d8:10".to_string()), "toml"); + assert_ne!(p, again); + } + + #[test] + fn quitting_without_changing_anything_writes_nothing() { + let dir = scratch("noop"); + let (answers, store) = subject(&dir); + let target = Target::Machine("98fa9b50d810".to_string()); + guard::write(&answers, store.as_ref(), &target, "toml", Some("x = 1\n")); + + let path = dir.join("98fa9b50d810/proxmox.toml"); + let before_mtime = std::fs::metadata(&path) + .expect("meta") + .modified() + .expect("mtime"); + + let out = round_trip(&answers, store.as_ref(), &target, "toml", "x = 1\n", |_| { + Ok(()) + }); + assert!(matches!(out, Edited::Unchanged), "{out:?}"); + assert_eq!( + std::fs::metadata(&path) + .expect("meta") + .modified() + .expect("mtime"), + before_mtime, + "an unchanged buffer must not touch the document" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_edit_goes_through_the_guard_and_keeps_the_operators_filename() { + let dir = scratch("stored"); + let (answers, store) = subject(&dir); + let target = Target::Machine("98fa9b50d810".to_string()); + + // A document the operator named themselves, which must survive the write. + std::fs::create_dir_all(dir.join("98fa9b50d810")).expect("dir"); + std::fs::write(dir.join("98fa9b50d810/theirs.toml"), "x = 1\n").expect("write"); + + let out = round_trip(&answers, store.as_ref(), &target, "toml", "x = 1\n", |p| { + std::fs::write(p, "x = 2\n").map_err(|e| e.to_string()) + }); + assert!(matches!(out, Edited::Stored(_)), "{out:?}"); + + assert!( + dir.join("98fa9b50d810/theirs.toml").exists(), + "the operator's own filename must survive" + ); + assert!( + !dir.join("98fa9b50d810/proxmox.toml").exists(), + "and no second document should appear beside it" + ); + let body = std::fs::read_to_string(dir.join("98fa9b50d810/theirs.toml")).expect("read"); + assert!(body.contains("x = 2"), "{body}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The rollback is what stands between a keystroke and a rack that cannot install. + #[test] + fn an_edit_that_would_break_the_answer_set_is_refused_and_put_back() { + let dir = scratch("refused"); + let (answers, store) = subject(&dir); + let target = Target::Machine("98fa9b50d810".to_string()); + guard::write(&answers, store.as_ref(), &target, "toml", Some("x = 1\n")); + + let out = round_trip(&answers, store.as_ref(), &target, "toml", "x = 1\n", |p| { + std::fs::write(p, "extends = \"nowhere\"\n").map_err(|e| e.to_string()) + }); + match &out { + Edited::Refused(introduced) => { + assert!(!introduced.is_empty(), "it must say what broke") + } + other => panic!("expected a refusal, got {other:?}"), + } + + let body = std::fs::read_to_string(dir.join("98fa9b50d810/proxmox.toml")).expect("read"); + assert!( + body.contains("x = 1"), + "the rollback did not restore it: {body}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// An editor that died must not be treated as an empty document. + #[test] + fn an_editor_that_fails_writes_nothing() { + let dir = scratch("died"); + let (answers, store) = subject(&dir); + let target = Target::Machine("98fa9b50d810".to_string()); + guard::write(&answers, store.as_ref(), &target, "toml", Some("x = 1\n")); + + let out = round_trip(&answers, store.as_ref(), &target, "toml", "x = 1\n", |p| { + std::fs::write(p, "").expect("truncate"); + Err("vi was killed".to_string()) + }); + assert!(matches!(out, Edited::Failed(_)), "{out:?}"); + let body = std::fs::read_to_string(dir.join("98fa9b50d810/proxmox.toml")).expect("read"); + assert!(body.contains("x = 1"), "{body}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// It holds a root password hash: it must not be left in the temporary directory. + #[test] + fn the_scratch_copy_is_removed_whatever_happens() { + let dir = scratch("cleanup"); + let (answers, store) = subject(&dir); + let target = Target::Machine("98fa9b50d810".to_string()); + let mut seen = PathBuf::new(); + + let out = round_trip(&answers, store.as_ref(), &target, "toml", "x = 1\n", |p| { + seen = p.to_path_buf(); + Err("boom".to_string()) + }); + assert!(matches!(out, Edited::Failed(_))); + assert!(!seen.exists(), "{} was left behind", seen.display()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/envfile.rs b/src/envfile.rs index 8a9a0d8..08d8c7e 100644 --- a/src/envfile.rs +++ b/src/envfile.rs @@ -28,7 +28,7 @@ use std::path::{Path, PathBuf}; pub const ENV_FILE: &str = "RESCRIPTUM_ENV_FILE"; /// Every variable this program reads, so a typo can be reported rather than ignored. -pub const KNOWN_KEYS: [&str; 30] = [ +pub const KNOWN_KEYS: [&str; 31] = [ "RESCRIPTUM_STORE", "RESCRIPTUM_ANSWERS_DIR", "RESCRIPTUM_DB_PATH", @@ -40,6 +40,7 @@ pub const KNOWN_KEYS: [&str; 30] = [ "RESCRIPTUM_ADMIN_ADDR", "RESCRIPTUM_ADMIN_TOKEN", "RESCRIPTUM_CAPTURE_DIR", + "RESCRIPTUM_CONTROLLERS_FILE", "RESCRIPTUM_LOG", "RESCRIPTUM_LOG_FILE", "RESCRIPTUM_PUBLIC_HOST", diff --git a/src/facts.rs b/src/facts.rs index ab61225..90708e6 100644 --- a/src/facts.rs +++ b/src/facts.rs @@ -144,6 +144,30 @@ impl Facts { } /// Every label known, for diagnostics. + /// Whatever this request said about which machine it is, for a log line. + /// + /// **This exists because a 404 named no machine**, and that made the single most + /// valuable thing a dashboard could show — *these machines are asking and I have no + /// answer for them* — impossible to derive. For a GET the identity is in the query + /// string, which is already in the logged target; for a **Proxmox POST it is only in + /// the body**, and the body is not logged. + /// + /// It is logging, not instrumentation: no counter, no state, no memory, one `format!` + /// on a path that is already failing. + pub fn identity(&self) -> Option { + // In the order a person would want them, and only the labels that actually name a + // machine — never the whole flattened body, which would put a password hash in + // the log. + for key in ["mac", "macaddress", "serial", "uuid", "product", "fqdn"] { + if let Some(values) = self.labels.get(key) + && let Some(first) = values.iter().find(|v| !v.is_empty()) + { + return Some(format!("{key}={first}")); + } + } + None + } + pub fn labels(&self) -> impl Iterator)> { self.labels.iter() } diff --git a/src/guard.rs b/src/guard.rs new file mode 100644 index 0000000..8e392be --- /dev/null +++ b/src/guard.rs @@ -0,0 +1,308 @@ +//! A write that cannot leave the answer set broken. +//! +//! **This is the property the admin API is built on, and it is not an HTTP property.** A +//! cycle between groups, or a machine pointing at a group that no longer exists, does not +//! fail at write time — it fails when a rack tries to install. Catching it here is the +//! difference between a refusal now and a failed provisioning run at three in the morning. +//! +//! It lives outside `admin` because more than one thing needs it. The admin API maps each +//! outcome below onto a status code and a JSON envelope; a local editor maps them onto +//! what it shows an operator. Neither of them should own the rule. + +use crate::select::Answers; +use crate::store::StoreWrite; + +/// What is being written. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Target { + Machine(String), + Group(String), + /// The fallback, which has no identifier of its own — only a format. + Default, +} + +impl Target { + pub fn label(&self) -> &'static str { + match self { + Target::Machine(_) => "machine", + Target::Group(_) => "group", + Target::Default => "default", + } + } + + pub fn id(&self) -> &str { + match self { + Target::Machine(id) | Target::Group(id) => id, + Target::Default => "", + } + } +} + +/// What a guarded write did. +/// +/// Deliberately not a `Result`: "refused because it would break the answer set" is a +/// normal outcome with something to say, not an error, and flattening it into one would +/// lose the list of what broke. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outcome { + /// Written. `problems` is everything still wrong with the answer set — **including + /// what was already wrong**, so a caller is not misled into thinking all is well just + /// because their own write was clean. + Stored { + problems: Vec, + }, + Deleted { + problems: Vec, + }, + /// It would have broken something, so it was put back. `introduced` is what would + /// have broken — never the pre-existing problems, which are not this caller's doing. + Refused { + introduced: Vec, + }, + /// A delete of something that was not there. + NotFound, + /// The store refused the write itself: an identifier that would become a bad path, an + /// unknown format. The caller's fault, and fixable by them. + Rejected(String), + /// The store could not be read. Not the caller's fault. + Unavailable(String), +} + +/// Apply a write, then check that it did not break the answer set. +/// +/// **Both reads go back to the store.** The listing is cached behind the store's +/// `version`, and over the file store that version is the answers directory's mtime — +/// which does not move when a document is written *inside* an existing identity's +/// directory, and which a coarse-granularity filesystem may not move even for a new one +/// within the same second. Without forcing it, this would compare a write against itself, +/// find no difference, and keep something that breaks a rack. +/// +/// Which side matters is worth stating: a stale `after` is a rollback that never runs and +/// fails **open**, while a stale `before` blames this write for a pre-existing problem and +/// fails **closed**. Both are forced, because the safe direction is cheap to buy twice. +pub fn write( + answers: &Answers, + store: &dyn StoreWrite, + target: &Target, + format: &str, + body: Option<&str>, +) -> Outcome { + answers.invalidate(); + let before = match answers.problems() { + Ok(p) => p, + Err(e) => return Outcome::Unavailable(e.to_string()), + }; + + // What was there before, so it can be put back. + let previous = match store.snapshot() { + Ok(s) => match target { + Target::Machine(id) => s + .machines + .iter() + .find(|m| &m.id == id && m.format == format) + .map(|m| (m.format.clone(), m.body.clone())), + Target::Group(name) => s + .groups + .iter() + .find(|g| &g.name == name && g.format == format) + .map(|g| (g.format.clone(), g.body.clone())), + Target::Default => s + .fallbacks + .iter() + .find(|d| d.format == format) + .map(|d| (d.format.clone(), d.body.clone())), + }, + Err(e) => return Outcome::Unavailable(e.to_string()), + }; + + let applied = match (target, body) { + (Target::Machine(id), Some(b)) => store.put_machine(id, format, b).map(|()| true), + (Target::Group(name), Some(b)) => store.put_group(name, format, b).map(|()| true), + (Target::Default, Some(b)) => store.put_default(format, b).map(|()| true), + (Target::Machine(id), None) => store.delete_machine(id, format), + (Target::Group(name), None) => store.delete_group(name, format), + (Target::Default, None) => store.delete_default(format), + }; + let existed = match applied { + Ok(v) => v, + Err(e) => return Outcome::Rejected(e.to_string()), + }; + + if body.is_none() && !existed { + return Outcome::NotFound; + } + + answers.invalidate(); + let after = answers.problems().unwrap_or_default(); + let introduced: Vec = after + .iter() + .filter(|p| !before.contains(p)) + .cloned() + .collect(); + + if !introduced.is_empty() { + // Undo, so the store is never left in a state that breaks installs. + let restored = match (target, &previous) { + (Target::Machine(id), Some((f, b))) => store.put_machine(id, f, b), + (Target::Group(name), Some((f, b))) => store.put_group(name, f, b), + (Target::Default, Some((f, b))) => store.put_default(f, b), + (Target::Machine(id), None) => store.delete_machine(id, format).map(drop), + (Target::Group(name), None) => store.delete_group(name, format).map(drop), + (Target::Default, None) => store.delete_default(format).map(drop), + }; + if let Err(e) = restored { + // Loud, because the consequence is otherwise silent: the store is now in a + // state nobody asked for. + crate::log::server(&format!( + "could not roll back {} {:?}: {e} — the store may be inconsistent", + target.label(), + target.id() + )); + } + return Outcome::Refused { introduced }; + } + + if body.is_some() { + Outcome::Stored { problems: after } + } else { + Outcome::Deleted { problems: after } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::FileStore; + use std::sync::Arc; + + fn scratch(name: &str) -> std::path::PathBuf { + static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "rescriptum-guard-{}-{name}-{n}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("scratch"); + dir + } + + fn subject(dir: &std::path::Path) -> (Answers, Arc) { + let store = Arc::new(FileStore::new(dir)); + let answers = Answers::new(Arc::new(FileStore::new(dir))); + (answers, store) + } + + #[test] + fn a_clean_write_is_stored_and_reports_what_was_already_broken() { + let dir = scratch("clean"); + let (answers, store) = subject(&dir); + + let out = write( + &answers, + store.as_ref(), + &Target::Machine("98fa9b50d810".to_string()), + "toml", + Some("[global]\nkeyboard = \"fr\"\n"), + ); + assert_eq!(out, Outcome::Stored { problems: vec![] }); + let _ = std::fs::remove_dir_all(&dir); + } + + /// **The rollback, over the file store, inside the reload backstop** — which is the + /// case the cached listing used to hide. Without `Answers::invalidate` this write + /// compares equal to itself and is kept. + #[test] + fn a_write_that_would_break_the_answer_set_is_refused_and_rolled_back() { + let dir = scratch("refused"); + let (answers, store) = subject(&dir); + + // A clean machine first, so the guard has a `before` with no problems. + write( + &answers, + store.as_ref(), + &Target::Machine("98fa9b50d810".to_string()), + "toml", + Some("[global]\nkeyboard = \"fr\"\n"), + ); + + // Now break it, immediately — well inside the backstop. + let out = write( + &answers, + store.as_ref(), + &Target::Machine("98fa9b50d810".to_string()), + "toml", + Some("extends = \"nowhere\"\n"), + ); + match &out { + Outcome::Refused { introduced } => { + assert!(!introduced.is_empty(), "it must say what broke"); + assert!( + introduced.iter().any(|p| p.contains("nowhere")), + "{introduced:?}" + ); + } + other => panic!("expected a refusal, got {other:?}"), + } + + // And the previous document is back, byte for byte. + let body = std::fs::read_to_string(dir.join("98fa9b50d810/proxmox.toml")).expect("read"); + assert!( + body.contains("keyboard"), + "the rollback did not restore it: {body}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A write that *would have created* something is undone by deleting it, not by + /// restoring a document that never existed. + #[test] + fn a_broken_first_write_leaves_nothing_behind() { + let dir = scratch("first"); + let (answers, store) = subject(&dir); + + let out = write( + &answers, + store.as_ref(), + &Target::Machine("98fa9b50d810".to_string()), + "toml", + Some("extends = \"nowhere\"\n"), + ); + assert!(matches!(out, Outcome::Refused { .. }), "{out:?}"); + assert!( + !dir.join("98fa9b50d810/proxmox.toml").exists(), + "the rolled-back document is still there" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn deleting_something_that_is_not_there_is_not_found() { + let dir = scratch("missing"); + let (answers, store) = subject(&dir); + let out = write( + &answers, + store.as_ref(), + &Target::Machine("98fa9b50d810".to_string()), + "toml", + None, + ); + assert_eq!(out, Outcome::NotFound); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_identifier_the_store_refuses_is_the_callers_fault() { + let dir = scratch("bad-id"); + let (answers, store) = subject(&dir); + let out = write( + &answers, + store.as_ref(), + &Target::Machine("../escape".to_string()), + "toml", + Some("x = 1\n"), + ); + assert!(matches!(out, Outcome::Rejected(_)), "{out:?}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/installed.rs b/src/installed.rs index e9f4d84..3ed0b7c 100644 --- a/src/installed.rs +++ b/src/installed.rs @@ -168,6 +168,63 @@ pub fn disarm(answers: &Answers, store: &dyn StoreWrite, facts: &Facts) -> io::R Ok(Disarmed { moved }) } +/// Put a machine's `.ipxe` back, the exact reverse of `disarm`. +/// +/// This is what `install` uses to arm a machine that has installed before. It reuses the +/// archive rather than inventing an image argument, so **the operator's own document comes +/// back byte for byte** — and it keeps `installed-` directories from accumulating, which +/// matters because the per-request scan is linear in identities. +/// +/// **Put before delete**, the same way round as `disarm`: if the put fails the archive is +/// still there and the caller is told, which is the recoverable half. +/// +/// `Ok(None)` means there was nothing archived — not an error, just nothing to undo. +/// `Ok(Some(id))` gives the machine identifier the document was put back under. +pub fn rearm(store: &dyn StoreWrite, id: &str) -> io::Result> { + let Some(from) = archive_of(store, id)? else { + return Ok(None); + }; + let Some(body) = read_machine(store, &from, "ipxe")? else { + return Ok(None); + }; + // The machine's own spelling, which is whatever `disarm` prefixed. Rebuilding it from + // what the operator typed would write `installed-aa:bb:...` beside + // `installed-aa-bb-...` and leave both. + let to = from + .strip_prefix(DISARMED) + .expect("archive_of only returns prefixed names") + .to_string(); + store.put_machine(&to, "ipxe", &body)?; + store.delete_machine(&from, "ipxe")?; + Ok(Some(to)) +} + +/// Whether a machine has an archived `.ipxe` waiting to be put back. +pub fn archived(store: &dyn StoreWrite, id: &str) -> io::Result { + Ok(archive_of(store, id)?.is_some()) +} + +/// The archive directory for a machine, found **by normalized identity** rather than by +/// pasting a prefix onto whatever the operator typed. +/// +/// `disarm` names the archive after the identifier as the *store* spells it, so +/// `98-fa-9b-50-d8-10` archives to `installed-98-fa-9b-50-d8-10`. Somebody typing +/// `98:fa:9b:50:d8:10` has to reach the same directory, which is the rule every other +/// lookup in this program already follows. +fn archive_of(store: &dyn StoreWrite, id: &str) -> io::Result> { + let wanted = format!( + "{}{}", + normalize(DISARMED.as_bytes()), + normalize(id.as_bytes()) + ); + Ok(store + .snapshot()? + .machines + .into_iter() + .find(|m| m.format == "ipxe" && normalize(m.id.as_bytes()) == wanted) + .map(|m| m.id)) +} + fn read_machine(store: &dyn StoreWrite, id: &str, format: &str) -> io::Result> { Ok(store .snapshot()? diff --git a/src/lib.rs b/src/lib.rs index 87cd853..088b5c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,13 +10,27 @@ pub mod boot; pub mod capture; pub mod cli; pub mod config; +/// Out-of-band controllers: where a machine's BMC, PiKVM or PDU is described. +pub mod controllers; +/// Editing an answer document in the operator's own editor, through the guard. +pub mod edit; pub mod envfile; pub mod facts; pub mod format; +/// A write that cannot leave the answer set broken — the rule, without the HTTP. +pub mod guard; /// A machine reporting that it finished installing, and the claim being dropped. pub mod installed; pub mod log; pub mod merge; +/// Driving a controller: what `power on`, `off`, `pxe` and `status` actually do. +pub mod power; +/// Talking to a Redfish service, through `curl` — there is no TLS in this binary. +pub mod redfish; pub mod select; pub mod store; +/// Following the server's log from another process: rotation, bounded buffer, filters. +pub mod tail; pub mod tomlconfig; +/// What a terminal interface keeps, and when it may do work. No drawing, on purpose. +pub mod tui; diff --git a/src/main.rs b/src/main.rs index 58f7dea..20816be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,7 +92,9 @@ fn main() -> ExitCode { // **Fatal for a subcommand too, and that is deliberate** — `tests/tftp.rs` pins it. // Unlike the log file above, an invalid combination is a statement about the // configuration itself rather than about who is running the command, and `check` - // reporting success under one would be the wrong answer. + // reporting success under one would be the wrong answer. Whether the read-only + // commands should downgrade this to a warning, the way `config` is exempted from + // `from_env` entirely, is a real question and a separate one; it is not settled here. if let Err(problem) = cfg.validate() { log::server(&format!("configuration error: {problem}")); return ExitCode::FAILURE; @@ -102,9 +104,15 @@ fn main() -> ExitCode { None => {} Some((cmd, rest)) if cmd == "render" => return cli::render(&cfg, rest), Some((cmd, _)) if cmd == "check" => return cli::check(&cfg), + Some((cmd, rest)) if cmd == "status" || cmd == "machines" || cmd == "groups" => { + return cli::fleet_command(&cfg, cmd, rest); + } Some((cmd, rest)) if cmd == "import" => return cli::import(&cfg, rest), Some((cmd, rest)) if cmd == "export" => return cli::export(&cfg, rest), Some((cmd, rest)) if cmd == "migrate" => return cli::migrate(&cfg, rest), + Some((cmd, rest)) if cmd == "power" => return cli::power(&cfg, rest), + Some((cmd, rest)) if cmd == "install" => return cli::install(&cfg, rest), + Some((cmd, rest)) if cmd == "tui" => return cli::tui(&cfg, rest), Some((cmd, rest)) if cmd == "media" => return cli::media(&cfg, rest), Some((cmd, rest)) if cmd == "boot" => return cli::boot(&cfg, rest), Some((cmd, _)) => { @@ -729,7 +737,12 @@ async fn handle( // lookup below is blocking IO — both belong off the async worker. let picked = tokio::task::spawn_blocking(move || { let facts = Facts::from_request(Some(&request_path), query.as_deref(), &body); - answers.resolve(&facts) + // The identity comes back with the resolution so that a 404 can name the machine + // that asked. For a GET it is already in the target; for a Proxmox POST it is only + // in the body, and the body is not logged — which is what made "these machines are + // asking and I have no answer for them" underivable. + let said = facts.identity(); + answers.resolve(&facts).map(|r| (r, said)) }) .await; @@ -740,7 +753,7 @@ async fn handle( }; match picked { - Ok(Ok(Some(resolution))) => { + Ok(Ok((Some(resolution), _))) => { record(&format!("200 {}", resolution.how())); log::request( &peer, @@ -759,9 +772,16 @@ async fn handle( .body(Full::new(Bytes::from(resolution.body))) .unwrap_or_else(|_| text(StatusCode::INTERNAL_SERVER_ERROR, "500\n")) } - Ok(Ok(None)) => { + Ok(Ok((None, said))) => { record("404"); - log::request(&peer, 404, &format!("{prefix} 404 no answer file applies")); + log::request( + &peer, + 404, + &match said { + Some(who) => format!("{prefix} 404 no answer file applies for {who}"), + None => format!("{prefix} 404 no answer file applies"), + }, + ); text(StatusCode::NOT_FOUND, "404 Not Found\n") } // A misconfiguration (bad TOML, a missing group) must be loud: a half-built diff --git a/src/power.rs b/src/power.rs new file mode 100644 index 0000000..6818fca --- /dev/null +++ b/src/power.rs @@ -0,0 +1,423 @@ +//! Driving a controller: what `power on`, `power off`, `power pxe` and `power status` do, +//! for either kind of controller. +//! +//! Everything here is **synchronous and operator-triggered**. There is no reconciliation +//! loop, no agent deciding a machine "should" be reinstalled, and no retry: every action +//! is a person or a script, once. That is the line between this and a provisioning +//! platform, and it is the whole reason the feature is affordable. + +use crate::controllers::{CommandHook, Controller, Kind}; +use crate::redfish::{self, Client}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// What a machine is doing, as its controller reports it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum State { + On, + Off, + /// A controller that presses buttons rather than reading firmware cannot say. A PDU + /// knows whether it is feeding the outlet, not whether the machine booted. + Unknown, +} + +impl State { + pub fn label(&self) -> &'static str { + match self { + State::On => "on", + State::Off => "off", + State::Unknown => "unknown", + } + } +} + +/// One machine's state, plus whatever its controller could say about its next boot. +#[derive(Debug, Clone)] +pub struct Status { + pub state: State, + /// `Once`/`Pxe` when a one-time network boot is armed, as the *service* reports it + /// rather than as we last asked for. + pub boot_override: Option, +} + +/// Whether the one-time boot override actually took. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Armed { + /// The service confirms it on a read-back. + Confirmed, + /// The controller has no boot override at all, which is **not a failure**: the boot + /// order stays on PXE and `RESCRIPTUM_BOOT_UNCLAIMED` plus the `installed-` disarm + /// decide whether the machine installs. A PiKVM plus rescriptum is a complete + /// solution; a BMC with one-time boot is belt and braces. + NotSupported, + /// The request succeeded and the state says otherwise. PiKVM's PATCH answers + /// `204 No Content` and changes nothing, so this is a real outcome rather than a + /// defensive branch. + Ignored, +} + +fn timeout_of(controller: &Controller) -> Duration { + match &controller.kind { + Kind::Redfish(_) => redfish::DEFAULT_TIMEOUT, + Kind::Command(c) => c.timeout, + } +} + +pub fn status(controller: &Controller) -> Result { + match &controller.kind { + Kind::Command(_) => Ok(Status { + // Not "off". A command controller drives an outlet or presses a button; it + // has no way to know, and saying "off" would be an invention an operator + // would act on. + state: State::Unknown, + boot_override: None, + }), + Kind::Redfish(r) => { + let client = Client::new(r); + let id = client.system_id().map_err(|e| e.to_string())?; + let system = client.system(&id).map_err(|e| e.to_string())?; + let state = match redfish::power_state(&system.body).as_deref() { + Some("On") => State::On, + Some("Off") => State::Off, + _ => State::Unknown, + }; + let (enabled, target) = redfish::boot_override(&system.body); + let boot_override = match (enabled.as_deref(), target) { + (Some("Disabled") | None, _) => None, + (Some(e), Some(t)) => Some(format!("{e}/{t}")), + (Some(e), None) => Some(e.to_string()), + }; + Ok(Status { + state, + boot_override, + }) + } + } +} + +pub fn on(controller: &Controller) -> Result<(), String> { + match &controller.kind { + Kind::Redfish(r) => reset(r, "On"), + Kind::Command(c) => run(&c.on, c.timeout, "on"), + } +} + +/// `GracefulShutdown` unless `hard`, which is `ForceOff`. +/// +/// The graceful form is the default because pulling power from a machine mid-write is how +/// a filesystem gets repaired by hand later; `--hard` is for the machine that has stopped +/// answering, which is the whole reason out-of-band control exists. +pub fn off(controller: &Controller, hard: bool) -> Result<(), String> { + match &controller.kind { + Kind::Redfish(r) => { + if hard { + reset(r, "ForceOff") + } else { + // Where a service offers no graceful form, `reset` names what it does + // offer rather than reporting the 400 a wrong one earns. + reset(r, "GracefulShutdown") + } + } + Kind::Command(c) => run(&c.off, c.timeout, "off"), + } +} + +/// Restart, choosing the form the service actually offers. +/// +/// `install` needs this because **`ResetType: "On"` sent to a system that is already on** +/// is refused by many implementations and treated as a no-op by others — either way +/// nothing happens while it looks like something did. A machine being reinstalled is +/// usually running, so this is the common case rather than the edge one. +pub fn restart(controller: &Controller) -> Result<(), String> { + match &controller.kind { + Kind::Redfish(r) => { + let client = Client::new(r); + let id = client.system_id().map_err(|e| e.to_string())?; + let system = client.system(&id).map_err(|e| e.to_string())?; + let allowed = redfish::allowable_resets(&system.body); + // Graceful first, because a restart of a working machine should let it write + // its filesystem out. + let choice = ["GracefulRestart", "ForceRestart", "PowerCycle"] + .into_iter() + .find(|c| allowed.iter().any(|a| a == c)) + .ok_or_else(|| { + format!( + "this system offers no restart — it accepts {}", + allowed.join(", ") + ) + })?; + client.reset(&id, choice).map_err(|e| e.to_string()) + } + // A PDU has one way to restart something and it is not gentle. Off then on is + // left to the operator rather than invented here, because the delay between them + // is a property of the hardware. + Kind::Command(_) => Err( + "a command controller has no restart — power it off and on, with whatever \ + delay that hardware needs" + .to_string(), + ), + } +} + +/// Arm a one-time network boot, and say whether it actually took. +pub fn pxe(controller: &Controller) -> Result { + match &controller.kind { + Kind::Redfish(r) => { + let client = Client::new(r); + let id = client.system_id().map_err(|e| e.to_string())?; + match client.set_pxe_once(&id) { + Ok(true) => Ok(Armed::Confirmed), + Ok(false) => Ok(Armed::Ignored), + Err(e) => Err(e.to_string()), + } + } + Kind::Command(c) if c.pxe.is_empty() => Ok(Armed::NotSupported), + Kind::Command(c) => run(&c.pxe, c.timeout, "pxe").map(|()| Armed::Confirmed), + } +} + +fn reset(r: &crate::controllers::Redfish, kind: &str) -> Result<(), String> { + let client = Client::new(r); + let id = client.system_id().map_err(|e| e.to_string())?; + client.reset(&id, kind).map_err(|e| e.to_string()) +} + +/// Run one hook, with a deadline. +/// +/// `std::process::Command` has no timeout of its own, so a hung `pdu` script would hang +/// `install` forever. Three rules, all of them load-bearing: +/// +/// - **argv only, never a shell.** The vector comes from the controllers file as written +/// and is handed to `Command` unchanged: no `sh -c`, no splitting, no interpolation. +/// - **Nothing from a request reaches it.** Templating exists in this program and must not +/// arrive here; values in that file come from that file. +/// - **A deadline, and a killed child is an *unknown* outcome**, not a failure. A PDU +/// script that was killed halfway may well have switched the outlet. +/// +/// stderr is inherited rather than captured, so the operator sees the script's own +/// complaint as it happens — and so that a chatty script cannot deadlock by filling a pipe +/// nobody is draining. +fn run(argv: &[String], timeout: Duration, what: &str) -> Result<(), String> { + let Some((program, args)) = argv.split_first() else { + return Err(format!("this controller has no `{what}` command")); + }; + + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|e| format!("cannot run {program}: {e}"))?; + + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return Ok(()), + Ok(Some(status)) => { + return Err(format!( + "{program} exited {} — see its own output above", + status.code().unwrap_or(-1) + )); + } + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "{program} did not finish within {}s and was killed — \ + **the outcome is unknown**; check the hardware rather than \ + running it again", + timeout.as_secs() + )); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => return Err(format!("{program}: {e}")), + } + } +} + +/// How many controllers are asked at once by `power list --state`. +/// +/// Bounded on purpose: two hundred controllers with a handful unreachable would otherwise +/// open two hundred connections and wait out every deadline at once. Small enough to be +/// kind to a NAS, large enough that a rack does not take minutes. +pub const PROBE_CONCURRENCY: usize = 8; + +/// Ask several controllers at once, bounded, and give back what each said. +/// +/// **Never on a redraw.** One unreachable BMC must not be able to freeze a screen, which +/// is why this is `--state` and not what a plain listing does. +pub fn probe(controllers: &[&Controller]) -> Vec> { + let mut out: Vec> = Vec::with_capacity(controllers.len()); + for chunk in controllers.chunks(PROBE_CONCURRENCY) { + let mut results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = chunk + .iter() + .map(|c| scope.spawn(move || status(c))) + .collect(); + handles + .into_iter() + .map(|h| { + h.join().unwrap_or_else(|_| { + Err("the probe panicked, which is a bug — treat the state as \ + unknown" + .to_string()) + }) + }) + .collect() + }); + out.append(&mut results); + } + out +} + +/// The deadline one controller would take, for saying how long a listing might. +pub fn worst_case(controllers: &[&Controller]) -> Duration { + controllers + .iter() + .map(|c| timeout_of(c)) + .max() + .unwrap_or_default() +} + +/// A hook's deadline, for a message. +pub fn hook_timeout(hook: &CommandHook) -> Duration { + hook.timeout +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::controllers; + + /// Resolve a standard tool rather than hard-coding a path. + /// + /// `/bin/true` exists on Linux and **not on macOS**, where it is `/usr/bin/true`; + /// `/bin/sleep` is the other way round on some distributions. A hard-coded path here + /// is the "passed locally, failed in CI for a reason unrelated to the change" trap + /// this repository has already been caught by once. + fn tool(name: &str) -> String { + ["/usr/bin", "/bin"] + .iter() + .map(|dir| format!("{dir}/{name}")) + .find(|p| std::path::Path::new(p).is_file()) + .unwrap_or_else(|| panic!("no {name} on this system")) + } + + fn command_controller(body: &str) -> Controller { + controllers::parse(body) + .expect("parse") + .iter() + .next() + .expect("one") + .clone() + } + + #[test] + fn a_command_controller_never_claims_to_know_the_power_state() { + // A PDU knows whether it is feeding an outlet, not whether the machine booted. + // Saying "off" would be an invention an operator would act on. + let c = command_controller(&format!( + "[\"aa\"]\nkind = \"command\"\non = [\"{}\"]\n", + tool("true") + )); + assert_eq!(status(&c).expect("status").state, State::Unknown); + } + + #[test] + fn a_hook_that_succeeds_is_a_success() { + let c = command_controller(&format!( + "[\"aa\"]\nkind = \"command\"\non = [\"{}\"]\n", + tool("true") + )); + assert!(on(&c).is_ok()); + } + + #[test] + fn a_hook_that_fails_reports_its_exit_code() { + let c = command_controller(&format!( + "[\"aa\"]\nkind = \"command\"\non = [\"{}\"]\n", + tool("false") + )); + let e = on(&c).expect_err("must fail"); + assert!(e.contains("exited 1"), "{e}"); + } + + #[test] + fn a_hook_that_does_not_exist_says_so_rather_than_panicking() { + let c = command_controller("[\"aa\"]\nkind = \"command\"\non = [\"/nonexistent/pdu\"]\n"); + let e = on(&c).expect_err("must fail"); + assert!(e.contains("cannot run"), "{e}"); + } + + /// `std::process::Command` has no deadline of its own, so this is the whole reason + /// `timeout` exists in the controllers file. + #[test] + fn a_hanging_hook_is_killed_and_reported_as_an_unknown_outcome() { + let c = command_controller(&format!( + "[\"aa\"]\nkind = \"command\"\non = [\"{}\", \"30\"]\ntimeout = 1\n", + tool("sleep") + )); + let started = Instant::now(); + let e = on(&c).expect_err("must time out"); + assert!( + started.elapsed() < Duration::from_secs(5), + "it waited {:?}, so the deadline did not fire", + started.elapsed() + ); + assert!(e.contains("outcome is unknown"), "{e}"); + // And it must not suggest running it again: the outlet may already have switched. + assert!(e.contains("rather than running it again"), "{e}"); + } + + #[test] + fn a_controller_with_no_pxe_command_is_not_a_failure() { + // Where one-time boot does not exist the boot order stays on PXE and the server + // decides whether to install — which is a complete solution, not a gap. + let c = command_controller(&format!( + "[\"aa\"]\nkind = \"command\"\non = [\"{}\"]\n", + tool("true") + )); + assert_eq!(pxe(&c).expect("pxe"), Armed::NotSupported); + } + + #[test] + fn a_command_controller_has_no_restart_and_says_why() { + let c = command_controller(&format!( + "[\"aa\"]\nkind = \"command\"\non = [\"{}\"]\n", + tool("true") + )); + let e = restart(&c).expect_err("no restart"); + assert!(e.contains("off and on"), "{e}"); + } + + #[test] + fn asking_for_a_command_the_controller_does_not_have_is_named() { + let c = command_controller(&format!( + "[\"aa\"]\nkind = \"command\"\non = [\"{}\"]\n", + tool("true") + )); + let e = off(&c, false).expect_err("no off command"); + assert!(e.contains("no `off` command"), "{e}"); + } + + #[test] + fn probing_several_controllers_bounded_gives_one_answer_each() { + let text = (0..20) + .map(|n| { + format!( + "[\"aa-bb-cc-dd-ee-{n:02}\"]\nkind = \"command\"\non = [\"{}\"]\n", + tool("true") + ) + }) + .collect::>() + .join("\n"); + let parsed = controllers::parse(&text).expect("parse"); + let all: Vec<&Controller> = parsed.iter().collect(); + let results = probe(&all); + assert_eq!(results.len(), 20); + assert!(results.iter().all(Result::is_ok)); + } +} diff --git a/src/redfish.rs b/src/redfish.rs new file mode 100644 index 0000000..069e152 --- /dev/null +++ b/src/redfish.rs @@ -0,0 +1,697 @@ +//! Talking to a Redfish service, through `curl`. +//! +//! **Why curl and not a TLS crate.** Redfish is HTTPS in practice and BMCs ship +//! self-signed certificates. There is no TLS in this binary on purpose — `rustls` plus +//! `webpki` is about a megabyte on armv7, which would roughly double a 2.8 MB binary for +//! one feature. `boot::fetch` already shells out for exactly this reason. +//! +//! **This is curl-only, which is a narrower claim than `fetch` makes.** `fetch` falls back +//! to `wget`; nothing here can. A Redfish call is a POST or a PATCH with a JSON body, +//! custom headers, and a credential that must stay out of the process table — wget does +//! not do that combination. So the error says *curl*, names why, and stops. +//! +//! **The credential never reaches `argv`.** A password in an argument vector is visible to +//! every user on the box through `/proc//cmdline`. `curl --config -` reads its +//! options from stdin instead, so `ps` shows only `curl --config -`. +//! +//! Four things about driving curl this way that decide whether it works: +//! +//! - **`--config -` occupies stdin**, so the request body cannot also arrive there. It +//! goes *inside* the config file, which means it meets the same quoting rules the +//! password does — hence one escaper used for every value rather than two that can +//! disagree. +//! - **`--fail` must not be used.** It suppresses the response body on an error status, +//! and that body is `error.@Message.ExtendedInfo[].Message`: a sentence the vendor wrote +//! about what went wrong. Discarding it is how somebody ends up reading a packet +//! capture. +//! - **`--write-out` goes to stdout, where the body already is.** Writing the status there +//! naively welds three digits onto the end of a JSON document. A newline plus the code, +//! split from the right, keeps both. +//! - **`Content-Type: application/json` has to be set by hand**, because `--data` sends a +//! form content type and a Redfish service answers that with 415. + +use crate::controllers::{Redfish, Tls}; +use serde_json::Value; +use std::io::Write; +use std::process::{Command, Stdio}; +use std::time::Duration; + +/// A BMC that accepts a connection and never answers is ordinary. Without a deadline +/// `install` would hang with no output. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(20); + +/// What one exchange produced. The status is kept separate from the body so that an error +/// body survives to be read — see the note about `--fail` above. +#[derive(Debug, Clone)] +pub struct Reply { + pub status: u16, + pub body: String, + /// `@odata.etag`, when the resource carried one. Some iLO and iDRAC builds answer a + /// `PATCH` without `If-Match` with 412. + pub etag: Option, +} + +impl Reply { + pub fn ok(&self) -> bool { + (200..300).contains(&self.status) + } + + /// The sentence the vendor wrote, when there is one. + /// + /// Surfacing "HTTP 400" and throwing this away is the difference between an operator + /// fixing their request and an operator reading a packet capture. + pub fn message(&self) -> Option { + let v: Value = serde_json::from_str(&self.body).ok()?; + let info = v.get("error")?.get("@Message.ExtendedInfo")?.as_array()?; + let messages: Vec = info + .iter() + .filter_map(|m| m.get("Message")?.as_str().map(str::to_string)) + .collect(); + if messages.is_empty() { + // Some services put a sentence directly on the error object instead. + return v.get("error")?.get("message")?.as_str().map(str::to_string); + } + Some(messages.join("; ")) + } + + /// ``, for a message a person reads. + pub fn describe(&self) -> String { + match self.message() { + Some(m) => format!("HTTP {} — {m}", self.status), + None => format!("HTTP {}", self.status), + } + } +} + +/// What a `PATCH` or a `POST` did, when the answer is that nobody knows. +/// +/// A deadline says when to stop waiting; it says nothing about what happened. A +/// `ComputerSystem.Reset` that timed out may have powered the rack on, and a `Boot` PATCH +/// that timed out may or may not have taken. **So a write is never retried automatically** +/// — the caller is told the outcome is unknown and given the means to read it back. +#[derive(Debug)] +pub enum Failed { + /// curl is not installed. There is no `wget` fallback here, unlike `boot::fetch`. + NoCurl, + /// The request did not complete. Whether it took effect is unknown. + Unknown(String), + /// It completed and the service refused it. + Refused(Reply), +} + +impl std::fmt::Display for Failed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Failed::NoCurl => write!( + f, + "curl is not installed, and there is no TLS in this binary — \ + a Redfish call needs a POST with a JSON body, custom headers and a \ + credential kept out of the process table, which wget cannot do" + ), + Failed::Unknown(why) => write!(f, "{why}"), + Failed::Refused(r) => write!(f, "{}", r.describe()), + } + } +} + +/// Escape one value for curl's configuration-file syntax. +/// +/// A value in double quotes understands `\\`, `\"`, `\t`, `\n`, `\r` and `\v`. A password +/// containing a backslash or a quote, written through unescaped, authenticates as +/// something else — and a BMC's answer to that is a 401, which reads as a wrong password +/// rather than as a bug. +/// +/// **One escaper, used for every value**, the JSON body included. Two of these is how one +/// of them stays wrong. +pub fn quote(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\t' => out.push_str("\\t"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\u{b}' => out.push_str("\\v"), + other => out.push(other), + } + } + out.push('"'); + out +} + +/// The client for one controller. +pub struct Client<'a> { + controller: &'a Redfish, + timeout: Duration, +} + +/// What `ps` would show. Held apart from the config text so a test can assert the +/// credential is in one and not the other. +pub fn argv() -> [&'static str; 3] { + ["curl", "--config", "-"] +} + +impl<'a> Client<'a> { + pub fn new(controller: &'a Redfish) -> Client<'a> { + Client { + controller, + timeout: DEFAULT_TIMEOUT, + } + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// The options curl is handed on stdin. Public so that it can be tested without + /// running anything, which is also how the credential's absence from `argv` is pinned. + pub fn config( + &self, + method: &str, + path: &str, + body: Option<&str>, + etag: Option<&str>, + ) -> String { + let mut out = String::new(); + let url = format!("{}{path}", self.controller.url); + out.push_str(&format!("url = {}\n", quote(&url))); + out.push_str(&format!("request = {}\n", quote(method))); + out.push_str(&format!( + "user = {}\n", + quote(&format!( + "{}:{}", + self.controller.user, self.controller.pass + )) + )); + // PiKVM authenticates with KVMD's own headers as well as Basic; sending both costs + // nothing and means one code path reaches both populations. + out.push_str(&format!( + "header = {}\n", + quote(&format!("X-KVMD-User: {}", self.controller.user)) + )); + out.push_str(&format!( + "header = {}\n", + quote(&format!("X-KVMD-Passwd: {}", self.controller.pass)) + )); + out.push_str("header = \"Accept: application/json\"\n"); + + if let Some(tag) = etag { + out.push_str(&format!( + "header = {}\n", + quote(&format!("If-Match: {tag}")) + )); + } + if let Some(body) = body { + // Set by hand: `--data` alone sends a form content type, and a Redfish service + // answers that with 415. This is the single most common "works in the vendor's + // example, fails from curl" failure. + out.push_str("header = \"Content-Type: application/json\"\n"); + out.push_str(&format!("data = {}\n", quote(body))); + } + + match &self.controller.tls { + Tls::Insecure => out.push_str("insecure\n"), + Tls::CaCert(p) => { + out.push_str(&format!("cacert = {}\n", quote(&p.display().to_string()))); + } + Tls::PinnedPubKey(k) => out.push_str(&format!("pinnedpubkey = {}\n", quote(k))), + } + + out.push_str(&format!("max-time = {}\n", self.timeout.as_secs())); + out.push_str("silent\n"); + out.push_str("show-error\n"); + // Deliberately no `fail`: it would discard the vendor's error body, which is the + // most useful thing a failed call produces. The status comes from `write-out` + // instead, after a newline so the body can be split back off from the right. + out.push_str("dump-header = \"-\"\n"); + out.push_str("write-out = \"\\n%{http_code}\"\n"); + out + } + + fn send( + &self, + method: &str, + path: &str, + body: Option<&str>, + etag: Option<&str>, + ) -> Result { + let config = self.config(method, path, body, etag); + + let mut child = match Command::new("curl") + .args(&argv()[1..]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(Failed::NoCurl), + Err(e) => return Err(Failed::Unknown(format!("cannot run curl: {e}"))), + }; + + if let Some(mut stdin) = child.stdin.take() + && let Err(e) = stdin.write_all(config.as_bytes()) + { + return Err(Failed::Unknown(format!("cannot write curl's options: {e}"))); + } + + let out = child + .wait_with_output() + .map_err(|e| Failed::Unknown(format!("curl did not finish: {e}")))?; + + if !out.status.success() { + let code = out.status.code().unwrap_or(-1); + let said = String::from_utf8_lossy(&out.stderr).trim().to_string(); + // 28 is curl's timeout, and it is the one whose meaning matters: the request + // may well have taken effect. Say so rather than implying nothing happened. + let why = match code { + 28 => format!( + "{} did not answer within {}s — **the outcome is unknown**; \ + read the state back rather than sending it again", + self.controller.url, + self.timeout.as_secs() + ), + 6 => format!("cannot resolve {}: {said}", self.controller.url), + 7 => format!("cannot connect to {}: {said}", self.controller.url), + 60 => format!( + "{} presented a certificate this configuration does not trust: {said}", + self.controller.url + ), + _ => format!("curl exited {code}: {said}"), + }; + return Err(Failed::Unknown(why)); + } + + let text = String::from_utf8_lossy(&out.stdout).into_owned(); + let (head_and_body, status) = text + .rsplit_once('\n') + .ok_or_else(|| Failed::Unknown("curl produced no status".to_string()))?; + let status: u16 = status + .trim() + .parse() + .map_err(|_| Failed::Unknown(format!("curl produced no status: {status:?}")))?; + + // `dump-header -` puts the headers on stdout ahead of the body, which is how the + // etag is read without a second request. + let (headers, body) = split_headers(head_and_body); + Ok(Reply { + status, + body: body.to_string(), + etag: header(headers, "etag"), + }) + } + + /// `GET`, which is the only verb here that may be retried freely. + pub fn get(&self, path: &str) -> Result { + self.send("GET", path, None, None) + } + + /// The system this controller drives. + /// + /// **Never follow a URL out of the response body.** `@odata.id` is service-root + /// relative by the specification, and PiKVM breaks that: the handbook serves Redfish + /// at `/api/redfish/v1` while kvmd emits `"@odata.id": "/redfish/v1/Systems/0"`, so + /// the value is inconsistent with the path it came from. Taking the **last segment** + /// and composing `/Systems/` is the only form that works on both. + /// + /// **`Members[0]` is a guess.** A blade enclosure, a Dell FX2, and a PiKVM with a + /// switch all expose several systems — and on a PiKVM with ATX disabled, the first + /// member is a switch port, a different machine entirely. With more than one, refuse + /// and name them; the entry can say `system = "…"`. + pub fn system_id(&self) -> Result { + if let Some(explicit) = &self.controller.system { + return Ok(explicit.clone()); + } + + let path = format!("{}/Systems", self.controller.base); + let reply = self.get(&path)?; + if !reply.ok() { + return Err(Failed::Refused(reply)); + } + + let members = members(&reply.body).map_err(|e| Failed::Unknown(format!("{path}: {e}")))?; + match members.len() { + 0 => Err(Failed::Unknown(format!( + "{} has no systems to drive", + self.controller.url + ))), + 1 => Ok(members[0].clone()), + _ => Err(Failed::Unknown(format!( + "{} exposes {} systems ({}) — say which with `system = \"…\"` in the \ + controllers file. Picking the first would power somebody else's machine", + self.controller.url, + members.len(), + members.join(", ") + ))), + } + } + + /// Ask this system to change power state. + /// + /// The reset is checked against `ResetType@Redfish.AllowableValues` first: sending + /// `GracefulShutdown` to a service that only offers `ForceOff` earns a 400, and + /// naming the ones that *would* work beats reporting the number. + /// + /// **Never retried.** A `Reset` that timed out may have powered the rack on. + pub fn reset(&self, id: &str, reset: &str) -> Result<(), Failed> { + let system = self.system(id)?; + let allowed = allowable_resets(&system.body); + if !allowed.is_empty() && !allowed.iter().any(|a| a == reset) { + return Err(Failed::Unknown(format!( + "this system does not accept {reset:?} — it offers {}", + allowed.join(", ") + ))); + } + + let body = format!("{{\"ResetType\":\"{reset}\"}}"); + let path = format!( + "{}/Systems/{id}/Actions/ComputerSystem.Reset", + self.controller.base + ); + let reply = self.send("POST", &path, Some(&body), None)?; + if reply.ok() { + Ok(()) + } else { + Err(Failed::Refused(reply)) + } + } + + /// Arm a **one-time** network boot, and read it back. + /// + /// `Once`, never `Continuous`, and that is a safety property rather than tidiness: an + /// override consumed at the next boot means a machine that fails to install and + /// reboots comes up on its own disk instead of installing again — the same protection + /// `RESCRIPTUM_BOOT_UNCLAIMED` gives from the other end. + /// + /// **`BootSourceOverrideMode` is deliberately not set.** It selects UEFI or Legacy; + /// setting it wrong makes a UEFI machine PXE-boot in legacy mode and fail in a way + /// that looks like the TFTP server, and on several iDRAC generations changing it needs + /// a reboot before it takes effect. Leave the BMC's own setting alone. + /// + /// **The read-back is not optional.** PiKVM's handler returns `204 No Content` and + /// does nothing at all — verified in kvmd's source — while reporting + /// `BootSourceOverrideEnabled: "Disabled"`. A client that trusts the status code + /// believes it armed a boot that will not happen, and the machine then installs + /// nothing while looking correct. + pub fn set_pxe_once(&self, id: &str) -> Result { + let before = self.system(id)?; + let path = format!("{}/Systems/{id}", self.controller.base); + let body = + r#"{"Boot":{"BootSourceOverrideTarget":"Pxe","BootSourceOverrideEnabled":"Once"}}"#; + + let reply = self.send("PATCH", &path, Some(body), before.etag.as_deref())?; + if !reply.ok() { + return Err(Failed::Refused(reply)); + } + + let after = self.system(id)?; + let (enabled, target) = boot_override(&after.body); + Ok(enabled.as_deref() == Some("Once") && target.as_deref() == Some("Pxe")) + } + + /// The system resource: its power state, what resets it accepts, and its etag. + pub fn system(&self, id: &str) -> Result { + let reply = self.get(&format!("{}/Systems/{id}", self.controller.base))?; + if reply.ok() { + Ok(reply) + } else { + Err(Failed::Refused(reply)) + } + } +} + +/// Member ids from a Redfish collection — the last segment of each `@odata.id`. +pub fn members(body: &str) -> Result, String> { + let v: Value = serde_json::from_str(body).map_err(|e| format!("not JSON: {e}"))?; + let array = v + .get("Members") + .and_then(Value::as_array) + .ok_or_else(|| "no `Members` array".to_string())?; + Ok(array + .iter() + .filter_map(|m| m.get("@odata.id")?.as_str()) + .filter_map(|id| id.trim_end_matches('/').rsplit('/').next()) + .filter(|id| !id.is_empty()) + .map(str::to_string) + .collect()) +} + +/// What resets this system says it accepts. +/// +/// Sending `GracefulShutdown` to a BMC that only offers `ForceOff` earns a 400. Reading +/// the list first means naming the ones that would work instead. +pub fn allowable_resets(system_body: &str) -> Vec { + let Ok(v) = serde_json::from_str::(system_body) else { + return Vec::new(); + }; + v.get("Actions") + .and_then(|a| a.get("#ComputerSystem.Reset")) + .and_then(|r| r.get("ResetType@Redfish.AllowableValues")) + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|s| s.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +/// `On`, `Off`, or whatever else the service calls it. +pub fn power_state(system_body: &str) -> Option { + serde_json::from_str::(system_body) + .ok()? + .get("PowerState")? + .as_str() + .map(str::to_string) +} + +/// Whether a one-time network boot is currently armed, as the service reports it. +/// +/// Read back rather than assumed, because a PATCH can succeed and do nothing: PiKVM's +/// handler returns **204 No Content** and ignores the body, so a client that trusts the +/// status believes it armed a boot that will not happen. +pub fn boot_override(system_body: &str) -> (Option, Option) { + let Ok(v) = serde_json::from_str::(system_body) else { + return (None, None); + }; + let Some(boot) = v.get("Boot") else { + return (None, None); + }; + let s = |k: &str| boot.get(k).and_then(Value::as_str).map(str::to_string); + ( + s("BootSourceOverrideEnabled"), + s("BootSourceOverrideTarget"), + ) +} + +fn split_headers(text: &str) -> (&str, &str) { + // curl writes each response's headers followed by a blank line. A proxy or a 100- + // continue can produce more than one block, so take the last separator rather than + // the first. + match text.rfind("\r\n\r\n") { + Some(i) => (&text[..i], &text[i + 4..]), + None => match text.rfind("\n\n") { + Some(i) => (&text[..i], &text[i + 2..]), + None => ("", text), + }, + } +} + +fn header(headers: &str, name: &str) -> Option { + headers + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(k, _)| k.trim().eq_ignore_ascii_case(name)) + .map(|(_, v)| v.trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::controllers::DEFAULT_BASE; + use std::path::PathBuf; + + fn controller(tls: Tls) -> Redfish { + Redfish { + url: "https://10.0.0.51".to_string(), + base: DEFAULT_BASE.to_string(), + user: "root".to_string(), + pass: "calvin".to_string(), + system: None, + tls, + } + } + + #[test] + fn a_password_with_a_quote_and_a_backslash_is_escaped_for_curls_own_syntax() { + // Written through unescaped it authenticates as something else, and the BMC's + // answer to that is a 401 — which reads as a wrong password rather than a bug. + assert_eq!(quote(r#"a"b\c"#), r#""a\"b\\c""#); + assert_eq!(quote("tab\there"), r#""tab\there""#); + assert_eq!(quote("line\nbreak"), r#""line\nbreak""#); + } + + /// The whole reason for `--config -`: `ps` shows the argument vector, and a password + /// in one is readable by every user on the box through `/proc//cmdline`. + #[test] + fn the_credential_is_in_the_configuration_and_never_in_the_argument_vector() { + let c = controller(Tls::Insecure); + let client = Client::new(&c); + let joined = argv().join(" "); + assert_eq!(joined, "curl --config -"); + assert!(!joined.contains("calvin")); + + let config = client.config("GET", "/redfish/v1/Systems", None, None); + assert!(config.contains("calvin"), "{config}"); + } + + /// `--data` alone sends a form content type, and Redfish answers that with 415. + #[test] + fn a_body_carries_an_explicit_json_content_type() { + let c = controller(Tls::Insecure); + let config = Client::new(&c).config("POST", "/x", Some(r#"{"ResetType":"On"}"#), None); + assert!( + config.contains("Content-Type: application/json"), + "{config}" + ); + // And the body is quoted by the same escaper the password is, because it lands in + // the same file — `--config -` has already taken stdin. + assert!( + config.contains(r#"data = "{\"ResetType\":\"On\"}""#), + "{config}" + ); + } + + /// It would discard `error.@Message.ExtendedInfo`, which is the most useful thing a + /// failed call produces. + #[test] + fn the_configuration_never_asks_curl_to_fail_quietly() { + let c = controller(Tls::Insecure); + let config = Client::new(&c).config("GET", "/x", None, None); + assert!(!config.lines().any(|l| l.trim() == "fail"), "{config}"); + // And a deadline is not optional: a BMC that accepts a connection and never + // answers is ordinary. + assert!(config.contains("max-time = "), "{config}"); + } + + #[test] + fn each_way_of_trusting_a_certificate_reaches_curl() { + let insecure = controller(Tls::Insecure); + assert!( + Client::new(&insecure) + .config("GET", "/x", None, None) + .contains("insecure") + ); + + let ca = controller(Tls::CaCert(PathBuf::from("/etc/bmc-ca.pem"))); + assert!( + Client::new(&ca) + .config("GET", "/x", None, None) + .contains(r#"cacert = "/etc/bmc-ca.pem""#) + ); + + let pinned = controller(Tls::PinnedPubKey("sha256//abc".to_string())); + assert!( + Client::new(&pinned) + .config("GET", "/x", None, None) + .contains(r#"pinnedpubkey = "sha256//abc""#) + ); + } + + #[test] + fn an_etag_becomes_an_if_match_header() { + // iLO and several iDRAC builds answer a PATCH without one with 412, and the + // message says "precondition", which sends people looking at the payload. + let c = controller(Tls::Insecure); + let with = Client::new(&c).config("PATCH", "/x", Some("{}"), Some("W/\"abc\"")); + assert!(with.contains(r#"If-Match: W/\"abc\""#), "{with}"); + + // And where the resource carried none, no header: `If-Match: *` is not universally + // accepted. + let without = Client::new(&c).config("PATCH", "/x", Some("{}"), None); + assert!(!without.contains("If-Match"), "{without}"); + } + + /// PiKVM's own shape, taken from kvmd's source: the body says `/redfish/v1/...` even + /// when the service is mounted at `/api/redfish/v1`, so only the last segment is safe + /// to use. + #[test] + fn member_ids_are_the_last_segment_and_never_a_path() { + let body = r#"{"Members":[ + {"@odata.id":"/redfish/v1/Systems/System.Embedded.1"}, + {"@odata.id":"/redfish/v1/Systems/0"}, + {"@odata.id":"/redfish/v1/Systems/SwitchPort0/"} + ]}"#; + assert_eq!( + members(body).expect("members"), + ["System.Embedded.1", "0", "SwitchPort0"] + ); + } + + #[test] + fn a_collection_that_is_not_json_says_so_rather_than_panicking() { + assert!(members("nope").is_err()); + assert!(members("{}").is_err()); + } + + #[test] + fn the_reset_list_and_the_power_state_are_read_from_the_system() { + // Exactly what kvmd emits, so the client is written against a real shape. + let body = r##"{ + "PowerState": "On", + "Actions": {"#ComputerSystem.Reset": { + "ResetType@Redfish.AllowableValues": + ["On","ForceOff","GracefulShutdown","ForceRestart","ForceOn","PushPowerButton"] + }}, + "Boot": {"BootSourceOverrideEnabled":"Disabled","BootSourceOverrideTarget":null} + }"##; + assert_eq!(power_state(body).as_deref(), Some("On")); + assert!(allowable_resets(body).contains(&"ForceRestart".to_string())); + // PiKVM reports the override as Disabled and its PATCH does nothing — which is + // why the caller reads this back instead of trusting a status code. + assert_eq!(boot_override(body).0.as_deref(), Some("Disabled")); + assert_eq!(boot_override(body).1, None); + } + + #[test] + fn a_vendors_error_sentence_survives_to_be_printed() { + let reply = Reply { + status: 400, + body: r#"{"error":{"@Message.ExtendedInfo":[ + {"Message":"The value 'Pxe' for the property BootSourceOverrideTarget is not in the list of acceptable values."} + ]}}"# + .to_string(), + etag: None, + }; + assert!( + reply.describe().contains("not in the list"), + "{}", + reply.describe() + ); + assert!(reply.describe().starts_with("HTTP 400")); + } + + #[test] + fn an_error_with_no_sentence_still_reports_its_status() { + let reply = Reply { + status: 500, + body: "".to_string(), + etag: None, + }; + assert_eq!(reply.describe(), "HTTP 500"); + } + + #[test] + fn headers_are_split_from_the_body_at_the_last_blank_line() { + let (h, b) = split_headers("HTTP/1.1 200 OK\r\nETag: \"x\"\r\n\r\n{\"a\":1}"); + assert!(h.contains("ETag")); + assert_eq!(b, "{\"a\":1}"); + assert_eq!(header(h, "etag"), Some("\"x\"".to_string())); + // A body containing a blank line must not be truncated by taking the first one. + let (_, b) = split_headers("H: 1\r\n\r\nline\r\n\r\nmore"); + assert_eq!(b, "more"); + } +} diff --git a/src/select.rs b/src/select.rs index 252c88d..33a3a9a 100644 --- a/src/select.rs +++ b/src/select.rs @@ -49,8 +49,15 @@ pub struct Resolution { impl Resolution { /// A compact description for the log line. + /// + /// **The extension, not the family.** `format.label()` reports the *family*, so + /// `.ipxe`, `.ks`, `.preseed`, `.cfg` and `.seed` all came out as `format=text` — + /// which makes the log unable to tell a machine fetching its **boot script** from the + /// same machine, minutes later, fetching its **answer document**. That distinction is + /// what "installs in flight" is made of. `format_name` was already carried here and + /// unused by this function. pub fn how(&self) -> String { - let mut parts = vec![format!("format={}", self.format.label())]; + let mut parts = vec![format!("format={}", self.format_name)]; if let Some(m) = &self.machine { parts.push(format!("machine={m}")); } @@ -252,6 +259,25 @@ impl Answers { Ok(self.listing()?.problems.clone()) } + /// Drop the cached listing, so the next read goes back to the store. + /// + /// **This exists for `admin::guarded`, and the rollback depends on it.** The guard + /// compares `problems()` before and after a write, but over the file store `version()` + /// is the answers directory's mtime — which does not move when a document is written + /// *inside* an existing identity's directory, and which a coarse-granularity + /// filesystem may not move even for a new one within the same second. Either way the + /// listing is served from cache for up to `RELOAD_BACKSTOP`, so a write that broke the + /// answer set would compare equal to itself, pass the guard, and be kept. + /// + /// Which side is forced matters, and only one of them has to be: a stale `after` is a + /// rollback that never runs and fails **open**, while a stale `before` blames this + /// write for a pre-existing problem and fails **closed**. The guard forces both, since + /// the safe direction is cheap to buy twice. + pub fn invalidate(&self) { + let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + *guard = None; + } + /// A group's format, so a caller can address it the way its installer would. pub fn group_format(&self, name: &str) -> io::Result> { Ok(self @@ -293,24 +319,29 @@ impl Answers { .collect()) } - /// Drop the cached listing, so the next read goes back to the store. + /// The chain a group extends, nearest parent first. /// - /// **This exists for `admin::guarded`, and the rollback depends on it.** The guard - /// compares `problems()` before and after a write, but over the file store `version()` - /// is the answers directory's mtime — which does not move when a document is written - /// *inside* an existing identity's directory, and which a coarse-granularity - /// filesystem may not move even for a new one within the same second. Either way the - /// listing is served from cache for up to `RELOAD_BACKSTOP`, so a write that broke the - /// answer set would compare equal to itself, pass the guard, and be kept. - /// - /// Which side is forced matters, and only one of them has to be: a stale `after` is a - /// rollback that never runs and fails **open**, while a stale `before` blames this - /// write for a pre-existing problem and fails **closed**. The guard forces both, since - /// the safe direction is cheap to buy twice. - pub fn invalidate(&self) { - let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); - *guard = None; + /// Resolved at load, so a cycle or a missing parent has already been reported and the + /// broken group dropped — what comes back here is a chain that works. + pub fn group_extends(&self, name: &str) -> io::Result> { + let listing = self.listing()?; + let mut chain = Vec::new(); + let mut at = name.to_string(); + // Bounded by the number of groups: a cycle cannot survive load, but a bound here + // costs nothing and means this can never be the thing that hangs. + for _ in 0..listing.groups.len() { + let Some(group) = listing.groups.iter().find(|g| g.name == at) else { + break; + }; + let Some(parent) = group.control.extends.clone() else { + break; + }; + chain.push(parent.clone()); + at = parent; + } + Ok(chain) } + /// A group's selector criteria, if it has any. pub fn group_matchers(&self, name: &str) -> io::Result> { Ok(self diff --git a/src/tail.rs b/src/tail.rs new file mode 100644 index 0000000..569395e --- /dev/null +++ b/src/tail.rs @@ -0,0 +1,487 @@ +//! Following the server's log from another process. +//! +//! **The server hands its log to nothing.** `log.rs` writes lines to stderr or to the file +//! `RESCRIPTUM_LOG_FILE` names; there is no ring buffer, no endpoint, no socket. So +//! anything that wants to show the log is reading a file, and that has consequences worth +//! stating rather than discovering: +//! +//! - **No `RESCRIPTUM_LOG_FILE`, no log to follow.** If logging goes to stderr, a separate +//! process cannot see it. Say exactly that and name the setting to change — an empty +//! pane that looks broken is worse than an explanation. +//! - **Tail from the end.** A NAS that has been provisioning for a year has a large log. +//! Seek to the end, keep a bounded buffer, poll for growth. +//! - **Handle rotation.** If the file changes underneath — logrotate, or the DSM package's +//! own rotation — reopen rather than following a deleted inode forever. This is the +//! classic `tail -f` bug and **it is silent**: the screen simply stops updating. +//! - **Filtering is client-side.** `RESCRIPTUM_LOG=problems` filters at the *source* and +//! changing it means restarting the server. These two exist for different reasons; do +//! not conflate them. +//! - **A log line is the parse surface**, so the log format becomes an interface. Accept +//! that deliberately, keep the parser forgiving, and treat an unparsable line as text to +//! display rather than as an error. + +use std::collections::VecDeque; +use std::fs::File; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +/// How much of the end of the file to read on opening. A first screen's worth, without +/// reading a year of provisioning to find it. +pub const WINDOW: u64 = 64 * 1024; + +/// One line, parsed as far as it usefully can be. +/// +/// Every field is optional on purpose. A line this does not understand is still a line +/// somebody needs to read, and refusing it would hide exactly the unusual event they are +/// looking for. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Line { + pub raw: String, + pub timestamp: Option, + /// `None` for a server line, which carries `-` where a peer would be. + pub peer: Option, + pub status: Option, + /// The **extension** — `toml`, `ipxe`, `preseed`. Which is the whole reason + /// `Resolution::how` reports the extension rather than the family: from the family, + /// a machine fetching its boot script and the same machine fetching its answer + /// document are one event. + pub format: Option, + pub machine: Option, + pub group: Option, +} + +impl Line { + /// Whether `RESCRIPTUM_LOG=problems` would have kept this one. Applied here rather + /// than at the source, because changing the source means restarting the server. + pub fn is_problem(&self) -> bool { + match self.status { + // `0` means the exchange never reached a status — a connection that timed out + // mid-body, say — and counts as a problem, exactly as `log::request` treats it. + Some(s) => s == 0 || s >= 400, + // A server line is startup, a warning or an error: all diagnostic. + None => true, + } + } + + pub fn parse(raw: &str) -> Line { + let mut line = Line { + raw: raw.to_string(), + ..Default::default() + }; + let mut rest = raw; + + // `YYYY-MM-DDTHH:MM:SSZ`, fixed width, so this is a shape check rather than a + // date parse — there is no date crate here and this does not add one. + if let Some((head, tail)) = rest.split_once(' ') + && head.len() == 20 + && head.ends_with('Z') + { + line.timestamp = Some(head.to_string()); + rest = tail; + } + + if let Some((head, tail)) = rest.split_once(' ') { + if head != "-" { + line.peer = Some(head.to_string()); + } + rest = tail; + } + + for token in rest.split_whitespace() { + if let Some(v) = token.strip_prefix("format=") { + line.format = Some(v.to_string()); + } else if let Some(v) = token.strip_prefix("machine=") { + line.machine = Some(v.to_string()); + } else if let Some(v) = token.strip_prefix("group=") { + line.group = Some(v.to_string()); + } else if line.status.is_none() + && token.len() == 3 + && let Ok(code) = token.parse::() + && (100..600).contains(&code) + { + line.status = Some(code); + } + } + line + } + + /// Whether this line is about a given machine, matched the way everything else here + /// matches: normalized, so separator style never decides. + pub fn mentions(&self, id: &str) -> bool { + let wanted = crate::select::normalize(id.as_bytes()); + if wanted.is_empty() { + return false; + } + crate::select::normalize(self.raw.as_bytes()).contains(&wanted) + } +} + +/// What to show. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Filter { + All, + /// The same rule `RESCRIPTUM_LOG=problems` applies, but here and now. + Problems, + /// One machine, however its identifier is spelled. + Machine(String), +} + +impl Filter { + pub fn keeps(&self, line: &Line) -> bool { + match self { + Filter::All => true, + Filter::Problems => line.is_problem(), + Filter::Machine(id) => line.mentions(id), + } + } +} + +/// A file being followed. +pub struct Tail { + path: PathBuf, + file: File, + /// What the file was when it was opened, so a replacement can be noticed. + identity: Option<(u64, u64)>, + position: u64, + lines: VecDeque, + capacity: usize, + /// Set when the file was replaced under us, so a caller can say so once. + pub rotated: bool, +} + +impl Tail { + /// Open a log and read the tail of it. + /// + /// `capacity` bounds what is kept in memory: this follows a file that may be + /// gigabytes, and holding all of it to show forty lines would be the wrong trade on a + /// NAS with 512 MB. + pub fn open(path: impl Into, capacity: usize) -> std::io::Result { + let path = path.into(); + let mut tail = Tail { + file: File::open(&path)?, + identity: identity(&path), + path, + position: 0, + lines: VecDeque::new(), + capacity, + rotated: false, + }; + tail.seek_to_window()?; + tail.read_new()?; + Ok(tail) + } + + /// Where the log would be, or why there is none to follow. + /// + /// A pane that is empty because logging goes to stderr looks identical to one that is + /// broken, so this returns the sentence to show instead of an empty list. + pub fn describe(log_file: Option<&Path>) -> Result<&Path, String> { + match log_file { + None => Err( + "this server logs to stderr, so no other process can read it. \ + `rescriptum config set RESCRIPTUM_LOG_FILE=/path/to/log` gives it \ + somewhere to follow" + .to_string(), + ), + Some(p) if p == Path::new("stderr") || p == Path::new("stdout") => Err(format!( + "RESCRIPTUM_LOG_FILE={} is a stream, not a file, so no other process can \ + read it", + p.display() + )), + Some(p) => Ok(p), + } + } + + fn seek_to_window(&mut self) -> std::io::Result<()> { + let len = self.file.metadata()?.len(); + let from = len.saturating_sub(WINDOW); + self.position = self.file.seek(SeekFrom::Start(from))?; + // Starting mid-file almost certainly lands mid-line. Drop the partial one rather + // than showing half a record as if it were whole. + if from > 0 { + let mut discard = Vec::new(); + let mut reader = BufReader::new(&self.file); + let n = reader.read_until(b'\n', &mut discard)?; + self.position += n as u64; + self.file.seek(SeekFrom::Start(self.position))?; + } + Ok(()) + } + + /// Read whatever has been appended, and notice if the file was replaced. + /// + /// Returns how many lines were added. + pub fn poll(&mut self) -> std::io::Result { + // **Two ways a rotation shows up**, and only checking one of them is how this + // silently stops updating. logrotate's `create` replaces the file, so the identity + // changes; its `copytruncate` keeps the same file and empties it, so the identity + // is unchanged and the length went backwards. + let now = identity(&self.path); + let shrank = self + .file + .metadata() + .map(|m| m.len() < self.position) + .unwrap_or(false); + + if (now.is_some() && now != self.identity) || shrank { + match File::open(&self.path) { + Ok(file) => { + self.file = file; + self.identity = now; + self.position = 0; + self.file.seek(SeekFrom::Start(0))?; + self.rotated = true; + } + // The new file may not be there yet — logrotate moves before it creates. + // Keep the old handle and try again next time rather than giving up. + Err(_) => return Ok(0), + } + } + + self.read_new() + } + + fn read_new(&mut self) -> std::io::Result { + self.file.seek(SeekFrom::Start(self.position))?; + let mut buffer = String::new(); + let read = self.file.read_to_string(&mut buffer)?; + if read == 0 { + return Ok(0); + } + + // A line that is still being written has no newline yet. Leave it in the file by + // rewinding past it, so it is read whole next time rather than shown in halves. + let complete = match buffer.rfind('\n') { + Some(i) => &buffer[..=i], + None => return Ok(0), + }; + self.position += complete.len() as u64; + + let mut added = 0; + for raw in complete.lines() { + if raw.is_empty() { + continue; + } + if self.lines.len() == self.capacity { + self.lines.pop_front(); + } + self.lines.push_back(Line::parse(raw)); + added += 1; + } + Ok(added) + } + + pub fn lines(&self) -> impl Iterator { + self.lines.iter() + } + + pub fn filtered<'a>(&'a self, filter: &'a Filter) -> impl Iterator { + self.lines.iter().filter(move |l| filter.keeps(l)) + } +} + +#[cfg(unix)] +fn identity(path: &Path) -> Option<(u64, u64)> { + use std::os::unix::fs::MetadataExt; + let meta = std::fs::metadata(path).ok()?; + Some((meta.dev(), meta.ino())) +} + +#[cfg(not(unix))] +fn identity(_path: &Path) -> Option<(u64, u64)> { + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn scratch(name: &str) -> PathBuf { + static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = + std::env::temp_dir().join(format!("rescriptum-tail-{}-{name}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("scratch"); + dir.join("rescriptum.log") + } + + fn append(path: &Path, text: &str) { + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .expect("open"); + f.write_all(text.as_bytes()).expect("write"); + } + + const ANSWERED: &str = "2026-08-30T09:00:00Z 10.0.0.7:41234 POST /answer body=512 200 \ + format=toml machine=98fa9b50d810 group=rack-a bytes=1024"; + const BOOTED: &str = "2026-08-30T09:00:01Z 10.0.0.7:41235 GET /ipxe/boot body=0 200 \ + format=ipxe machine=98fa9b50d810 bytes=300"; + const MISSED: &str = "2026-08-30T09:00:02Z 10.0.0.9:41236 POST /answer body=512 404 \ + no answer file applies for mac=11:22:33:44:55:66"; + const WARNED: &str = "2026-08-30T09:00:03Z - warning: /srv/answers cannot be read"; + + #[test] + fn an_answer_line_is_parsed_into_its_parts() { + let l = Line::parse(ANSWERED); + assert_eq!(l.timestamp.as_deref(), Some("2026-08-30T09:00:00Z")); + assert_eq!(l.peer.as_deref(), Some("10.0.0.7:41234")); + assert_eq!(l.status, Some(200)); + assert_eq!(l.machine.as_deref(), Some("98fa9b50d810")); + assert_eq!(l.group.as_deref(), Some("rack-a")); + assert_eq!(l.format.as_deref(), Some("toml")); + assert!(!l.is_problem()); + } + + /// **The distinction the whole `format_name` change exists for.** A machine fetching + /// its boot script and the same machine fetching its answer document are the two ends + /// of an install; from the family label both said `text`. + #[test] + fn a_boot_script_is_distinguishable_from_an_answer_document() { + assert_eq!(Line::parse(BOOTED).format.as_deref(), Some("ipxe")); + assert_eq!(Line::parse(ANSWERED).format.as_deref(), Some("toml")); + } + + #[test] + fn a_server_line_has_no_peer_and_counts_as_a_problem() { + let l = Line::parse(WARNED); + assert!(l.peer.is_none(), "{l:?}"); + assert!(l.is_problem()); + } + + #[test] + fn a_404_now_names_the_machine_that_asked() { + let l = Line::parse(MISSED); + assert_eq!(l.status, Some(404)); + assert!(l.is_problem()); + assert!(l.mentions("11:22:33:44:55:66")); + // Normalized on both sides, so separator style never decides. + assert!(l.mentions("112233445566")); + } + + /// An unparsable line is text to display, never an error — refusing it would hide the + /// unusual event somebody is looking for. + #[test] + fn a_line_this_does_not_understand_is_still_a_line() { + let l = Line::parse("something nobody planned for"); + assert!(l.timestamp.is_none()); + assert_eq!(l.raw, "something nobody planned for"); + assert!(l.is_problem(), "unknown shapes are shown, not dropped"); + } + + #[test] + fn filters_are_applied_here_rather_than_at_the_source() { + let lines: Vec = [ANSWERED, BOOTED, MISSED, WARNED] + .iter() + .map(|l| Line::parse(l)) + .collect(); + + let problems = lines.iter().filter(|l| Filter::Problems.keeps(l)).count(); + assert_eq!(problems, 2, "the 404 and the warning"); + + let mine = Filter::Machine("98:fa:9b:50:d8:10".to_string()); + assert_eq!(lines.iter().filter(|l| mine.keeps(l)).count(), 2); + assert_eq!(lines.iter().filter(|l| Filter::All.keeps(l)).count(), 4); + } + + #[test] + fn following_a_file_picks_up_what_is_appended() { + let path = scratch("append"); + append(&path, &format!("{ANSWERED}\n")); + let mut tail = Tail::open(&path, 100).expect("open"); + assert_eq!(tail.lines().count(), 1); + + append(&path, &format!("{BOOTED}\n{MISSED}\n")); + assert_eq!(tail.poll().expect("poll"), 2); + assert_eq!(tail.lines().count(), 3); + // And nothing new is nothing new, rather than the same lines again. + assert_eq!(tail.poll().expect("poll"), 0); + } + + /// A record still being written must not be shown in halves. + #[test] + fn a_partial_line_waits_for_its_newline() { + let path = scratch("partial"); + append(&path, &format!("{ANSWERED}\n")); + let mut tail = Tail::open(&path, 100).expect("open"); + + append( + &path, + "2026-08-30T09:00:09Z 10.0.0.7:1 POST /answer body=1 2", + ); + assert_eq!(tail.poll().expect("poll"), 0, "half a line is not a line"); + + append(&path, "00 format=toml\n"); + assert_eq!(tail.poll().expect("poll"), 1); + assert_eq!(tail.lines().last().expect("line").status, Some(200)); + } + + /// **The classic `tail -f` bug, and it is silent**: following a deleted inode forever + /// while the screen quietly stops updating. This is logrotate's `create`. + #[test] + fn a_rotated_file_is_reopened_rather_than_followed_into_the_void() { + let path = scratch("rotate"); + append(&path, &format!("{ANSWERED}\n")); + let mut tail = Tail::open(&path, 100).expect("open"); + assert_eq!(tail.lines().count(), 1); + + // What logrotate does: move the old one aside, create a new one. + std::fs::rename(&path, path.with_extension("log.1")).expect("rotate"); + append(&path, &format!("{BOOTED}\n")); + + assert_eq!(tail.poll().expect("poll"), 1, "it must follow the new file"); + assert!(tail.rotated, "and say that it did"); + assert_eq!( + tail.lines().last().expect("line").format.as_deref(), + Some("ipxe") + ); + } + + /// The other half: `copytruncate` keeps the same inode and empties it, so only the + /// length going backwards gives it away. Checking one and not the other is how this + /// stops updating for half the deployments. + #[test] + fn a_truncated_file_is_noticed_even_though_it_is_the_same_file() { + let path = scratch("truncate"); + append(&path, &format!("{ANSWERED}\n{BOOTED}\n")); + let mut tail = Tail::open(&path, 100).expect("open"); + assert_eq!(tail.lines().count(), 2); + + std::fs::write(&path, format!("{MISSED}\n")).expect("truncate"); + assert_eq!(tail.poll().expect("poll"), 1); + assert!(tail.rotated); + } + + /// A NAS that has been provisioning for a year has a large log, and holding all of it + /// to show forty lines is the wrong trade on a machine with 512 MB. + #[test] + fn the_buffer_is_bounded_and_keeps_the_newest() { + let path = scratch("bounded"); + for n in 0..50 { + append( + &path, + &format!("2026-08-30T09:00:00Z 10.0.0.7:1 GET /x body=0 200 format=toml n={n}\n"), + ); + } + let tail = Tail::open(&path, 10).expect("open"); + assert_eq!(tail.lines().count(), 10); + assert!( + tail.lines().last().expect("line").raw.contains("n=49"), + "the newest lines are the ones worth keeping" + ); + } + + /// An empty pane that looks broken is worse than an explanation. + #[test] + fn there_being_no_file_to_follow_is_explained_rather_than_shown_empty() { + let e = Tail::describe(None).expect_err("stderr is not followable"); + assert!(e.contains("RESCRIPTUM_LOG_FILE"), "{e}"); + assert!(e.contains("config set"), "it must say how to fix it: {e}"); + + assert!(Tail::describe(Some(Path::new("stderr"))).is_err()); + assert!(Tail::describe(Some(Path::new("/var/log/x.log"))).is_ok()); + } +} diff --git a/src/tomlconfig.rs b/src/tomlconfig.rs index bbbfd22..a00fe09 100644 --- a/src/tomlconfig.rs +++ b/src/tomlconfig.rs @@ -60,7 +60,7 @@ pub struct Mapped { /// one-to-one mapping with the environment lives in this table instead of in the /// spelling, which is the right place for it — `config --value` and the panel both go /// through it, so nobody has to hold two names in their head. -pub const MAPPING: [Mapped; 30] = [ +pub const MAPPING: [Mapped; 31] = [ Mapped { key: "RESCRIPTUM_ANSWERS_DIR", path: "answers_dir", @@ -141,6 +141,11 @@ pub const MAPPING: [Mapped; 30] = [ path: "answer.capture_dir", numeric: false, }, + Mapped { + key: "RESCRIPTUM_CONTROLLERS_FILE", + path: "power.controllers_file", + numeric: false, + }, Mapped { key: "RESCRIPTUM_MEDIA_DIR", path: "media.dir", diff --git a/src/tui/draw.rs b/src/tui/draw.rs new file mode 100644 index 0000000..284792d --- /dev/null +++ b/src/tui/draw.rs @@ -0,0 +1,592 @@ +//! The part that paints, and the loop that drives it. +//! +//! Everything it needs to decide is in [`super`], which has no terminal dependency at all +//! and is tested in every build. What is here is ratatui and crossterm and nothing else: +//! set the terminal up, wait for an event, ask the state machine what it means, do that, +//! draw. It is deliberately the thinnest layer in this program. +//! +//! Three rules from the plan are enforced by construction rather than by care: +//! +//! - **The panic hook is installed *before* raw mode.** The release profile keeps +//! unwinding rather than aborting, so a panic mid-draw must not leave the operator with +//! a dead terminal and no echo. Installing it afterwards leaves a window where exactly +//! that happens. +//! - **Redraw on an event, not on a timer.** A 60 fps loop on an ARMv7 NAS burns a core +//! for a screen nobody is looking at. The poll below has a long timeout, and the only +//! thing on a clock is the log tail. +//! - **No network IO on the draw path.** `App::on_key` returns an [`Action`]; the loop +//! performs it between frames. One unreachable BMC cannot freeze a screen because the +//! screen was never what was waiting. + +use super::remote::Remote; +use super::{Action, App, Key, Pane}; +use crate::config::Config; +use crate::select::Answers; +use crate::tail::{Filter, Tail}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use crossterm::{ExecutableCommand, execute}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Tabs, Wrap}; +use std::io::{self, Stdout}; +use std::time::{Duration, Instant}; + +/// How long to wait for a key before looking at the log again. Seconds, not frames. +const POLL: Duration = Duration::from_millis(500); +/// How often the log tail is re-read while the log screen is open. +const TAIL_INTERVAL: Duration = Duration::from_secs(1); + +type Term = Terminal>; + +/// What the screens render. Refreshed by [`Action::Reload`], never mid-draw. +struct Model { + machines: Vec, + groups: Vec, + problems: Vec, + store: String, + /// Set when something was done, so the operator sees the outcome of a keystroke. + said: Option, +} + +/// Where a screen's contents come from. +/// +/// Remote mode inherits SQLite-only from the API it speaks to — that is a property of the +/// admin API, not a new decision — and it reads three screens because that is what the API +/// has. The rest say so rather than inventing a read surface. +pub enum Source { + Local(Answers), + Remote(Remote), +} + +impl Model { + fn from(source: &Source) -> Model { + match source { + Source::Local(answers) => Model::load(answers), + Source::Remote(remote) => Model { + machines: remote.machines().unwrap_or_default(), + groups: remote.groups().unwrap_or_default(), + problems: remote.problems().unwrap_or_default(), + store: remote.describe(), + // A failure here is worth saying out loud: an empty fleet and an + // unreachable one look identical otherwise. + said: remote.machines().err(), + }, + } + } + + fn load(answers: &Answers) -> Model { + Model { + machines: crate::cli::fleet::machines(answers).unwrap_or_default(), + groups: crate::cli::fleet::groups(answers).unwrap_or_default(), + problems: answers.problems().unwrap_or_default(), + store: answers.describe(), + said: None, + } + } + + fn rows(&self, pane: Pane) -> usize { + match pane { + Pane::Machines => self.machines.len(), + Pane::Groups => self.groups.len(), + Pane::Problems => self.problems.len(), + _ => 0, + } + } + + fn selected_id(&self, app: &App) -> Option { + match app.pane { + Pane::Machines => self.machines.get(app.selected).map(|m| m.id.clone()), + Pane::Groups => self.groups.get(app.selected).map(|g| g.name.clone()), + _ => None, + } + } +} + +/// Put the terminal back, whatever happened. +/// +/// Called from the panic hook, from every early return, and around the `$EDITOR` suspend — +/// where the editor itself may die. +fn restore() { + let _ = disable_raw_mode(); + let _ = io::stdout().execute(LeaveAlternateScreen); + let _ = io::stdout().execute(crossterm::cursor::Show); +} + +fn setup() -> io::Result { + // **Before raw mode, not after.** Between the two is a window where a panic leaves a + // terminal with no echo and no line discipline, and the operator has to type `reset` + // blind. + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + restore(); + previous(info); + })); + + enable_raw_mode()?; + let mut out = io::stdout(); + execute!(out, EnterAlternateScreen)?; + Terminal::new(CrosstermBackend::new(out)) +} + +/// `rescriptum tui` — the whole loop. +pub fn run(cfg: &Config, source: Source) -> io::Result<()> { + let mut app = App { + remote: matches!(source, Source::Remote(_)), + ..App::default() + }; + let mut model = Model::from(&source); + // The log is a file this process reads; if there is none, the screen says so rather + // than looking broken. + // The log is a file on *this* machine. Over the wire there is none to follow, and the + // admin API deliberately does not serve one — whether a fleet token should be able to + // read a server's log is a decision nobody has made. + let mut log: Result = if app.remote { + Err("local only — the admin API serves no log, deliberately".to_string()) + } else { + match Tail::describe(cfg.log_file.as_deref()) { + Ok(path) => Tail::open(path, 2000).map_err(|e| format!("{}: {e}", path.display())), + Err(why) => Err(why), + } + }; + let mut last_tail = Instant::now(); + + let mut term = setup()?; + let outcome = loop { + if let Err(e) = term.draw(|frame| render(frame, &app, &model, &log)) { + break Err(e); + } + + // A long poll rather than a frame clock: nothing moves unless something happened. + match event::poll(POLL) { + Ok(true) => {} + Ok(false) => { + if app.pane == Pane::Logs && last_tail.elapsed() >= TAIL_INTERVAL { + if let Ok(tail) = log.as_mut() { + let _ = tail.poll(); + } + last_tail = Instant::now(); + } + continue; + } + Err(e) => break Err(e), + } + + let key = match event::read() { + Ok(Event::Key(k)) if k.kind == KeyEventKind::Press => translate(k), + Ok(_) => continue, + Err(e) => break Err(e), + }; + let Some(key) = key else { continue }; + + let rows = model.rows(app.pane); + let selected = model.selected_id(&app); + let Some(action) = app.on_key(key, rows, selected.as_deref()) else { + continue; + }; + + model.said = None; + match action { + Action::Quit => break Ok(()), + Action::Reload => model = Model::from(&source), + Action::Render(id) => { + model.said = Some(match &source { + Source::Local(answers) => rendered(answers, &id), + // `GET /resolve/{id}` would do it, but that is a second request shape + // and the plan's promise was one endpoint. Named rather than faked. + Source::Remote(_) => { + format!("{id}: `rescriptum render` shows this, locally") + } + }); + } + Action::Edit(id) => { + // Suspending means leaving the alternate screen and giving the terminal + // back; `restore` is what the editor needs and what a crash in it must not + // skip. + let Source::Local(answers) = &source else { + model.said = Some("local only".to_string()); + continue; + }; + restore(); + let said = edit(cfg, answers, &id); + term = setup()?; + let _ = term.clear(); + model = Model::load(answers); + model.said = Some(said); + } + other => { + model.said = Some(match &source { + Source::Local(answers) => perform(cfg, answers, &other), + Source::Remote(_) => "local only".to_string(), + }); + } + } + }; + + restore(); + outcome +} + +/// Everything that is not a redraw and not an edit. +/// +/// Slow actions land here, between frames, which is the whole point of `on_key` returning +/// a description rather than doing the work. +fn perform(cfg: &Config, answers: &Answers, action: &Action) -> String { + let controllers = match cfg.controllers_file.as_deref() { + Some(p) => match crate::controllers::load(p) { + Ok(c) => c, + Err(e) => return e, + }, + None => { + return "no controllers: RESCRIPTUM_CONTROLLERS_FILE names a file, and nothing does" + .to_string(); + } + }; + + // A named alias rather than a closure type written inline: what this is, is "the one + // thing to do to the controller once it has been found". + type Deed = Box String>; + + let (id, run): (&str, Deed) = match action { + Action::Probe => { + let all: Vec<&crate::controllers::Controller> = controllers.iter().collect(); + let states = crate::power::probe(&all); + let on = states + .iter() + .filter(|s| matches!(s, Ok(st) if st.state == crate::power::State::On)) + .count(); + let unreachable = states.iter().filter(|s| s.is_err()).count(); + return format!( + "{} controller(s): {on} on, {unreachable} unreachable", + all.len() + ); + } + Action::PowerOn(id) => ( + id, + Box::new(|c| match crate::power::on(c) { + Ok(()) => "power on sent".to_string(), + Err(e) => e, + }), + ), + Action::PowerOff { id, hard } => { + let hard = *hard; + ( + id, + Box::new(move |c| match crate::power::off(c, hard) { + Ok(()) => if hard { "forced off" } else { "shutdown sent" }.to_string(), + Err(e) => e, + }), + ) + } + Action::Pxe(id) => ( + id, + Box::new(|c| match crate::power::pxe(c) { + Ok(crate::power::Armed::Confirmed) => "one-time network boot armed".to_string(), + Ok(crate::power::Armed::NotSupported) => { + "no boot override here — the server decides".to_string() + } + Ok(crate::power::Armed::Ignored) => { + "accepted and not applied — this controller reports success without \ + doing anything" + .to_string() + } + Err(e) => e, + }), + ), + Action::Arm(id) => { + let store = match cfg.open_store() { + Ok(s) => s, + Err(e) => return e.to_string(), + }; + return match crate::installed::rearm(store.as_ref(), id) { + Ok(Some(under)) => format!("{id}: its boot script is back, as {under}"), + Ok(None) => format!("{id}: nothing archived to put back"), + Err(e) => format!("{id}: {e}"), + }; + } + Action::Disarm(id) => { + let _ = answers; + return format!( + "{id}: disarming by hand is `POST /installed`'s job — a machine reports its \ + own success" + ); + } + _ => return String::new(), + }; + + match controllers.find(id) { + Some(c) => format!("{}: {}", c.id, run(c)), + None => format!("{id}: no controller"), + } +} + +fn rendered(answers: &Answers, id: &str) -> String { + match answers.resolve(&crate::facts::Facts::from_identity(id)) { + Ok(Some(r)) => format!("{id}: {} ({} bytes)", r.how(), r.body.len()), + Ok(None) => format!("{id}: nothing applies — the server would answer 404"), + Err(e) => format!("{id}: {e}"), + } +} + +fn edit(cfg: &Config, answers: &Answers, id: &str) -> String { + let store = match cfg.open_store() { + Ok(s) => s, + Err(e) => return e.to_string(), + }; + let target = crate::guard::Target::Machine(id.to_string()); + let (format, before) = match store.snapshot() { + Ok(s) => match s.machines.into_iter().find(|m| m.id == id) { + Some(m) => (m.format, m.body), + None => return format!("{id}: no document to edit"), + }, + Err(e) => return e.to_string(), + }; + + let (editor, note) = crate::edit::editor(std::env::var("EDITOR").ok()); + let outcome = + crate::edit::round_trip(answers, store.as_ref(), &target, &format, &before, |p| { + match std::process::Command::new(&editor).arg(p).status() { + Ok(s) if s.success() => Ok(()), + Ok(s) => Err(format!("{editor} exited {}", s.code().unwrap_or(-1))), + Err(e) => Err(format!("cannot run {editor}: {e}")), + } + }); + + let said = match outcome { + crate::edit::Edited::Unchanged => format!("{id}: unchanged, nothing written"), + crate::edit::Edited::Stored(p) if p.is_empty() => format!("{id}: stored"), + crate::edit::Edited::Stored(p) => format!("{id}: stored, {} problem(s) remain", p.len()), + crate::edit::Edited::Refused(p) => { + format!("{id}: refused and rolled back — {}", p.join("; ")) + } + crate::edit::Edited::Failed(e) => format!("{id}: {e}"), + }; + match note { + Some(n) => format!("{said} ({n})"), + None => said, + } +} + +/// crossterm's keys, reduced to the ones the state machine knows. +fn translate(k: KeyEvent) -> Option { + Some(match k.code { + KeyCode::Up => Key::Up, + KeyCode::Down => Key::Down, + KeyCode::PageUp => Key::PageUp, + KeyCode::PageDown => Key::PageDown, + KeyCode::Home => Key::Home, + KeyCode::End => Key::End, + KeyCode::Tab => Key::Tab, + KeyCode::BackTab => Key::BackTab, + KeyCode::Enter => Key::Enter, + KeyCode::Esc => Key::Escape, + // Ctrl-C is what a terminal user's fingers do; it means quit here too. + KeyCode::Char('c') if k.modifiers.contains(KeyModifiers::CONTROL) => Key::Char('q'), + KeyCode::Char(c) => Key::Char(c), + _ => return None, + }) +} + +fn render(frame: &mut Frame, app: &App, model: &Model, log: &Result) { + let areas = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(frame.area()); + + let titles: Vec<&str> = Pane::ORDER.iter().map(|p| p.title()).collect(); + let selected = Pane::ORDER.iter().position(|p| *p == app.pane).unwrap_or(0); + frame.render_widget(Tabs::new(titles).select(selected).divider(" "), areas[0]); + + let body = areas[1]; + match app.describe_pane(cfg!(feature = "boot")) { + Some(note) => frame.render_widget( + Paragraph::new(note) + .wrap(Wrap { trim: true }) + .block(block(app)), + body, + ), + None => match app.pane { + Pane::Dashboard => dashboard(frame, body, model), + Pane::Machines => machines(frame, body, app, model), + Pane::Groups => groups(frame, body, app, model), + Pane::Problems => problems(frame, body, app, model), + Pane::Logs => logs(frame, body, app, log), + Pane::Media | Pane::Boot => frame.render_widget( + Paragraph::new( + "`rescriptum media list` and `boot check` say more than a pane could", + ) + .block(block(app)), + body, + ), + }, + } + + let hint = match &app.search { + Some(text) => format!("/{text}"), + None => match &model.said { + Some(said) => said.clone(), + None => "tab panes · ↑↓ move · enter render · e edit · o on · X force off · \ + x pxe · s state · / filter · r reload · q quit" + .to_string(), + }, + }; + frame.render_widget(Paragraph::new(hint), areas[2]); +} + +fn block(app: &App) -> Block<'static> { + Block::default() + .borders(Borders::ALL) + .title(app.pane.title().to_string()) +} + +fn dashboard(frame: &mut Frame, area: Rect, model: &Model) { + let armed = model.machines.iter().filter(|m| m.armed).count(); + let by_group = model.machines.iter().filter(|m| m.armed_by_group).count(); + let disarmed = model.machines.iter().filter(|m| m.disarmed).count(); + + let mut lines = vec![ + Line::from(model.store.clone()), + Line::from(""), + Line::from(format!( + "{} machine(s), {} group(s)", + model.machines.len(), + model.groups.len() + )), + Line::from(format!( + "{armed} armed, {disarmed} disarmed by a previous install" + )), + ]; + if by_group > 0 { + // Its own line, because these reinstall on every network boot and their webhook + // reports success while doing it. + lines.push(Line::from(format!( + "{by_group} armed by a group, which `POST /installed` cannot disarm" + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(if model.problems.is_empty() { + "no problems".to_string() + } else { + format!( + "{} problem(s) — see the problems pane", + model.problems.len() + ) + })); + + frame.render_widget( + Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("dashboard")), + area, + ); +} + +fn machines(frame: &mut Frame, area: Rect, app: &App, model: &Model) { + let mut app = app.clone(); + let window = app.visible(model.machines.len(), area.height.saturating_sub(2) as usize); + let items: Vec = model.machines[window.clone()] + .iter() + .enumerate() + .map(|(i, m)| { + let marker = if window.start + i == app.selected { + "> " + } else { + " " + }; + let armed = match (m.armed, m.armed_by_group, m.disarmed) { + (true, true, _) => " armed by a group (cannot disarm itself)", + (true, false, _) => " armed", + (false, _, true) => " disarmed", + _ => "", + }; + let group = m.group.as_deref().unwrap_or("-"); + ListItem::new(format!( + "{marker}{} [{}] {group}{armed}", + m.id, + m.formats.join(",") + )) + }) + .collect(); + frame.render_widget(List::new(items).block(block(&app)), area); +} + +fn groups(frame: &mut Frame, area: Rect, app: &App, model: &Model) { + let mut app = app.clone(); + let window = app.visible(model.groups.len(), area.height.saturating_sub(2) as usize); + let items: Vec = model.groups[window.clone()] + .iter() + .enumerate() + .map(|(i, g)| { + let marker = if window.start + i == app.selected { + "> " + } else { + " " + }; + let extends = if g.extends.is_empty() { + String::new() + } else { + format!(" extends {}", g.extends.join(" -> ")) + }; + ListItem::new(format!( + "{marker}{} [{}] {} member(s){extends}", + g.name, + g.format, + g.members.len() + )) + }) + .collect(); + frame.render_widget(List::new(items).block(block(&app)), area); +} + +fn problems(frame: &mut Frame, area: Rect, app: &App, model: &Model) { + if model.problems.is_empty() { + frame.render_widget( + Paragraph::new("no problems — which is the normal state").block(block(app)), + area, + ); + return; + } + let mut app = app.clone(); + let window = app.visible(model.problems.len(), area.height.saturating_sub(2) as usize); + let items: Vec = model.problems[window] + .iter() + .map(|p| ListItem::new(p.clone())) + .collect(); + frame.render_widget(List::new(items).block(block(&app)), area); +} + +fn logs(frame: &mut Frame, area: Rect, app: &App, log: &Result) { + let tail = match log { + // Named, not blank: an empty pane that looks broken is worse than an explanation. + Err(why) => { + frame.render_widget( + Paragraph::new(why.clone()) + .wrap(Wrap { trim: true }) + .block(block(app)), + area, + ); + return; + } + Ok(t) => t, + }; + + let height = area.height.saturating_sub(2) as usize; + let kept: Vec<&crate::tail::Line> = tail.filtered(&app.filter).collect(); + let from = kept.len().saturating_sub(height); + let items: Vec = kept[from..] + .iter() + .map(|l| ListItem::new(l.raw.clone())) + .collect(); + + let title = match &app.filter { + Filter::All => "logs".to_string(), + Filter::Problems => "logs — problems only".to_string(), + Filter::Machine(id) => format!("logs — {id}"), + }; + let mut b = Block::default().borders(Borders::ALL).title(title); + if tail.rotated { + b = b.title_bottom("the log was rotated and is being followed into the new file"); + } + frame.render_widget(List::new(items).block(b), area); +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..533f151 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,595 @@ +//! The state a terminal interface keeps, and the rules about when it may do work. +//! +//! **ratatui is immediate-mode: the state is ours.** It draws what it is handed each +//! frame, so selection, scroll offset and the current pane live in a struct here rather +//! than inside a widget. There is no retained tree to look for. +//! +//! Which means the interesting half of the interface has nothing to do with drawing, and +//! is therefore here — compiled and tested in every build, including +//! `--no-default-features`. The draw layer on top is thin by construction, and the rules +//! below are what keep it that way: +//! +//! - **A key press never does the work.** `on_key` returns an [`Action`] describing what +//! should happen; something outside the draw path performs it. That is what makes +//! "never do network IO on a redraw" a property rather than a discipline — one +//! unreachable BMC must not be able to freeze a screen. +//! - **Redraw on an event, not on a timer.** A 60 fps loop on an ARMv7 NAS burns a core +//! for a screen nobody is looking at. +//! - **Nothing here is the only way to do anything.** Every action below has a command +//! that does the same thing, because scripts, `deploy.sh` and CI cannot press keys. + +/// The painting, and only the painting. Behind the feature, so the answer server a NAS +/// runs links none of it. +#[cfg(feature = "tui")] +pub mod draw; +/// Reading a deployment's fleet over the admin API, for `tui --remote`. +#[cfg(feature = "tui")] +pub mod remote; + +use crate::tail::Filter; + +/// The screens, in the order they are cycled through. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pane { + /// Is this thing working, and what is happening. + Dashboard, + Machines, + Groups, + Media, + Boot, + Logs, + Problems, +} + +impl Pane { + pub const ORDER: [Pane; 7] = [ + Pane::Dashboard, + Pane::Machines, + Pane::Groups, + Pane::Media, + Pane::Boot, + Pane::Logs, + Pane::Problems, + ]; + + pub fn title(self) -> &'static str { + match self { + Pane::Dashboard => "dashboard", + Pane::Machines => "machines", + Pane::Groups => "groups", + Pane::Media => "media", + Pane::Boot => "boot", + Pane::Logs => "logs", + Pane::Problems => "problems", + } + } + + /// Whether this screen needs the boot feature to say anything. + /// + /// A build without `boot` keeps the screen and explains itself, exactly as `cli`'s + /// `media` and `boot` subcommands do — the surface stays described everywhere, and + /// only the binary that cannot honour it objects. + pub fn needs_boot(self) -> bool { + matches!(self, Pane::Media | Pane::Boot) + } + + fn next(self) -> Pane { + let i = Pane::ORDER.iter().position(|p| *p == self).unwrap_or(0); + Pane::ORDER[(i + 1) % Pane::ORDER.len()] + } + + fn previous(self) -> Pane { + let i = Pane::ORDER.iter().position(|p| *p == self).unwrap_or(0); + Pane::ORDER[(i + Pane::ORDER.len() - 1) % Pane::ORDER.len()] + } +} + +/// A key, reduced to what this program cares about. +/// +/// Its own type rather than crossterm's, so the state machine compiles and is tested +/// without a terminal library — and so a different one could be swapped underneath. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Key { + Up, + Down, + PageUp, + PageDown, + Home, + End, + Tab, + BackTab, + Enter, + Escape, + Char(char), +} + +/// Work the interface wants done — **returned, never performed here**. +/// +/// Everything that touches the network, the store or a controller becomes one of these and +/// is carried out away from the draw path. A rack of dead BMCs then cannot freeze a +/// screen, because the screen was never the thing waiting. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Re-read the store. Cheap, local, and the only one safe to do often. + Reload, + /// Ask every controller whether it is on. **Bounded, concurrent, and on demand** — + /// never what a redraw does. + Probe, + /// Show what one machine would be served. + Render(String), + /// Hand a document to `$EDITOR`, which means suspending the screen. + Edit(String), + Arm(String), + Disarm(String), + /// Power actions, which exist only where a controller does. + PowerOn(String), + PowerOff { + id: String, + hard: bool, + }, + Pxe(String), + /// Leave. + Quit, +} + +impl Action { + /// Whether performing this can block on something outside this machine. + /// + /// The draw path may never run one of these, and the test below pins that no key + /// press produces one synchronously. + pub fn is_slow(&self) -> bool { + matches!( + self, + Action::Probe + | Action::PowerOn(_) + | Action::PowerOff { .. } + | Action::Pxe(_) + | Action::Edit(_) + ) + } +} + +/// What the interface is showing and where it is looking. +#[derive(Debug, Clone)] +pub struct App { + pub pane: Pane, + /// Which row, within the current pane's list. + pub selected: usize, + /// The first visible row, kept so the selection is always on screen. + pub offset: usize, + pub filter: Filter, + /// Set when a pane cannot say anything in this build, so it explains instead of + /// looking broken. + pub note: Option, + /// Typed into the filter box, when one is open. + pub search: Option, + /// Reading a deployment over the admin API rather than a store on this machine. + /// + /// **Nothing powers anything in this mode**, and that is a property of the state + /// machine rather than a rule somebody remembers. The answer endpoint is + /// unauthenticated by necessity and the admin API decides what every machine installs; + /// putting power control over the wire is how a provisioning server becomes a weapon. + /// The plan says never, so `on_key` refuses rather than the loop declining later. + pub remote: bool, +} + +impl Default for App { + fn default() -> App { + App { + pane: Pane::Dashboard, + selected: 0, + offset: 0, + filter: Filter::All, + note: None, + search: None, + remote: false, + } + } +} + +impl App { + /// Handle one key, given how many rows the current pane holds. + /// + /// `rows` is passed in rather than held, because the pane's contents belong to the + /// model the interface renders and not to the interface. + pub fn on_key(&mut self, key: Key, rows: usize, selected_id: Option<&str>) -> Option { + // A filter box swallows keys while it is open, or typing `q` in it would quit. + if let Some(text) = &mut self.search { + match key { + Key::Escape => { + self.search = None; + self.filter = Filter::All; + } + Key::Enter => { + let text = text.clone(); + self.search = None; + self.filter = if text.is_empty() { + Filter::All + } else { + Filter::Machine(text) + }; + } + Key::Char(c) => text.push(c), + _ => {} + } + return None; + } + + match key { + Key::Tab => { + self.go(self.pane.next()); + None + } + Key::BackTab => { + self.go(self.pane.previous()); + None + } + Key::Up => { + self.selected = self.selected.saturating_sub(1); + None + } + Key::Down => { + if rows > 0 { + self.selected = (self.selected + 1).min(rows - 1); + } + None + } + Key::Home => { + self.selected = 0; + None + } + Key::End => { + self.selected = rows.saturating_sub(1); + None + } + Key::PageUp => { + self.selected = self.selected.saturating_sub(10); + None + } + Key::PageDown => { + if rows > 0 { + self.selected = (self.selected + 10).min(rows - 1); + } + None + } + Key::Char('q') => Some(Action::Quit), + Key::Char('r') => Some(Action::Reload), + Key::Char('/') => { + self.search = Some(String::new()); + None + } + // Problems-only, because that is the pane where "show me only the bad ones" + // is the whole point. Elsewhere it would silently mean something else. + Key::Char('p') if self.pane == Pane::Logs => { + self.filter = if self.filter == Filter::Problems { + Filter::All + } else { + Filter::Problems + }; + None + } + // **Power and editing are local only, and this arm is why no screen can + // forget.** It sits above every action arm on purpose: placed lower, the + // `Key::Char('e')` and `Key::Char('a')` arms would match first. + _ if self.remote + && matches!( + key, + Key::Char('o' | 'O' | 'X' | 'x' | 'e' | 's' | 'a' | 'd') + ) => + { + self.note = Some( + "local only — this is a remote view, and nothing here powers anything" + .to_string(), + ); + None + } + + // Everything below needs a row under the cursor, so a pane with nothing in it + // does nothing rather than acting on row zero of an empty list. + Key::Enter => selected_id.map(|id| Action::Render(id.to_string())), + Key::Char('e') => selected_id.map(|id| Action::Edit(id.to_string())), + Key::Char('a') => selected_id.map(|id| Action::Arm(id.to_string())), + Key::Char('d') => selected_id.map(|id| Action::Disarm(id.to_string())), + Key::Char('o') => selected_id.map(|id| Action::PowerOn(id.to_string())), + Key::Char('O') => selected_id.map(|id| Action::PowerOff { + id: id.to_string(), + hard: false, + }), + // Upper case and a separate key: forcing a machine off mid-write is how a + // filesystem gets repaired by hand later. + Key::Char('X') => selected_id.map(|id| Action::PowerOff { + id: id.to_string(), + hard: true, + }), + Key::Char('x') => selected_id.map(|id| Action::Pxe(id.to_string())), + Key::Char('s') => Some(Action::Probe), + Key::Escape => { + self.filter = Filter::All; + None + } + _ => None, + } + } + + fn go(&mut self, pane: Pane) { + self.pane = pane; + // Row three of `machines` means nothing in `groups`. + self.selected = 0; + self.offset = 0; + self.note = None; + } + + /// The rows to draw, given the height available — and the offset adjusted so the + /// selection is on screen. + /// + /// Scrolling belongs here rather than in the draw call, because "the selection is + /// always visible" is a property of the state, and a draw that computed it would have + /// to compute it identically in every pane. + pub fn visible(&mut self, rows: usize, height: usize) -> std::ops::Range { + if height == 0 || rows == 0 { + self.offset = 0; + return 0..0; + } + self.selected = self.selected.min(rows - 1); + if self.selected < self.offset { + self.offset = self.selected; + } else if self.selected >= self.offset + height { + self.offset = self.selected + 1 - height; + } + // A list that shrank under a scrolled view must not leave a window past its end. + self.offset = self.offset.min(rows.saturating_sub(height)); + let end = (self.offset + height).min(rows); + self.offset..end + } + + /// What this pane can say in this build. + /// + /// A screen that is empty because the feature is off looks identical to one that is + /// broken, so it carries the sentence instead. + pub fn describe_pane(&self, has_boot: bool) -> Option<&'static str> { + if self.pane.needs_boot() && !has_boot { + return Some( + "this binary was built without the `boot` feature, so it has no media or \ + boot to show", + ); + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> App { + App::default() + } + + /// **The rule that keeps one unreachable BMC from freezing a screen.** A key press + /// produces a description of work; something off the draw path does it. + #[test] + fn no_key_press_performs_slow_work_itself() { + let mut a = app(); + a.pane = Pane::Machines; + for key in [ + Key::Char('s'), + Key::Char('o'), + Key::Char('X'), + Key::Char('x'), + Key::Char('e'), + ] { + let action = a.on_key(key, 3, Some("98fa9b50d810")); + let action = action.expect("these keys mean something on a machine row"); + assert!( + action.is_slow(), + "{key:?} should be recognised as work to do elsewhere: {action:?}" + ); + } + } + + #[test] + fn a_pane_with_nothing_in_it_acts_on_nothing() { + // Row zero of an empty list is not a machine, and arming it would be a surprise. + let mut a = app(); + a.pane = Pane::Machines; + for key in [Key::Enter, Key::Char('e'), Key::Char('a'), Key::Char('o')] { + assert_eq!(a.on_key(key, 0, None), None, "{key:?}"); + } + } + + #[test] + fn moving_between_panes_forgets_the_row() { + // Row three of `machines` means nothing in `groups`. + let mut a = app(); + a.on_key(Key::Tab, 9, None); + a.on_key(Key::Down, 9, None); + a.on_key(Key::Down, 9, None); + assert_eq!(a.selected, 2); + a.on_key(Key::Tab, 9, None); + assert_eq!(a.selected, 0); + assert_eq!(a.offset, 0); + } + + #[test] + fn the_panes_cycle_in_both_directions() { + let mut a = app(); + assert_eq!(a.pane, Pane::Dashboard); + a.on_key(Key::BackTab, 0, None); + assert_eq!(a.pane, Pane::Problems, "wrapping backwards"); + a.on_key(Key::Tab, 0, None); + assert_eq!(a.pane, Pane::Dashboard, "and forwards again"); + } + + #[test] + fn the_selection_cannot_leave_the_list() { + let mut a = app(); + for _ in 0..20 { + a.on_key(Key::Down, 3, None); + } + assert_eq!(a.selected, 2); + for _ in 0..20 { + a.on_key(Key::Up, 3, None); + } + assert_eq!(a.selected, 0); + } + + #[test] + fn scrolling_keeps_the_selection_on_screen() { + let mut a = app(); + a.selected = 0; + assert_eq!(a.visible(100, 10), 0..10); + + a.selected = 15; + let window = a.visible(100, 10); + assert!(window.contains(&15), "{window:?}"); + assert_eq!(window.len(), 10); + + a.selected = 2; + let window = a.visible(100, 10); + assert!(window.contains(&2), "scrolling back up: {window:?}"); + } + + /// A list that shrank under a scrolled view — a machine removed while the screen was + /// halfway down it — must not leave a window past the end. + #[test] + fn a_list_that_shrinks_does_not_leave_the_window_past_its_end() { + let mut a = app(); + a.selected = 90; + a.visible(100, 10); + let window = a.visible(12, 10); + assert!(window.end <= 12, "{window:?}"); + assert!( + a.selected < 12, + "the selection followed the list: {}", + a.selected + ); + } + + #[test] + fn an_empty_list_has_an_empty_window_rather_than_a_panic() { + let mut a = app(); + a.selected = 5; + assert_eq!(a.visible(0, 10), 0..0); + assert_eq!(a.visible(10, 0), 0..0); + } + + /// Typing `q` into a filter box must not quit. + #[test] + fn a_filter_box_swallows_the_keys_that_would_otherwise_be_commands() { + let mut a = app(); + assert_eq!(a.on_key(Key::Char('/'), 3, None), None); + for c in "qro".chars() { + assert_eq!(a.on_key(Key::Char(c), 3, None), None, "{c} must be typed"); + } + assert_eq!(a.search.as_deref(), Some("qro")); + + a.on_key(Key::Enter, 3, None); + assert_eq!(a.filter, Filter::Machine("qro".to_string())); + assert!(a.search.is_none()); + } + + #[test] + fn an_empty_search_clears_the_filter_rather_than_matching_nothing() { + let mut a = app(); + a.filter = Filter::Machine("x".to_string()); + a.on_key(Key::Char('/'), 3, None); + a.on_key(Key::Enter, 3, None); + assert_eq!(a.filter, Filter::All); + } + + /// Client-side, and only where it means something: `RESCRIPTUM_LOG=problems` filters + /// at the source and needs a restart, which is a different thing entirely. + #[test] + fn problems_only_toggles_and_only_on_the_log_screen() { + let mut a = app(); + a.pane = Pane::Logs; + a.on_key(Key::Char('p'), 5, None); + assert_eq!(a.filter, Filter::Problems); + a.on_key(Key::Char('p'), 5, None); + assert_eq!(a.filter, Filter::All, "it toggles back"); + + let mut a = app(); + a.pane = Pane::Machines; + a.on_key(Key::Char('p'), 5, None); + assert_eq!(a.filter, Filter::All, "elsewhere it must mean nothing"); + } + + /// A screen that is empty because the feature is off looks identical to one that is + /// broken, so it says which. + #[test] + fn a_pane_that_needs_a_feature_it_does_not_have_explains_itself() { + let mut a = app(); + a.pane = Pane::Media; + assert!(a.describe_pane(false).is_some()); + assert!(a.describe_pane(true).is_none()); + + a.pane = Pane::Machines; + assert!( + a.describe_pane(false).is_none(), + "machines needs no feature" + ); + } + + /// **Nothing powers anything over the wire.** The answer endpoint is unauthenticated + /// by necessity and the admin API sets the root password of every machine installed + /// afterwards; power control near either is how a provisioning server becomes a + /// weapon. Refused in the state machine, so no screen can forget. + #[test] + fn a_remote_view_powers_nothing_and_says_so() { + let mut a = app(); + a.remote = true; + a.pane = Pane::Machines; + for key in [ + Key::Char('o'), + Key::Char('O'), + Key::Char('X'), + Key::Char('x'), + Key::Char('s'), + ] { + assert_eq!( + a.on_key(key, 3, Some("98fa9b50d810")), + None, + "{key:?} must do nothing remotely" + ); + assert!( + a.note.as_deref().is_some_and(|n| n.contains("local only")), + "and it must say why: {:?}", + a.note + ); + } + } + + /// Editing suspends the screen and hands a document to `$EDITOR` on *this* machine. + /// Over the wire that is somebody else's document and somebody else's editor. + #[test] + fn a_remote_view_does_not_edit_either() { + let mut a = app(); + a.remote = true; + a.pane = Pane::Machines; + assert_eq!(a.on_key(Key::Char('e'), 3, Some("98fa9b50d810")), None); + } + + /// Reading still works, or the mode would be pointless. + #[test] + fn a_remote_view_still_navigates_and_renders() { + let mut a = app(); + a.remote = true; + a.pane = Pane::Machines; + assert_eq!( + a.on_key(Key::Enter, 3, Some("98fa9b50d810")), + Some(Action::Render("98fa9b50d810".to_string())) + ); + assert_eq!(a.on_key(Key::Char('r'), 3, None), Some(Action::Reload)); + assert_eq!(a.on_key(Key::Char('q'), 3, None), Some(Action::Quit)); + } + + #[test] + fn q_quits_and_r_reloads_and_neither_is_slow() { + let mut a = app(); + assert_eq!(a.on_key(Key::Char('q'), 0, None), Some(Action::Quit)); + let reload = a.on_key(Key::Char('r'), 0, None).expect("reload"); + assert_eq!(reload, Action::Reload); + // Re-reading the store is local, so it is the one thing safe to do often. + assert!(!reload.is_slow()); + } +} diff --git a/src/tui/remote.rs b/src/tui/remote.rs new file mode 100644 index 0000000..a49db4d --- /dev/null +++ b/src/tui/remote.rs @@ -0,0 +1,260 @@ +//! Reading a deployment's fleet over the admin API. +//! +//! **One request, not one per machine.** `GET /machines` returns bare identifiers, so a +//! remote view built on the endpoints that existed would need a `GET /resolve/{id}` for +//! every machine — two thousand round trips on the fleet this project measures itself +//! against, which is not "the same screens over the wire" but a different and much worse +//! program. `GET /fleet` exists for this, and it returns **byte for byte** what +//! `machines --json` prints, from the same producer, so the two cannot drift. +//! +//! **Three screens, honestly labelled.** The admin API has machines, groups and check — +//! and nothing for media, boot or the log. Rather than growing a read API with its own +//! auth-exposed surface, the other panes say so. +//! +//! **Nothing powers anything.** That is refused in [`super::App::on_key`] rather than +//! here, so no screen can forget it. +//! +//! Through `curl`, like `redfish` and `boot::fetch`: there is no TLS in this binary, and +//! the token must stay out of the process table where `ps` would show it. + +use crate::redfish::quote; +use serde_json::Value; +use std::io::Write; +use std::process::{Command, Stdio}; +use std::time::Duration; + +/// A screen redraw must never wait longer than a person will. +const TIMEOUT: Duration = Duration::from_secs(10); + +pub struct Remote { + base: String, + token: String, +} + +/// Written by hand rather than derived, because the derived one would print the token. +/// +/// This credential sets the root password of every machine installed afterwards, and a +/// `{:?}` in a log line or a panic message is exactly how it would escape. The same reason +/// `config::Setting` never carries a secret's value. +impl std::fmt::Debug for Remote { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Remote") + .field("base", &self.base) + .field("token", &"") + .finish() + } +} + +impl Remote { + /// `url` is the admin listener — `http://nas:9000`, with no path. + pub fn new(url: &str, token: &str) -> Result { + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(format!( + "{url:?} needs a scheme, and it is the admin listener's" + )); + } + if token.trim().is_empty() { + return Err( + "no admin token: RESCRIPTUM_ADMIN_TOKEN is what this API authenticates with" + .to_string(), + ); + } + Ok(Remote { + base: url.trim_end_matches('/').to_string(), + token: token.to_string(), + }) + } + + /// The token goes in the option file on stdin, never in `argv` — `ps` would otherwise + /// show a credential that sets the root password of every machine installed afterwards. + fn get(&self, path: &str) -> Result { + let mut config = String::new(); + config.push_str(&format!( + "url = {}\n", + quote(&format!("{}{path}", self.base)) + )); + config.push_str(&format!( + "header = {}\n", + quote(&format!("Authorization: Bearer {}", self.token)) + )); + config.push_str(&format!("max-time = {}\n", TIMEOUT.as_secs())); + config.push_str("silent\nshow-error\n"); + config.push_str("write-out = \"\\n%{http_code}\"\n"); + + let mut child = Command::new("curl") + .args(["--config", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => { + "curl is not installed, and there is no TLS in this binary".to_string() + } + _ => format!("cannot run curl: {e}"), + })?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(config.as_bytes()) + .map_err(|e| format!("cannot write curl's options: {e}"))?; + } + let out = child + .wait_with_output() + .map_err(|e| format!("curl did not finish: {e}"))?; + if !out.status.success() { + return Err(format!( + "{}{path}: {}", + self.base, + String::from_utf8_lossy(&out.stderr).trim() + )); + } + + let text = String::from_utf8_lossy(&out.stdout).into_owned(); + let (body, status) = text + .rsplit_once('\n') + .ok_or_else(|| "curl produced no status".to_string())?; + match status.trim() { + "200" => Ok(body.to_string()), + "401" => Err("401 — the admin token was refused".to_string()), + "404" => Err(format!( + "404 — {} has no /fleet, so it is older than this build", + self.base + )), + other => Err(format!("HTTP {other} from {}{path}", self.base)), + } + } + + /// The same shape `crate::cli::fleet::machines` produces locally, parsed back. + pub fn machines(&self) -> Result, String> { + let body = self.get("/fleet")?; + let v: Value = serde_json::from_str(&body).map_err(|e| format!("/fleet: {e}"))?; + let rows = v + .get("machines") + .and_then(Value::as_array) + .ok_or_else(|| "/fleet: no `machines`".to_string())?; + + Ok(rows + .iter() + .map(|m| crate::cli::fleet::Machine { + id: string(m, "id"), + formats: m + .get("formats") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|s| s.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(), + group: m.get("group").and_then(Value::as_str).map(str::to_string), + armed: flag(m, "armed"), + armed_by_group: flag(m, "armed_by_group"), + disarmed: flag(m, "disarmed"), + }) + .collect()) + } + + /// Group names only: the admin API lists identifiers, and inventing the rest would be + /// the drift `GET /fleet` exists to prevent. + pub fn groups(&self) -> Result, String> { + let body = self.get("/groups")?; + let v: Value = serde_json::from_str(&body).map_err(|e| format!("/groups: {e}"))?; + let rows = v + .get("group") + .and_then(Value::as_array) + .ok_or_else(|| "/groups: unexpected shape".to_string())?; + Ok(rows + .iter() + .filter_map(|g| g.as_str()) + .map(|name| crate::cli::fleet::Group { + name: name.to_string(), + format: String::new(), + origin: "over the admin API".to_string(), + members: Vec::new(), + matchers: Vec::new(), + extends: Vec::new(), + }) + .collect()) + } + + pub fn problems(&self) -> Result, String> { + let body = self.get("/check")?; + let v: Value = serde_json::from_str(&body).map_err(|e| format!("/check: {e}"))?; + Ok(v.get("problems") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|s| s.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default()) + } + + pub fn describe(&self) -> String { + format!("{} (remote, read-only)", self.base) + } +} + +fn string(v: &Value, key: &str) -> String { + v.get(key) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() +} + +fn flag(v: &Value, key: &str) -> bool { + v.get(key).and_then(Value::as_bool).unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_url_must_be_the_admin_listener_and_the_token_must_exist() { + assert!(Remote::new("nas:9000", "x".repeat(16).as_str()).is_err()); + let e = Remote::new("http://nas:9000", " ").expect_err("no token"); + assert!(e.contains("RESCRIPTUM_ADMIN_TOKEN"), "{e}"); + assert!(Remote::new("http://nas:9000/", "token").is_ok()); + } + + /// The remote model must read back what the local one prints, or the two views are + /// two programs. `tests/admin.rs` asserts the endpoint is byte-identical to + /// `machines --json`; this asserts this end of it. + #[test] + fn the_fleet_payload_parses_into_the_same_shape_as_the_local_model() { + let body = r#"{"machines":[ + {"id":"98fa9b50d810","formats":["ipxe","toml"],"group":"rack-a", + "armed":true,"armed_by_group":false,"disarmed":false}, + {"id":"aabbccddeeff","formats":[],"group":null, + "armed":true,"armed_by_group":true,"disarmed":false} + ]}"#; + let v: serde_json::Value = serde_json::from_str(body).expect("json"); + let rows = v["machines"].as_array().expect("rows"); + assert_eq!(rows.len(), 2); + assert_eq!(string(&rows[0], "id"), "98fa9b50d810"); + assert!(flag(&rows[0], "armed")); + assert!(!flag(&rows[0], "armed_by_group")); + // The one that cannot disarm itself has to survive the wire, because that is the + // state an operator most needs to see. + assert!(flag(&rows[1], "armed_by_group")); + assert!(rows[1].get("group").expect("key").is_null()); + } + + /// A `{:?}` in a log line or a panic message is exactly how a credential escapes. + #[test] + fn debug_never_prints_the_token() { + let r = Remote::new("http://nas:9000", "s3cr3t-admin-token").expect("valid"); + let shown = format!("{r:?}"); + assert!(!shown.contains("s3cr3t"), "{shown}"); + assert!(shown.contains("nas:9000"), "{shown}"); + } + + #[test] + fn a_missing_field_is_a_default_rather_than_a_panic() { + let v: serde_json::Value = serde_json::from_str(r#"{"id":"x"}"#).expect("json"); + assert_eq!(string(&v, "nothing"), ""); + assert!(!flag(&v, "nothing")); + } +} diff --git a/tests/admin.rs b/tests/admin.rs index 373da21..bc693d2 100644 --- a/tests/admin.rs +++ b/tests/admin.rs @@ -712,3 +712,53 @@ fn binding_the_admin_api_beyond_loopback_is_said_out_loud() { let log = s.startup_log(); assert!(log.contains("not bound to loopback"), "{log}"); } + +/// **One producer, two renderings.** `GET /fleet` must be byte-identical to what +/// `machines --json` prints, because the alternative is a remote view that drifts from the +/// local one — and the only way to notice would be an operator seeing two different +/// answers to the same question. +#[test] +fn the_fleet_endpoint_is_byte_identical_to_the_command() { + let s = Server::start(); + assert_eq!( + status(&s.admin( + "PUT", + "/machines/98fa9b50d810", + "[global]\nkeyboard = \"fr\"\n" + )), + 200 + ); + + let response = s.admin("GET", "/fleet", ""); + let over_http = response + .split_once("\r\n\r\n") + .map_or("", |(_, b)| b) + .to_string(); + let from_cli = std::process::Command::new(env!("CARGO_BIN_EXE_rescriptum")) + .env("RESCRIPTUM_STORE", "sqlite") + .env("RESCRIPTUM_DB_PATH", s.dir.join("answers.db")) + .env("RESCRIPTUM_TFTP_ADDR", "off") + .args(["machines", "--json"]) + .output() + .expect("run machines --json"); + let from_cli = String::from_utf8_lossy(&from_cli.stdout).trim().to_string(); + + assert_eq!( + over_http.trim(), + from_cli, + "the two renderings have drifted" + ); +} + +/// It is behind the token like everything else — this API decides what gets installed on +/// every machine, and a fleet listing is reconnaissance. +#[test] +fn the_fleet_endpoint_is_not_readable_without_the_token() { + let s = Server::start(); + let r = Server::raw( + &s.admin_addr, + "GET /fleet HTTP/1.1\r\nHost: admin\r\n\r\n", + &[], + ); + assert_eq!(status(&r), 401, "{r}"); +} diff --git a/tests/cli.rs b/tests/cli.rs index f00c3c6..c3cd24c 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -187,7 +187,11 @@ fn render_with_a_path_is_constrained_the_way_the_real_url_would_be() { let ks = c.run(&["render", "--query", "path=/rhel/ks&mac=98:fa:9b:50:d8:10"]); assert!(ks.ok, "{ks}"); assert!(ks.stdout.contains("lang fr_FR"), "{ks}"); - assert!(ks.stderr.contains("format=text"), "{ks}"); + // The **extension**, not the family. This used to read `format=text`, which made + // `.ks`, `.preseed`, `.cfg`, `.seed` and `.ipxe` indistinguishable in the log — and a + // log that cannot tell a boot script from an answer document cannot say which + // machines are mid-install. + assert!(ks.stderr.contains("format=ks"), "{ks}"); let toml = c.run(&[ "render", @@ -1668,3 +1672,536 @@ fn migrate_refuses_the_whole_run_when_a_destination_is_taken() { "an unrelated document moved during a run that failed" ); } + +// ---- power --------------------------------------------------------------- + +/// A controllers file, written `0600` the way the parser insists on, and **outside the +/// answers directory** — it is a `.toml`, so dropped inside one it would be a misplaced +/// answer document, exactly as a configuration file is. `Case::etc` is that place. +#[cfg(unix)] +fn controllers(case: &Case, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let dir = case.etc(); + fs::create_dir_all(&dir).expect("scratch etc"); + let path = dir.join("controllers.toml"); + fs::write(&path, body).expect("write controllers"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("chmod"); + path +} + +#[cfg(unix)] +const TWO_CONTROLLERS: &str = r#" +["98-fa-9b-50-d8-10"] +kind = "redfish" +url = "https://10.0.0.51" +user = "root" +pass = "calvin" +verify = false + +["aa-bb-cc-dd-ee-ff"] +kind = "command" +on = ["/usr/local/bin/pdu", "outlet", "7", "on"] +off = ["/usr/local/bin/pdu", "outlet", "7", "off"] +"#; + +/// Unset means the feature does not exist, the way `RESCRIPTUM_MEDIA_DIR` works. +#[test] +fn power_without_a_controllers_file_says_which_variable_names_one() { + let c = Case::new(&[("98fa9b50d810.toml", "[global]\nkeyboard = \"fr\"\n")]); + let r = c.run(&["power", "list"]); + assert!(!r.ok, "{r}"); + assert!(r.stderr.contains("RESCRIPTUM_CONTROLLERS_FILE"), "{r}"); +} + +/// **Both sides of the join**, and the machine/group distinction on the answer side — +/// which is what `install` will refuse on, so it had better be visible before then. +#[cfg(unix)] +#[test] +fn power_list_joins_controllers_to_the_answer_set() { + let c = Case::new(&[ + ("98fa9b50d810.toml", "[global]\nkeyboard = \"fr\"\n"), + ( + "groups/rack-a.toml", + "members = [\"11:22:33:44:55:66\"]\n[global]\nkeyboard = \"us\"\n", + ), + ]); + let file = controllers( + &c, + &format!("{TWO_CONTROLLERS}\n[\"11-22-33-44-55-66\"]\nkind = \"command\"\non = [\"x\"]\n"), + ); + let r = c.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", c.dir.as_path()), + ("RESCRIPTUM_CONTROLLERS_FILE", file.as_path()), + ], + &["power", "list"], + ); + + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("98-fa-9b-50-d8-10"), "{r}"); + assert!(r.stdout.contains("answered by its own document"), "{r}"); + // A controller for a machine nothing answers is not an error — you may be able to + // power a machine you have no answer for. + assert!(r.stdout.contains("nothing answers it"), "{r}"); + // And one answered only by its group, which is the case that cannot disarm itself. + assert!(r.stdout.contains("answered by a group"), "{r}"); + assert!(r.stdout.contains("3 controller(s)"), "{r}"); +} + +/// Said every time, because it is the thing somebody meant to fix later and did not. +#[cfg(unix)] +#[test] +fn power_list_says_out_loud_which_controllers_are_unverified() { + let c = Case::new(&[("98fa9b50d810.toml", "[global]\nkeyboard = \"fr\"\n")]); + let file = controllers(&c, TWO_CONTROLLERS); + let r = c.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", c.dir.as_path()), + ("RESCRIPTUM_CONTROLLERS_FILE", file.as_path()), + ], + &["power", "list"], + ); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("verify = false"), "{r}"); + // A controller that cannot arm a one-time boot is described, not treated as broken: + // the boot order stays on PXE and the server decides. + assert!(r.stdout.contains("the server decides"), "{r}"); +} + +/// Refused at use, not warned about — unlike the env file, where refusing would stop an +/// otherwise healthy server. +#[cfg(unix)] +#[test] +fn power_refuses_a_controllers_file_others_can_read() { + use std::os::unix::fs::PermissionsExt; + let c = Case::new(&[("98fa9b50d810.toml", "[global]\nkeyboard = \"fr\"\n")]); + let file = controllers(&c, TWO_CONTROLLERS); + fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).expect("chmod"); + + let r = c.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", c.dir.as_path()), + ("RESCRIPTUM_CONTROLLERS_FILE", file.as_path()), + ], + &["power", "list"], + ); + assert!(!r.ok, "{r}"); + assert!(r.stderr.contains("chmod 600"), "{r}"); +} + +/// The server must not read this file, so a broken one cannot stop it answering — that is +/// the whole reason it is not a startup error. +#[cfg(unix)] +#[test] +fn a_broken_controllers_file_does_not_stop_the_other_commands() { + let c = Case::new(&[("98fa9b50d810.toml", "[global]\nkeyboard = \"fr\"\n")]); + let file = controllers(&c, "this is not toml at all"); + let r = c.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", c.dir.as_path()), + ("RESCRIPTUM_CONTROLLERS_FILE", file.as_path()), + ], + &["check"], + ); + assert!(r.ok, "check must not care about the controllers file\n{r}"); + + // But the command that does read it says why, naming the line. + let r = c.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", c.dir.as_path()), + ("RESCRIPTUM_CONTROLLERS_FILE", file.as_path()), + ], + &["power", "list"], + ); + assert!(!r.ok, "{r}"); +} + +/// The same rule the configuration file has, for the same reason: this format shares the +/// `.toml` extension with an answer document, so a controllers file dropped at the top of +/// the answers directory is a misplaced answer and is reported rather than served. +#[cfg(unix)] +#[test] +fn a_controllers_file_inside_the_answers_directory_is_reported_as_a_stray_answer() { + let c = Case::new(&[("98fa9b50d810.toml", "[global]\nkeyboard = \"fr\"\n")]); + let stray = c.dir.join("controllers.toml"); + fs::write(&stray, TWO_CONTROLLERS).expect("write"); + + let r = c.run(&["check"]); + assert!(!r.ok, "a stray .toml must fail check\n{r}"); + assert!(r.stdout.contains("controllers.toml"), "{r}"); +} + +/// Resolved rather than hard-coded: `/bin/true` exists on Linux and not on macOS. A +/// hard-coded path here is the "passed locally, failed in CI" trap this repository has +/// already been caught by once. +#[cfg(unix)] +fn tool(name: &str) -> String { + ["/usr/bin", "/bin"] + .iter() + .map(|dir| format!("{dir}/{name}")) + .find(|p| Path::new(p).is_file()) + .unwrap_or_else(|| panic!("no {name} on this system")) +} + +#[cfg(unix)] +fn power_case() -> (Case, PathBuf) { + let c = Case::new(&[("aabbccddeeff.toml", "[global]\nkeyboard = \"fr\"\n")]); + let file = controllers( + &c, + &format!( + "[\"aa-bb-cc-dd-ee-ff\"]\nkind = \"command\"\non = [\"{}\"]\noff = [\"{}\"]\n", + tool("true"), + tool("false") + ), + ); + (c, file) +} + +#[cfg(unix)] +fn run_power(c: &Case, file: &Path, args: &[&str]) -> Run { + c.run_env( + &[ + ("RESCRIPTUM_ANSWERS_DIR", c.dir.as_path()), + ("RESCRIPTUM_CONTROLLERS_FILE", file), + ], + args, + ) +} + +/// A controller that presses a button has no way to know, and saying "off" would be an +/// invention an operator would act on. +#[cfg(unix)] +#[test] +fn power_status_of_a_command_controller_is_unknown_rather_than_guessed() { + let (c, file) = power_case(); + let r = run_power(&c, &file, &["power", "status", "aa:bb:cc:dd:ee:ff"]); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("unknown"), "{r}"); +} + +#[cfg(unix)] +#[test] +fn power_on_runs_the_hook_and_power_off_reports_its_exit_code() { + let (c, file) = power_case(); + let on = run_power(&c, &file, &["power", "on", "aa:bb:cc:dd:ee:ff"]); + assert!(on.ok, "{on}"); + assert!(on.stdout.contains("power on sent"), "{on}"); + + // The fixture's `off` is `false`, so a failing hook must fail the command rather than + // being reported as done. + let off = run_power(&c, &file, &["power", "off", "aa:bb:cc:dd:ee:ff"]); + assert!(!off.ok, "a failing hook must not report success\n{off}"); + assert!(off.stderr.contains("exited 1"), "{off}"); +} + +/// Not a failure: the boot order stays on the network and the server decides whether the +/// machine installs — which is what `RESCRIPTUM_BOOT_UNCLAIMED` already does. +#[cfg(unix)] +#[test] +fn power_pxe_on_a_controller_without_one_explains_rather_than_failing() { + let (c, file) = power_case(); + let r = run_power(&c, &file, &["power", "pxe", "aa:bb:cc:dd:ee:ff"]); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("no boot override"), "{r}"); + assert!(r.stdout.contains("let the server decide"), "{r}"); +} + +#[cfg(unix)] +#[test] +fn an_unknown_machine_is_told_what_is_configured_instead() { + let (c, file) = power_case(); + let r = run_power(&c, &file, &["power", "on", "11:22:33:44:55:66"]); + assert!(!r.ok, "{r}"); + assert!(r.stderr.contains("aa-bb-cc-dd-ee-ff"), "{r}"); +} + +/// `list` must not probe, `list --state` must — and must say it is going to, because a +/// rack of unreachable BMCs otherwise looks like a hang. +#[cfg(unix)] +#[test] +fn only_list_with_state_asks_the_controllers_anything() { + let (c, file) = power_case(); + let plain = run_power(&c, &file, &["power", "list"]); + assert!(plain.ok, "{plain}"); + assert!( + !plain.stderr.contains("asking"), + "a plain listing must not probe\n{plain}" + ); + + let probed = run_power(&c, &file, &["power", "list", "--state"]); + assert!(probed.ok, "{probed}"); + assert!(probed.stderr.contains("asking 1 controller"), "{probed}"); + assert!(probed.stdout.contains("unknown"), "{probed}"); +} + +// ---- install ------------------------------------------------------------- + +#[cfg(unix)] +const ARMED_MACHINE: &[(&str, &str)] = &[ + ("aabbccddeeff.toml", "[global]\nkeyboard = \"fr\"\n"), + ("aabbccddeeff.ipxe", "#!ipxe\nchain http://x/answer\n"), +]; + +#[cfg(unix)] +fn install_case(files: &[(&str, &str)]) -> (Case, PathBuf) { + let c = Case::new(files); + let file = controllers( + &c, + &format!( + "[\"aabbccddeeff\"]\nkind = \"command\"\non = [\"{}\"]\n", + tool("true") + ), + ); + (c, file) +} + +#[cfg(unix)] +fn run_install(c: &Case, file: &Path, env: &[(&str, &Path)], args: &[&str]) -> Run { + let mut all: Vec<(&str, &Path)> = vec![ + ("RESCRIPTUM_ANSWERS_DIR", c.dir.as_path()), + ("RESCRIPTUM_CONTROLLERS_FILE", file), + ]; + all.extend_from_slice(env); + c.run_env(&all, args) +} + +#[cfg(unix)] +#[test] +fn install_renders_every_format_before_anything_is_powered() { + let (c, file) = install_case(ARMED_MACHINE); + let r = run_install(&c, &file, &[], &["install", "aa:bb:cc:dd:ee:ff"]); + assert!(r.ok, "{r}"); + // Both documents, not a guess at which one the boot script leads to. + assert!(r.stdout.contains("ok toml"), "{r}"); + assert!(r.stdout.contains("ok ipxe"), "{r}"); + assert!(r.stdout.contains("installing"), "{r}"); +} + +/// Powering on a machine that boots into a broken answer leaves an installer sitting at a +/// prompt in a rack — the exact failure this project exists to prevent. +#[cfg(unix)] +#[test] +fn install_refuses_when_a_document_would_not_render() { + let (c, file) = install_case(&[ + // A machine whose answer names a group that does not exist. + ("aabbccddeeff.toml", "extends = \"nowhere\"\n"), + ("aabbccddeeff.ipxe", "#!ipxe\nchain http://x/answer\n"), + ]); + let r = run_install(&c, &file, &[], &["install", "aa:bb:cc:dd:ee:ff"]); + assert!(!r.ok, "{r}"); + assert!(r.stderr.contains("nothing has been powered on"), "{r}"); +} + +/// **The refusal the whole group-arming finding produced.** A machine armed only by its +/// group installs, reports success, is never disarmed, and installs again forever. +#[cfg(unix)] +#[test] +fn install_refuses_a_machine_armed_only_by_its_group() { + let (c, file) = install_case(&[ + ("aabbccddeeff.toml", "[global]\nkeyboard = \"fr\"\n"), + ( + "groups/rack-a.ipxe", + "# answer: member aa:bb:cc:dd:ee:ff\n#!ipxe\nchain http://x/answer\n", + ), + ]); + let r = run_install(&c, &file, &[], &["install", "aa:bb:cc:dd:ee:ff"]); + assert!(!r.ok, "{r}"); + assert!(r.stderr.contains("a group is never disarmed"), "{r}"); + assert!(r.stderr.contains("reinstall itself"), "{r}"); +} + +/// Refused in **both** unclaimed modes, for different reasons — one is dangerous and the +/// other is merely useless, and an operator should be told which. +#[cfg(unix)] +#[test] +fn install_refuses_an_unarmed_machine_differently_in_each_unclaimed_mode() { + let (c, file) = install_case(&[("aabbccddeeff.toml", "[global]\nkeyboard = \"fr\"\n")]); + + let menu = run_install(&c, &file, &[], &["install", "aa:bb:cc:dd:ee:ff"]); + assert!(!menu.ok, "{menu}"); + assert!(menu.stderr.contains("boot menu"), "{menu}"); + + let local = run_install( + &c, + &file, + &[("RESCRIPTUM_BOOT_UNCLAIMED", Path::new("local"))], + &["install", "aa:bb:cc:dd:ee:ff"], + ); + assert!(!local.ok, "{local}"); + assert!( + local + .stderr + .contains("looks exactly like a successful install"), + "the dangerous case must say so\n{local}" + ); +} + +/// The archive is reused rather than an image argument invented, so the operator's own +/// document comes back byte for byte — and `installed-` directories stop accumulating. +#[cfg(unix)] +#[test] +fn install_puts_back_what_a_previous_install_archived() { + let (c, file) = install_case(&[ + ("aabbccddeeff.toml", "[global]\nkeyboard = \"fr\"\n"), + ( + "installed-aabbccddeeff.ipxe", + "#!ipxe\n# the operator's own words\nchain http://x/answer\n", + ), + ]); + let r = run_install(&c, &file, &[], &["install", "aa:bb:cc:dd:ee:ff"]); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("disarmed by a previous install"), "{r}"); + assert!(r.stdout.contains("boot script is back"), "{r}"); + + // Byte for byte, and the archive is gone rather than left to accumulate. + let back = fs::read_to_string(c.dir.join("aabbccddeeff/boot.ipxe")).expect("restored"); + assert!(back.contains("the operator's own words"), "{back}"); + assert!(!c.dir.join("installed-aabbccddeeff").exists()); +} + +/// `--dry-run` proves the whole chain without touching the hardware, which is what makes +/// it safe to check a rack before powering any of it. +#[cfg(unix)] +#[test] +fn install_dry_run_changes_nothing() { + let (c, file) = install_case(&[ + ("aabbccddeeff.toml", "[global]\nkeyboard = \"fr\"\n"), + ("installed-aabbccddeeff.ipxe", "#!ipxe\nchain http://x/a\n"), + ]); + let r = run_install( + &c, + &file, + &[], + &["install", "aa:bb:cc:dd:ee:ff", "--dry-run"], + ); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("nothing was powered on"), "{r}"); + // The archive is still archived: a dry run must not arm anything either. + assert!(c.dir.join("installed-aabbccddeeff").exists()); +} + +/// A second armed machine, so the refusal is genuinely about the missing controller +/// rather than about the answer set — the checks run in that order on purpose, and a test +/// that cannot tell them apart proves the wrong one. +#[cfg(unix)] +#[test] +fn install_without_a_controller_says_so_rather_than_half_arming() { + let mut files = ARMED_MACHINE.to_vec(); + files.push(("112233445566.toml", "[global]\nkeyboard = \"fr\"\n")); + files.push(("112233445566.ipxe", "#!ipxe\nchain http://x/answer\n")); + let (c, file) = install_case(&files); + + let r = run_install(&c, &file, &[], &["install", "11:22:33:44:55:66"]); + assert!(!r.ok, "{r}"); + assert!(r.stderr.contains("no controller"), "{r}"); + // It got past the answer checks, which is what makes this about the controller. + assert!(r.stdout.contains("armed by its own document"), "{r}"); +} + +// ---- the read model ------------------------------------------------------ + +const FLEET: &[(&str, &str)] = &[ + ("98fa9b50d810.toml", "[global]\nkeyboard = \"fr\"\n"), + ("98fa9b50d810.ipxe", "#!ipxe\nchain http://x/a\n"), + ( + "groups/rack-a.toml", + "extends = \"base\"\nmembers = [\"11:22:33:44:55:66\"]\n[global]\nkeyboard = \"us\"\n", + ), + ("groups/base.toml", "[global]\ncountry = \"fr\"\n"), + ("installed-aabbccddeeff.ipxe", "#!ipxe\nchain http://x/a\n"), +]; + +#[test] +fn machines_lists_what_answers_each_one_and_how_it_is_armed() { + let c = Case::new(FLEET); + let r = c.run(&["machines"]); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("98fa9b50d810"), "{r}"); + assert!(r.stdout.contains("armed"), "{r}"); + // A machine a group only names has no documents of its own, and is still a machine. + assert!( + r.stdout.contains("11:22:33:44:55:66") || r.stdout.contains("112233445566"), + "{r}" + ); + // An archive is a *state* of the machine it names, not a machine of its own. + assert!(!r.stdout.contains("installed-"), "{r}"); + assert!(r.stdout.contains("disarmed by a previous install"), "{r}"); +} + +#[test] +fn groups_shows_the_extends_chain_and_what_each_one_claims() { + let c = Case::new(FLEET); + let r = c.run(&["groups"]); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("extends base"), "{r}"); + assert!(r.stdout.contains("1 member(s)"), "{r}"); +} + +#[test] +fn status_counts_the_fleet_and_names_the_group_armed_ones() { + let c = Case::new(&[ + ( + "groups/rack-a.ipxe", + "# answer: member aa:bb:cc:dd:ee:ff\n#!ipxe\nchain http://x/a\n", + ), + ( + "groups/rack-a.toml", + "members = [\"aa:bb:cc:dd:ee:ff\"]\n[g]\nx = 1\n", + ), + ]); + let r = c.run(&["status"]); + assert!(r.ok, "{r}"); + assert!(r.stdout.contains("armed by a group"), "{r}"); + assert!(r.stdout.contains("cannot disarm"), "{r}"); +} + +/// Zero problems is the normal state, so a non-zero exit here would cry wolf. `check` is +/// what keys an exit code on the answer set. +#[test] +fn status_succeeds_even_when_the_answer_set_has_problems() { + let c = Case::new(&[("98fa9b50d810.toml", "extends = \"nowhere\"\n")]); + let r = c.run(&["status"]); + assert!(r.ok, "status must report, not judge\n{r}"); + assert!(r.stdout.contains("problem:"), "{r}"); + // And `check` still fails on the same set, which is the one that gates a deploy. + assert!(!c.run(&["check"]).ok); +} + +#[test] +fn the_json_form_is_parseable_and_says_the_same_thing() { + let c = Case::new(FLEET); + for (args, key) in [ + (["machines", "--json"], "machines"), + (["groups", "--json"], "groups"), + (["status", "--json"], "armed"), + ] { + let r = c.run(&args); + assert!(r.ok, "{r}"); + let v: serde_json::Value = + serde_json::from_str(r.stdout.trim()).unwrap_or_else(|e| panic!("{args:?}: {e}\n{r}")); + assert!(v.get(key).is_some(), "{args:?} has no {key}: {r}"); + } +} + +/// A machine holding two formats is the case the layout exists for, so asking for one of +/// them should not be a trick. +#[test] +fn render_can_be_asked_for_one_named_format() { + let c = Case::new(&[ + ("98fa9b50d810.toml", "[global]\nkeyboard = \"fr\"\n"), + ( + "98fa9b50d810.preseed", + "d-i debian-installer/locale string fr_FR\n", + ), + ]); + let toml = c.run(&["render", "98:fa:9b:50:d8:10", "--format", "toml"]); + assert!(toml.ok, "{toml}"); + assert!(toml.stdout.contains("keyboard"), "{toml}"); + + let preseed = c.run(&["render", "98:fa:9b:50:d8:10", "--format", "preseed"]); + assert!(preseed.ok, "{preseed}"); + assert!(preseed.stdout.contains("debian-installer"), "{preseed}"); + + let nonsense = c.run(&["render", "98:fa:9b:50:d8:10", "--format", "txt"]); + assert!(!nonsense.ok, "txt is deliberately not a format\n{nonsense}"); +} diff --git a/tests/integration.rs b/tests/integration.rs index 2079f96..69b73e4 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1453,3 +1453,38 @@ fn a_kickstart_or_a_preseed_can_report_installed_with_one_curl() { ); assert!(response.starts_with("HTTP/1.1 401"), "{response}"); } + +/// **The single most valuable thing a dashboard could show** — *these machines are asking +/// and I have no answer for them* — was underivable, because a 404 named no machine. For +/// a GET the identity is in the target already; for a Proxmox POST it is only in the body, +/// and the body is not logged. +/// +/// Logging, not instrumentation: no counter, no state, one `format!` on a path that is +/// already failing. +#[test] +fn a_404_names_the_machine_that_asked_even_when_it_only_said_so_in_the_body() { + let s = Server::start(&[("98fa9b50d810.toml", "marker = \"x\"\n")]); + // A body the shape Proxmox sends, for a machine nothing answers. + let body = r#"{"network_interfaces":[{"name":"eno1","mac":"11:22:33:44:55:66"}]}"#; + let r = s.post(body); + assert!(status_line(&r).starts_with("HTTP/1.1 404"), "{r}"); + + // stderr is drained by a background thread, so poll rather than sleeping once — a + // slow machine must not make this flaky and a fast one must not make it slow. + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + let logged = s.startup_log(); + if let Some(line) = logged.lines().find(|l| l.contains("404")) { + assert!( + line.contains("11:22:33:44:55:66"), + "the 404 must name who asked: {line}" + ); + return; + } + assert!( + std::time::Instant::now() < deadline, + "no 404 line appeared in:\n{logged}" + ); + std::thread::sleep(Duration::from_millis(50)); + } +} diff --git a/tests/power.rs b/tests/power.rs new file mode 100644 index 0000000..7cefa1c --- /dev/null +++ b/tests/power.rs @@ -0,0 +1,492 @@ +//! The Redfish client, against a Redfish service that actually answers. +//! +//! There is no BMC in CI and no PiKVM either, so the service here is a few hundred lines +//! of `TcpListener` shaped like the real ones — and crucially the **real `curl`** is what +//! talks to it. That is the half worth testing without hardware: the option file, the +//! quoting, the status/body split, the etag round trip, and what a timeout is reported as. +//! Everything a vendor's own firmware decides still has to be confirmed on a board. +//! +//! Plain HTTP, because TLS is exactly the part curl is trusted with rather than tested +//! here. + +use rescriptum::controllers::{DEFAULT_BASE, Redfish, Tls}; +use rescriptum::redfish::{Client, Failed}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +/// How the fake service should behave for one test. +#[derive(Clone)] +struct Behaviour { + /// Members of `/Systems`, as `@odata.id` values. + members: Vec, + /// Sent on the system resource, when set. + etag: Option, + /// What `Boot` reports. PiKVM answers a PATCH 204 and leaves this at Disabled. + boot: Arc)>>, + /// Whether a PATCH actually changes anything — false is PiKVM's behaviour. + patch_takes_effect: bool, + /// Answer nothing at all, so curl hits its deadline. + stall: bool, + /// Fail the reset with this vendor sentence. + reset_error: Option, +} + +impl Default for Behaviour { + fn default() -> Behaviour { + Behaviour { + members: vec!["/redfish/v1/Systems/System.Embedded.1".to_string()], + etag: None, + boot: Arc::new(Mutex::new(("Disabled".to_string(), None))), + patch_takes_effect: true, + stall: false, + reset_error: None, + } + } +} + +struct Service { + url: String, + behaviour: Behaviour, + /// Every `If-Match` the service was sent, so a test can assert one arrived. + if_match: Arc>>, + resets: Arc>>, +} + +impl Service { + fn start(behaviour: Behaviour) -> Service { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + let if_match = Arc::new(Mutex::new(Vec::new())); + let resets = Arc::new(Mutex::new(Vec::new())); + + let b = behaviour.clone(); + let seen = Arc::clone(&if_match); + let got = Arc::clone(&resets); + thread::spawn(move || { + for stream in listener.incoming().flatten() { + let b = b.clone(); + let seen = Arc::clone(&seen); + let got = Arc::clone(&got); + thread::spawn(move || serve(stream, &b, &seen, &got)); + } + }); + + Service { + url: format!("http://127.0.0.1:{port}"), + behaviour, + if_match, + resets, + } + } + + fn controller(&self) -> Redfish { + Redfish { + url: self.url.clone(), + base: DEFAULT_BASE.to_string(), + user: "root".to_string(), + // A password carrying both characters curl's config syntax cares about, in + // every test rather than only in one — if the escaping breaks, everything + // here starts answering 401 and says so. + pass: r#"a"b\c"#.to_string(), + system: None, + tls: Tls::Insecure, + } + } +} + +fn serve( + mut stream: TcpStream, + b: &Behaviour, + if_match: &Mutex>, + resets: &Mutex>, +) { + let mut reader = BufReader::new(stream.try_clone().expect("clone")); + let mut request = String::new(); + if reader.read_line(&mut request).is_err() { + return; + } + let mut parts = request.split_whitespace(); + let method = parts.next().unwrap_or("").to_string(); + let path = parts.next().unwrap_or("").to_string(); + + let mut length = 0usize; + let mut authorized = false; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() { + break; + } + if let Some((k, v)) = line.split_once(':') { + let (k, v) = (k.trim().to_ascii_lowercase(), v.trim().to_string()); + match k.as_str() { + "content-length" => length = v.parse().unwrap_or(0), + "if-match" => if_match.lock().expect("lock").push(v), + // `root` with the password above, base64. Checked rather than ignored, so + // that a quoting bug in the option file shows up as a 401 here instead of + // passing silently. + "authorization" => authorized = v == format!("Basic {}", basic()), + _ => {} + } + } + } + + let mut body = vec![0u8; length]; + if length > 0 && reader.read_exact(&mut body).is_err() { + return; + } + let body = String::from_utf8_lossy(&body).into_owned(); + + if b.stall { + // Accept, say nothing, and let curl's deadline decide. This is a BMC that is + // reachable and wedged, which is an ordinary state. + thread::sleep(Duration::from_secs(30)); + return; + } + + if !authorized { + let _ = reply( + &mut stream, + 401, + None, + r#"{"error":{"message":"bad credential"}}"#, + ); + return; + } + + let systems = format!("{DEFAULT_BASE}/Systems"); + if method == "GET" && path == systems { + let members: Vec = b + .members + .iter() + .map(|m| format!(r#"{{"@odata.id":"{m}"}}"#)) + .collect(); + let payload = format!( + r#"{{"Members":[{}],"Members@odata.count":{}}}"#, + members.join(","), + members.len() + ); + let _ = reply(&mut stream, 200, None, &payload); + return; + } + + let reset_suffix = "/Actions/ComputerSystem.Reset"; + if method == "POST" && path.ends_with(reset_suffix) { + let kind = serde_json::from_str::(&body) + .ok() + .and_then(|v| v.get("ResetType")?.as_str().map(str::to_string)) + .unwrap_or_default(); + resets.lock().expect("lock").push(kind); + match &b.reset_error { + Some(message) => { + let payload = format!( + r#"{{"error":{{"@Message.ExtendedInfo":[{{"Message":"{message}"}}]}}}}"# + ); + let _ = reply(&mut stream, 400, None, &payload); + } + None => { + let _ = reply(&mut stream, 204, None, ""); + } + } + return; + } + + if path.starts_with(&systems) { + if method == "PATCH" { + if b.patch_takes_effect + && let Ok(v) = serde_json::from_str::(&body) + && let Some(boot) = v.get("Boot") + { + let mut held = b.boot.lock().expect("lock"); + if let Some(e) = boot + .get("BootSourceOverrideEnabled") + .and_then(|s| s.as_str()) + { + held.0 = e.to_string(); + } + held.1 = boot + .get("BootSourceOverrideTarget") + .and_then(|s| s.as_str()) + .map(str::to_string); + } + // 204 whether or not anything changed — which is exactly PiKVM's behaviour, + // and the reason the client reads the state back. + let _ = reply(&mut stream, 204, None, ""); + return; + } + + let (enabled, target) = b.boot.lock().expect("lock").clone(); + let target = match target { + Some(t) => format!("\"{t}\""), + None => "null".to_string(), + }; + let payload = format!( + r##"{{"PowerState":"Off", + "Actions":{{"#ComputerSystem.Reset":{{ + "ResetType@Redfish.AllowableValues":["On","ForceOff","ForceRestart"]}}}}, + "Boot":{{"BootSourceOverrideEnabled":"{enabled}","BootSourceOverrideTarget":{target}}}}}"## + ); + let _ = reply(&mut stream, 200, b.etag.as_deref(), &payload); + return; + } + + let _ = reply( + &mut stream, + 404, + None, + r#"{"error":{"message":"no such resource"}}"#, + ); +} + +fn basic() -> String { + // Base64 of `root:a"b\c`, hand-rolled rather than pulling a crate in for six bytes. + const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let input = br#"root:a"b\c"#; + let mut out = String::new(); + for chunk in input.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + for i in 0..4 { + if i <= chunk.len() { + out.push(TABLE[((n >> (18 - 6 * i)) & 0x3f) as usize] as char); + } else { + out.push('='); + } + } + } + out +} + +fn reply( + stream: &mut TcpStream, + status: u16, + etag: Option<&str>, + body: &str, +) -> std::io::Result<()> { + let mut head = format!( + "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n", + body.len() + ); + if let Some(tag) = etag { + head.push_str(&format!("ETag: {tag}\r\n")); + } + head.push_str("\r\n"); + stream.write_all(head.as_bytes())?; + stream.write_all(body.as_bytes())?; + stream.flush() +} + +fn scratch() -> usize { + static N: AtomicUsize = AtomicUsize::new(0); + N.fetch_add(1, Ordering::Relaxed) +} + +// ---- discovery ------------------------------------------------------------ + +#[test] +fn one_system_is_discovered_and_named_by_its_last_segment() { + let s = Service::start(Behaviour::default()); + let c = s.controller(); + let id = Client::new(&c).system_id().expect("discovery"); + // Not the path out of the body: the id, which is what `/Systems/` is built + // from. PiKVM emits `/redfish/v1/...` even when mounted at `/api/redfish/v1`. + assert_eq!(id, "System.Embedded.1"); + let _ = scratch(); +} + +/// A blade chassis, a Dell FX2, and a PiKVM with a switch all do this. Picking the first +/// would power somebody else's machine. +#[test] +fn several_systems_are_refused_by_name_rather_than_guessed_between() { + let s = Service::start(Behaviour { + members: vec![ + "/redfish/v1/Systems/0".to_string(), + "/redfish/v1/Systems/SwitchPort0".to_string(), + ], + ..Default::default() + }); + let c = s.controller(); + let e = Client::new(&c).system_id().expect_err("must refuse"); + let said = e.to_string(); + assert!(said.contains('0'), "{said}"); + assert!(said.contains("SwitchPort0"), "{said}"); + assert!( + said.contains("system ="), + "it must say how to resolve this: {said}" + ); +} + +#[test] +fn an_explicit_system_skips_discovery_entirely() { + let s = Service::start(Behaviour { + members: vec!["/redfish/v1/Systems/0".to_string(), "/x/1".to_string()], + ..Default::default() + }); + let mut c = s.controller(); + c.system = Some("SwitchPort3".to_string()); + assert_eq!( + Client::new(&c).system_id().expect("explicit"), + "SwitchPort3" + ); +} + +// ---- credentials ---------------------------------------------------------- + +/// The service checks Basic auth against a password carrying `"` and `\`. If the option +/// file's quoting were wrong, every test in this file would 401. +#[test] +fn a_password_with_a_quote_and_a_backslash_authenticates() { + let s = Service::start(Behaviour::default()); + let c = s.controller(); + assert!(Client::new(&c).system_id().is_ok()); + + let mut wrong = s.controller(); + wrong.pass = "not-it".to_string(); + let e = Client::new(&wrong).system_id().expect_err("must fail"); + assert!(e.to_string().contains("401"), "{e}"); +} + +// ---- errors --------------------------------------------------------------- + +/// `--fail` would have thrown this away, and the operator would be reading a packet +/// capture instead of a sentence. +#[test] +fn a_vendors_error_sentence_reaches_the_caller() { + let s = Service::start(Behaviour { + reset_error: Some("The value 'Nope' is not in the list of acceptable values.".to_string()), + ..Default::default() + }); + let c = s.controller(); + let e = Client::new(&c) + .reset("System.Embedded.1", "ForceOff") + .expect_err("must fail"); + let said = e.to_string(); + assert!(said.contains("HTTP 400"), "{said}"); + assert!(said.contains("acceptable values"), "{said}"); +} + +/// Naming the ones that would work beats reporting the 400 that a wrong one earns. +#[test] +fn a_reset_the_system_does_not_offer_is_refused_before_it_is_sent() { + let s = Service::start(Behaviour::default()); + let c = s.controller(); + let e = Client::new(&c) + .reset("System.Embedded.1", "GracefulShutdown") + .expect_err("must refuse"); + let said = e.to_string(); + assert!( + said.contains("ForceOff"), + "it must name what is offered: {said}" + ); + assert!( + s.resets.lock().expect("lock").is_empty(), + "nothing should have been sent" + ); +} + +#[test] +fn a_reset_the_system_offers_is_sent_as_written() { + let s = Service::start(Behaviour::default()); + let c = s.controller(); + Client::new(&c) + .reset("System.Embedded.1", "On") + .expect("reset"); + assert_eq!(*s.resets.lock().expect("lock"), ["On"]); +} + +/// A deadline says when to stop waiting, not what happened. This is the one message that +/// must not imply the request did nothing. +#[test] +fn a_stalled_service_is_reported_as_an_unknown_outcome() { + let s = Service::start(Behaviour { + stall: true, + ..Default::default() + }); + let c = s.controller(); + let e = Client::new(&c) + .with_timeout(Duration::from_secs(1)) + .system_id() + .expect_err("must time out"); + assert!(matches!(e, Failed::Unknown(_)), "{e:?}"); + let said = e.to_string(); + assert!(said.contains("outcome is unknown"), "{said}"); + assert!(said.contains("read the state back"), "{said}"); +} + +// ---- the boot override ---------------------------------------------------- + +#[test] +fn an_etag_is_read_from_the_system_and_sent_back_as_if_match() { + // iLO and several iDRAC builds answer a PATCH without one with 412. + let s = Service::start(Behaviour { + etag: Some("W/\"abc123\"".to_string()), + ..Default::default() + }); + let c = s.controller(); + assert!( + Client::new(&c) + .set_pxe_once("System.Embedded.1") + .expect("patch"), + "the override should have taken" + ); + let seen = s.if_match.lock().expect("lock").clone(); + assert_eq!( + seen, + ["W/\"abc123\""], + "the etag must come back as If-Match" + ); +} + +#[test] +fn no_etag_means_no_if_match_header_rather_than_a_wildcard() { + // `If-Match: *` is not universally accepted. + let s = Service::start(Behaviour::default()); + let c = s.controller(); + Client::new(&c) + .set_pxe_once("System.Embedded.1") + .expect("patch"); + assert!(s.if_match.lock().expect("lock").is_empty()); +} + +/// **The find that makes the read-back mandatory.** PiKVM's PATCH handler returns 204 and +/// does nothing at all, while reporting the override as Disabled. A client that trusts the +/// status code believes it armed a boot that will never happen — and the machine then +/// installs nothing while looking correct. +#[test] +fn a_patch_that_answers_204_and_does_nothing_is_caught_by_reading_it_back() { + let s = Service::start(Behaviour { + patch_takes_effect: false, + ..Default::default() + }); + let c = s.controller(); + let armed = Client::new(&c) + .set_pxe_once("System.Embedded.1") + .expect("the PATCH itself succeeds"); + assert!( + !armed, + "204 must not be taken as proof: the service still reports the override disabled" + ); +} + +#[test] +fn the_override_is_once_and_never_continuous() { + // An override consumed at the next boot means a machine that fails to install and + // reboots comes up on its own disk rather than installing again. + let s = Service::start(Behaviour::default()); + let c = s.controller(); + assert!( + Client::new(&c) + .set_pxe_once("System.Embedded.1") + .expect("patch") + ); + let (enabled, target) = s.behaviour.boot.lock().expect("lock").clone(); + assert_eq!(enabled, "Once"); + assert_eq!(target.as_deref(), Some("Pxe")); +}