kanit

Toy init system
Log | Files | Refs | README | LICENSE

commit 53d9686cfa45d9f19d14a4f86ed7922b92b40098
Author: Sylvia Ivory <git@sivory.net>
Date:   Sun,  7 Jan 2024 19:23:42 -0800

Initial Commit

Diffstat:
A.cargo/config.toml | 2++
A.gitignore | 8++++++++
A.vscode/settings.json | 6++++++
A.woodpecker/test.yml | 28++++++++++++++++++++++++++++
ACargo.lock | 745+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACargo.toml | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
ALICENSE | 21+++++++++++++++++++++
Acrates/cli/Cargo.toml | 20++++++++++++++++++++
Acrates/cli/src/blame.rs | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/cli/src/flags.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/cli/src/lib.rs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/cli/src/main.rs | 3+++
Acrates/cli/src/service/disable.rs | 19+++++++++++++++++++
Acrates/cli/src/service/enable.rs | 24++++++++++++++++++++++++
Acrates/cli/src/service/list.rs | 42++++++++++++++++++++++++++++++++++++++++++
Acrates/cli/src/service/mod.rs | 7+++++++
Acrates/cli/src/teardown.rs | 28++++++++++++++++++++++++++++
Acrates/common/Cargo.toml | 4++++
Acrates/common/src/constants.rs | 9+++++++++
Acrates/common/src/error.rs | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/common/src/lib.rs | 2++
Acrates/diagnostics/Cargo.toml | 19+++++++++++++++++++
Acrates/diagnostics/src/lib.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Acrates/diagnostics/src/logger.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/diagnostics/src/scope.rs | 9+++++++++
Acrates/diagnostics/src/tap.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/diagnostics/src/timing.rs | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/executor/Cargo.toml | 16++++++++++++++++
Acrates/executor/src/lib.rs | 33+++++++++++++++++++++++++++++++++
Acrates/init/Cargo.toml | 48++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/init/src/baked_rc.rs | 20++++++++++++++++++++
Acrates/init/src/bsod.rs | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/init/src/ev_loop.rs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/init/src/lib.rs | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/init/src/main.rs | 5+++++
Acrates/init/src/rc.rs | 42++++++++++++++++++++++++++++++++++++++++++
Acrates/rc/Cargo.toml | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/rc/src/control.rs | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/rc/src/event.rs | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/rc/src/lib.rs | 14++++++++++++++
Acrates/rc/src/loader/mod.rs | 389+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/rc/src/loader/sort.rs | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/supervisor/Cargo.toml | 21+++++++++++++++++++++
Acrates/supervisor/src/builder.rs | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/supervisor/src/cli.rs | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/supervisor/src/flags.rs | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/supervisor/src/lib.rs | 11+++++++++++
Acrates/supervisor/src/main.rs | 12++++++++++++
Acrates/supervisor/src/supervisor.rs | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/unit/Cargo.toml | 35+++++++++++++++++++++++++++++++++++
Acrates/unit/src/dependencies.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/unit/src/formats/config.rs | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/unit/src/formats/grouping.rs | 280+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/unit/src/formats/mod.rs | 8++++++++
Acrates/unit/src/formats/testing.rs | 25+++++++++++++++++++++++++
Acrates/unit/src/formats/unit.rs | 397+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/unit/src/lib.rs | 6++++++
Acrates/unit/src/unit.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/Cargo.toml | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/lib.rs | 6++++++
Acrates/units/src/loader.rs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/mounts.rs | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/cgroup.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/clock.rs | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/devfs.rs | 240+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/hostname.rs | 32++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/hwdrivers.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/localmount.rs | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/mdev.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/mod.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/modules.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/procfs.rs | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/rootfs.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/run.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/seed.rs | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/swap.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/sysctl.rs | 40++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/oneshot/sysfs.rs | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/services/getty.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/units/src/services/mod.rs | 5+++++
Acrates/units/src/services/syslog.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/async.md | 17+++++++++++++++++
Adocs/man/kanit-unit.5 | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/man/kanit.8 | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/unit.md | 32++++++++++++++++++++++++++++++++
Ajustfile | 25+++++++++++++++++++++++++
Areadme.md | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/answers | 19+++++++++++++++++++
Ascripts/controller | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/init | 31+++++++++++++++++++++++++++++++
Ascripts/initializer | 18++++++++++++++++++
Ascripts/prepare-vm | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/start | 30++++++++++++++++++++++++++++++
Asrc/main.rs | 29+++++++++++++++++++++++++++++
Aunits/syslog | 5+++++
95 files changed, 7511 insertions(+), 0 deletions(-)

diff --git a/.cargo/config.toml b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "x86_64-unknown-linux-musl" diff --git a/.gitignore b/.gitignore @@ -0,0 +1,8 @@ +/target +/rootfs +/.idea +/.home +/notes.md +/initramfs +/vmlinuz-virt +*.qcow2 diff --git a/.vscode/settings.json b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "cSpell.words": [ + "kanit", + "poweroff" + ] +} diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml @@ -0,0 +1,28 @@ +when: + branch: master + event: [push, pull_request] + +steps: + - name: lint + image: "docker.io/rust:1.89-alpine3.22" + commands: + - apk add --no-cache musl-dev + - rustup component add clippy rustfmt + - cargo clippy -- --no-deps -Dwarnings + - cargo clippy --features testing -- --no-deps -Dwarnings + - cargo clippy --no-default-features --features timings,baked-rc,cli -- --no-deps -Dwarnings + - cargo fmt -- --check + - name: test + image: "docker.io/rust:1.89-alpine3.22" + depends_on: + - lint + commands: + - apk add --no-cache musl-dev just curl qemu-system-x86_64 qemu-img git bash + - curl -o tapview "https://gitlab.com/esr/tapview/-/raw/master/tapview" + - chmod +x ./tapview + - curl -o /usr/local/bin/lexpect "https://binaries.gayest.systems/lexpect/lexpect-lua54/target/x86_64-unknown-linux-musl/release/lexpect" + - chmod +x /usr/local/bin/lexpect + - sed 's/security_model=mapped/security_model=passthrough/' scripts/controller scripts/start -i + - ./scripts/prepare-vm + - just test > test.log + - cat test.log | ./tapview diff --git a/Cargo.lock b/Cargo.lock @@ -0,0 +1,745 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "async-channel" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8828ec6e544c02b0d6691d21ed9f9218d0384a82542855073c2a3f58304aaf0" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6baa8f0178795da0e71bc42c9e5d13261aac7ee549853162e66a241ba17964" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "tracing", + "windows-sys", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7eda79bbd84e29c2b308d1dc099d7de8dcc7035e48f4bf5dc4a531a44ff5e2a" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", + "tracing", + "windows-sys", +] + +[[package]] +name = "async-signal" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "794f185324c2f00e771cd9f1ae8b5ac68be2ca7abb129a87afd6e86d228bc54d" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-lite" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + +[[package]] +name = "kanit-cli" +version = "0.1.0" +dependencies = [ + "kanit-common", + "kanit-unit", + "nix", + "xflags", +] + +[[package]] +name = "kanit-common" +version = "0.1.0" + +[[package]] +name = "kanit-diagnostics" +version = "0.1.0" +dependencies = [ + "kanit-common", + "log", + "send_wrapper", +] + +[[package]] +name = "kanit-executor" +version = "0.1.0" +dependencies = [ + "async-executor", + "async-io", + "futures-lite", + "send_wrapper", +] + +[[package]] +name = "kanit-init" +version = "0.1.0" +dependencies = [ + "async-fs", + "async-signal", + "futures-lite", + "kanit-common", + "kanit-diagnostics", + "kanit-executor", + "kanit-rc", + "libc", + "log", + "nix", +] + +[[package]] +name = "kanit-multicall" +version = "0.1.0" +dependencies = [ + "kanit-cli", + "kanit-init", + "kanit-supervisor", +] + +[[package]] +name = "kanit-rc" +version = "0.1.0" +dependencies = [ + "async-lock", + "async-process", + "async-trait", + "kanit-common", + "kanit-diagnostics", + "kanit-executor", + "kanit-supervisor", + "kanit-unit", + "kanit-units", + "log", + "nix", + "send_wrapper", + "sha2", + "walkdir", +] + +[[package]] +name = "kanit-supervisor" +version = "0.1.0" +dependencies = [ + "async-process", + "kanit-common", + "nix", + "xflags", +] + +[[package]] +name = "kanit-unit" +version = "0.1.0" +dependencies = [ + "async-fs", + "async-trait", + "base64", + "blocking", + "kanit-common", + "kanit-supervisor", + "log", + "nix", + "nom", + "send_wrapper", +] + +[[package]] +name = "kanit-units" +version = "0.1.0" +dependencies = [ + "async-fs", + "async-process", + "async-trait", + "blocking", + "fastrand", + "futures-lite", + "kanit-common", + "kanit-executor", + "kanit-supervisor", + "kanit-unit", + "libc", + "log", + "nix", + "walkdir", +] + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "parking" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "piper" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1d5c74c9876f070d3e8fd503d748c7d974c3e48da8f41350fa5222ef9b4391" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3ed00ed3fbf728b5816498ecd316d1716eecaced9c0c8d2c5a6740ca214985b" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "tracing", + "windows-sys", +] + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "syn" +version = "2.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "201fcda3845c23e8212cd466bfebf0bd20694490fc0356ae8e428e0824a915a6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +dependencies = [ + "windows-sys", +] + +[[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-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[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 = "xflags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d9e15fbb3de55454b0106e314b28e671279009b363e6f1d8e39fdc3bf048944" +dependencies = [ + "xflags-macros", +] + +[[package]] +name = "xflags-macros" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "672423d4fea7ffa2f6c25ba60031ea13dc6258070556f125cc4d790007d4a155" diff --git a/Cargo.toml b/Cargo.toml @@ -0,0 +1,52 @@ +[workspace] +resolver = "2" +members = [ + "crates/init", + "crates/cli", + "crates/rc", + "crates/common", + "crates/supervisor", + "crates/executor", + "crates/diagnostics", + "crates/units", + "crates/unit" +] + +[workspace.package] +version = "0.1.0" +edition = "2024" + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" # we just forever loop anyway +strip = "debuginfo" + +[profile.min] +inherits = "release" +strip = true +opt-level = "z" + +[package] +name = "kanit-multicall" +version.workspace = true +edition.workspace = true + +[features] +default = ["timings", "baked-rc", "cli"] +cli = ["dep:kanit-cli"] +timings = ["kanit-init/timings", "kanit-cli?/blame"] +baked-rc = ["kanit-init/baked-rc"] +testing = ["kanit-init/testing"] + +[dependencies.kanit-cli] +path = "./crates/cli" +optional = true + +[dependencies.kanit-init] +path = "./crates/init" + +[dependencies.kanit-supervisor] +path = "./crates/supervisor" +default-features = false +features = ["cli"] diff --git a/LICENSE b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Sylvia Ivory + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "kanit-cli" +version.workspace = true +edition.workspace = true + +[features] +blame = [] + +[dependencies.xflags] +version = "0.3" + +[dependencies.nix] +version = "0.29" +features = ["user", "reboot"] + +[dependencies.kanit-unit] +path = "../unit" + +[dependencies.kanit-common] +path = "../common" diff --git a/crates/cli/src/blame.rs b/crates/cli/src/blame.rs @@ -0,0 +1,85 @@ +use std::fs; + +use kanit_common::constants; +use kanit_common::error::{Context, Result}; + +use crate::flags::Blame; + +#[derive(Copy, Clone)] +pub struct BlameEntry<'a> { + name: &'a str, + duration: u128, + level: usize, +} + +impl<'a> BlameEntry<'a> { + pub fn parse_single_entry(line: &'a str) -> Result<Self> { + let mut parts = line.split_whitespace(); + + let name = parts.next().context("expected `name`")?; + + let duration = parts + .next() + .context("expected `duration`")? + .parse::<u128>() + .context("failed to parse `duration`")?; + + let level = parts + .next() + .context("expected `level`")? + .parse::<usize>() + .context("failed to parse `level`")?; + + Ok(Self { + name, + duration, + level, + }) + } +} + +fn parse_blame(lines: &str) -> Vec<BlameEntry> { + lines + .lines() + .filter_map(|line| BlameEntry::parse_single_entry(line).ok()) + .collect() +} + +pub fn blame(opts: Blame) -> Result<()> { + let timings = fs::read_to_string(constants::KAN_TIMINGS).context("failed to read timings")?; + + let blames = parse_blame(&timings); + + let mut filtered_timings = blames + .iter() + .filter(|l| l.name.starts_with("unit:")) + .map(|l| BlameEntry { + name: l.name, + level: l.level, + duration: ((l.duration as f64) / 1000.0).round() as u128, + }) + .collect::<Vec<_>>(); + + if opts.sorted { + filtered_timings.sort_by(|a, b| b.duration.partial_cmp(&a.duration).unwrap()); + } + + let max_len = filtered_timings + .iter() + .map(|l| l.duration.to_string()) + .max_by(|a, b| a.len().cmp(&b.len())) + .unwrap() + .len(); + + for timing in filtered_timings { + let dur = timing.duration.to_string(); + + println!( + "{}{dur}ms {}", + " ".repeat(max_len - dur.len()), + &timing.name[5..] + ) + } + + Ok(()) +} diff --git a/crates/cli/src/flags.rs b/crates/cli/src/flags.rs @@ -0,0 +1,51 @@ +xflags::xflags! { + cmd kanit { + /// Teardown and power-off the system. + cmd poweroff { + /// Force a power-off, not performing a teardown. + optional -f, --force + } + /// Teardown and reboot the system. + cmd reboot { + /// Force a reboot, not performing a teardown. + optional -f, --force + } + /// Teardown and halt the system. + cmd halt { + /// Force a halt, not performing a teardown. + optional -f, --force + } + /// Teardown and reboot the system via kexec. + cmd kexec { + /// Force a reboot via kexec, not performing a teardown. + optional -f, --force + } + /// Print unit startup times. + cmd blame { + // Print units sorted by startup time. + optional -s, --sorted + } + /// Service related utilities. + cmd service { + /// Enable a unit at the specified runlevel. + cmd enable { + /// The name of the unit. + required unit: String + /// The runlevel to enable the service at. + optional runlevel: String + } + /// Disable a unit at the specified runlevel. + cmd disable { + /// The name of the unit. + required unit: String + /// The runlevel to enable the service at. + optional runlevel: String + } + /// List all enabled units. + cmd list { + /// Shows the individual unit groups. + optional -p, --plan + } + } + } +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs @@ -0,0 +1,52 @@ +use std::{env, ffi::OsString, process::ExitCode}; + +use flags::{Kanit, KanitCmd, ServiceCmd}; + +#[cfg(feature = "blame")] +mod blame; +mod flags; +mod service; +mod teardown; + +fn handle_cli_inner(args: Vec<OsString>) -> ExitCode { + let res = match Kanit::from_vec(args) { + Ok(app) => match app.subcommand { + KanitCmd::Poweroff(opts) => teardown::teardown("poweroff", opts.force), + KanitCmd::Reboot(opts) => teardown::teardown("reboot", opts.force), + KanitCmd::Halt(opts) => teardown::teardown("halt", opts.force), + KanitCmd::Kexec(opts) => teardown::teardown("kexec", opts.force), + #[cfg(feature = "blame")] + KanitCmd::Blame(opts) => blame::blame(opts), + #[cfg(not(feature = "blame"))] + KanitCmd::Blame(_) => { + eprintln!("kanit compiled without blame"); + return ExitCode::FAILURE; + } + KanitCmd::Service(svc) => match svc.subcommand { + ServiceCmd::Enable(opts) => service::enable(opts), + ServiceCmd::Disable(opts) => service::disable(opts), + ServiceCmd::List(opts) => service::list(opts), + }, + }, + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + }; + + if let Err(e) = res { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + + ExitCode::SUCCESS +} + +pub fn handle_cli(pop_first: bool) -> ExitCode { + let mut args = env::args_os(); + if pop_first { + args.next(); + } + + handle_cli_inner(args.collect()) +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + kanit_cli::handle_cli(true); +} diff --git a/crates/cli/src/service/disable.rs b/crates/cli/src/service/disable.rs @@ -0,0 +1,19 @@ +use std::fs::remove_file; +use std::path::PathBuf; + +use kanit_common::constants; +use kanit_common::error::{Context, Result}; + +use crate::flags::Disable; + +pub fn disable(opts: Disable) -> Result<()> { + let unit = PathBuf::from(constants::KAN_ENABLED_DIR) + .join(opts.runlevel.unwrap_or_else(|| String::from("default"))) + .join(opts.unit); + + remove_file(&unit).context("failed to disable unit")?; + + println!("delete {}", unit.to_string_lossy()); + + Ok(()) +} diff --git a/crates/cli/src/service/enable.rs b/crates/cli/src/service/enable.rs @@ -0,0 +1,24 @@ +use std::os::unix::fs::symlink; +use std::path::PathBuf; + +use kanit_common::constants; +use kanit_common::error::{Context, Result}; + +use crate::flags::Enable; + +pub fn enable(opts: Enable) -> Result<()> { + let src = PathBuf::from(constants::KAN_UNIT_DIR).join(&opts.unit); + let dst = PathBuf::from(constants::KAN_ENABLED_DIR) + .join(opts.runlevel.unwrap_or_else(|| String::from("default"))) + .join(&opts.unit); + + symlink(&src, &dst).context("failed to enable unit")?; + + println!( + "link {} -> {}", + src.to_string_lossy(), + dst.to_string_lossy() + ); + + Ok(()) +} diff --git a/crates/cli/src/service/list.rs b/crates/cli/src/service/list.rs @@ -0,0 +1,42 @@ +use std::fs; +use std::path::Path; + +use kanit_common::constants; +use kanit_common::error::{Context, Result, StaticError}; +use kanit_unit::formats::DependencyGrouping; + +use crate::flags::List; + +pub fn list(opts: List) -> Result<()> { + let group_path = Path::new(constants::KAN_DEPENDENCY_MAP); + + if !group_path.exists() { + Err(StaticError("failed to find dependency map"))?; + } + + let group = fs::read_to_string(group_path) + .context("failed to read dependency map")? + .parse::<DependencyGrouping>() + .context("failed to parse dependency map")? + .groups; + + for (i, level) in group.iter() { + println!("level {i}"); + + if opts.plan { + for (o, group) in level.iter().enumerate() { + println!("|> group {o}"); + + for unit in group.iter() { + println!(" |> {unit}"); + } + } + } else { + for unit in level.iter().flatten() { + println!("|> {unit}"); + } + } + } + + Ok(()) +} diff --git a/crates/cli/src/service/mod.rs b/crates/cli/src/service/mod.rs @@ -0,0 +1,7 @@ +pub use disable::disable; +pub use enable::enable; +pub use list::list; + +mod disable; +mod enable; +mod list; diff --git a/crates/cli/src/teardown.rs b/crates/cli/src/teardown.rs @@ -0,0 +1,28 @@ +use std::fs::write; + +use nix::sys::reboot::{RebootMode, reboot}; +use nix::unistd::getuid; + +use kanit_common::constants::KAN_PIPE; +use kanit_common::error::{Context, Result, StaticError}; + +pub fn teardown(cmd: &str, force: bool) -> Result<()> { + if !getuid().is_root() { + Err(StaticError("operation not permitted"))?; + } + + if force { + match cmd { + "poweroff" => reboot(RebootMode::RB_POWER_OFF), + "reboot" => reboot(RebootMode::RB_AUTOBOOT), + "halt" => reboot(RebootMode::RB_HALT_SYSTEM), + "kexec" => reboot(RebootMode::RB_KEXEC), + _ => unreachable!(), + } + .context("Failed to reboot")?; + } + + write(KAN_PIPE, cmd).context("failed to write to pipe")?; + + Ok(()) +} diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "kanit-common" +version.workspace = true +edition.workspace = true diff --git a/crates/common/src/constants.rs b/crates/common/src/constants.rs @@ -0,0 +1,9 @@ +pub const KAN_PIPE: &str = "/run/kan.pipe"; +pub const KAN_TIMINGS: &str = "/run/kan.timing"; +pub const KAN_PIDS: &str = "/run/kan.pid"; +pub const KAN_PATH: &str = "/bin:/sbin:/usr/bin:/usr/sbin"; +pub const KAN_SEED: &str = "/var/lib/seed"; +pub const KAN_DEPENDENCY_MAP: &str = "/var/lib/kan.map"; +pub const KAN_UNIT_DIR: &str = "/etc/kanit/system"; +pub const KAN_ENABLED_DIR: &str = "/etc/kanit/enabled"; +pub const KAN_VERSION: &str = "0.1.0"; diff --git a/crates/common/src/error.rs b/crates/common/src/error.rs @@ -0,0 +1,151 @@ +use std::fmt::{self, Debug, Display, Formatter}; + +pub type Result<T, E = Error> = std::result::Result<T, E>; + +#[derive(Debug)] +pub enum ErrorKind { + Recoverable, + Unrecoverable, +} + +type LazyContext = dyn Send + Fn() -> String; + +pub struct Error { + kind: ErrorKind, + context: Option<Box<LazyContext>>, + original: Box<dyn std::error::Error + Send>, +} + +impl Error { + fn new<S: Display + Send + 'static>( + kind: ErrorKind, + context: Option<S>, + original: Box<dyn std::error::Error + Send>, + ) -> Self { + Self { + context: context.map(|n| Box::new(move || n.to_string()) as Box<LazyContext>), + kind, + original, + } + } + + pub fn is_recoverable(&self) -> bool { + matches!(self.kind, ErrorKind::Recoverable) + } +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + if let Some(ref context) = self.context { + write!(f, "{}: {}", context(), self.original) + } else { + write!(f, "{}", self.original) + } + } +} + +impl Debug for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("Error") + .field("kind", &self.kind) + .field("original", &self.original) + .finish() + } +} + +impl<E: std::error::Error + Send + 'static> From<E> for Error { + fn from(error: E) -> Self { + Self::new::<&str>(ErrorKind::Unrecoverable, None, Box::new(error)) + } +} + +#[derive(Debug)] +pub struct StaticError(pub &'static str); + +impl Display for StaticError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for StaticError {} + +pub struct WithError(Box<LazyContext>); + +impl WithError { + pub fn with<F: Send + Fn() -> String + 'static>(e: F) -> Self { + Self(Box::new(e) as Box<LazyContext>) + } +} + +impl Display for WithError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0()) + } +} + +impl Debug for WithError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("Error").finish() + } +} + +impl std::error::Error for WithError {} + +pub trait Context<T>: Sized { + fn context_kind<C: Display + Send + 'static>(self, context: C, kind: ErrorKind) -> Result<T>; + fn with_context_kind<C: Send + Fn() -> String + 'static>( + self, + context: C, + kind: ErrorKind, + ) -> Result<T> { + self.context_kind(WithError::with(context), kind) + } + fn context<C: Display + Send + 'static>(self, context: C) -> Result<T> { + self.context_kind(context, ErrorKind::Unrecoverable) + } + fn with_context<C: Send + Fn() -> String + 'static>(self, context: C) -> Result<T> { + self.with_context_kind(context, ErrorKind::Unrecoverable) + } + fn kind(self, kind: ErrorKind) -> Result<T>; +} + +impl<T> Context<T> for Option<T> { + fn context_kind<C: Display + Send + 'static>(self, context: C, kind: ErrorKind) -> Result<T> { + match self { + Some(ok) => Ok(ok), + None => Err(Error::new( + kind, + Some(context), + Box::new(StaticError("attempted to unwrap 'None' value")), + )), + } + } + + fn kind(self, kind: ErrorKind) -> Result<T> { + match self { + Some(ok) => Ok(ok), + None => Err(Error::new::<&str>( + kind, + None, + Box::new(StaticError("attempted to unwrap 'None' value")), + )), + } + } +} + +impl<T, E: std::error::Error + Send + 'static> Context<T> for Result<T, E> { + fn context_kind<C: Display + Send + 'static>(self, context: C, kind: ErrorKind) -> Result<T> { + match self { + Ok(ok) => Ok(ok), + Err(e) => Err(Error::new(kind, Some(context), Box::new(e))), + } + } + + fn kind(self, kind: ErrorKind) -> Result<T> { + match self { + Ok(ok) => Ok(ok), + Err(e) => Err(Error::new::<&str>(kind, None, Box::new(e))), + } + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs @@ -0,0 +1,2 @@ +pub mod constants; +pub mod error; diff --git a/crates/diagnostics/Cargo.toml b/crates/diagnostics/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "kanit-diagnostics" +version.workspace = true +edition.workspace = true + +[features] +tap = [] +timings = ["send_wrapper"] + +[dependencies.send_wrapper] +version = "0.6" +optional = true + +[dependencies.log] +version = "0.4" +features = ["std"] + +[dependencies.kanit-common] +path = "../common" diff --git a/crates/diagnostics/src/lib.rs b/crates/diagnostics/src/lib.rs @@ -0,0 +1,44 @@ +pub use logger::*; + +mod logger; +mod scope; +#[cfg(feature = "tap")] +pub mod tap; +#[cfg(feature = "timings")] +pub mod timing; + +#[cfg(not(feature = "tap"))] +pub mod tap { + pub fn header() {} + + pub fn enter_subtest<S: ToString>(_: Option<S>) {} + + pub fn exit_subtest() {} + + pub fn plan(_: usize) {} + + pub fn bail<S: ToString>(_: Option<S>) {} + + pub fn ok<S: ToString>(_: usize, _: Option<S>) {} + + pub fn not_ok<S: ToString>(_: usize, _: Option<S>) {} +} + +#[cfg(not(feature = "timings"))] +pub mod timing { + use std::rc::Rc; + + pub use crate::scope::Scope; + + pub fn register() {} + + pub fn push_scope<S: ToString>(_name: S) -> usize { + 0 + } + + pub fn pop_scope(_id: usize) {} + + pub fn get_scopes() -> Rc<[Scope]> { + Rc::from([]) + } +} diff --git a/crates/diagnostics/src/logger.rs b/crates/diagnostics/src/logger.rs @@ -0,0 +1,92 @@ +use std::io::Write; +use std::{fmt, io}; + +use log::{LevelFilter, Metadata, Record, set_boxed_logger, set_max_level}; + +use kanit_common::error::{Context, Result}; + +const SYMBOLS: [char; 5] = ['!', '=', '*', '&', '#']; + +const COLORS: [Colors; 5] = [ + Colors::BrightRed, + Colors::BrightYellow, + Colors::BrightGreen, + Colors::BrightCyan, + Colors::BrightMagenta, +]; + +#[repr(u8)] +#[derive(Copy, Clone)] +pub enum Colors { + Black = 0, + Red = 1, + Green = 2, + Yellow = 3, + Blue = 4, + Magenta = 5, + Cyan = 6, + White = 7, + BrightBlack = 8, + BrightRed = 9, + BrightGreen = 10, + BrightYellow = 11, + BrightBlue = 12, + BrightMagenta = 13, + BrightCyan = 14, + BrightWhite = 15, +} + +impl Colors { + pub fn reset() -> &'static str { + "\x1b[0m" + } +} + +impl fmt::Display for Colors { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "\x1b[38;5;{}m", *self as u16) + } +} + +// temporary solution +// logger should switch to a journal once initialized +pub struct InitializationLogger { + level: LevelFilter, +} + +impl InitializationLogger { + pub fn init(level: LevelFilter) -> Result<()> { + let logger = Self { level }; + + set_max_level(level); + set_boxed_logger(Box::new(logger)).context("failed to initialize logger")?; + + Ok(()) + } +} + +impl log::Log for InitializationLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= self.level + } + + fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + let idx = (record.level() as usize) - 1; + let sym = SYMBOLS[idx]; + let color = COLORS[idx]; + + let mut stdout = io::stdout().lock(); + + // we can't handle error + let _ = writeln!( + &mut stdout, + "{color}{sym}{} {}", + Colors::reset(), + record.args() + ); + } + } + + fn flush(&self) {} +} diff --git a/crates/diagnostics/src/scope.rs b/crates/diagnostics/src/scope.rs @@ -0,0 +1,9 @@ +use std::time::{Duration, Instant}; + +#[derive(Clone)] +pub struct Scope { + pub name: String, + pub start: Instant, + pub duration: Option<Duration>, + pub level: usize, +} diff --git a/crates/diagnostics/src/tap.rs b/crates/diagnostics/src/tap.rs @@ -0,0 +1,57 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; + +static LEVEL: AtomicUsize = AtomicUsize::new(0); + +fn print_leveled<S: ToString>(s: S) { + println!( + "{}{}", + " ".repeat(LEVEL.load(Ordering::Relaxed)), + s.to_string() + ); +} + +pub fn header() { + println!("TAP version 14"); +} + +pub fn enter_subtest<S: ToString>(desc: Option<S>) { + if let Some(desc) = desc { + print_leveled(format!("# Subtest: {}", desc.to_string())) + } else { + print_leveled("# Subtest") + } + + LEVEL.fetch_add(1, Ordering::Relaxed); +} + +pub fn exit_subtest() { + LEVEL.fetch_sub(1, Ordering::Relaxed); +} + +pub fn plan(tests: usize) { + print_leveled(format!("1..{tests}")) +} + +pub fn bail<S: ToString>(desc: Option<S>) { + if let Some(desc) = desc { + print_leveled(format!("Bail out! {}", desc.to_string())) + } else { + print_leveled("Bail out!"); + } +} + +pub fn ok<S: ToString>(test: usize, desc: Option<S>) { + if let Some(desc) = desc { + print_leveled(format!("ok {test} - {}", desc.to_string())); + } else { + print_leveled(format!("ok {test}")); + } +} + +pub fn not_ok<S: ToString>(test: usize, desc: Option<S>) { + if let Some(desc) = desc { + print_leveled(format!("not ok {test} - {}", desc.to_string())); + } else { + print_leveled(format!("not ok {test}")); + } +} diff --git a/crates/diagnostics/src/timing.rs b/crates/diagnostics/src/timing.rs @@ -0,0 +1,66 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::OnceLock; +use std::time::Instant; + +use send_wrapper::SendWrapper; + +pub use crate::scope::Scope; + +static GLOBAL_TIMER: OnceLock<SendWrapper<RefCell<Timer>>> = OnceLock::new(); + +pub struct Timer { + scopes: Vec<Scope>, + level: usize, +} + +pub fn register() { + let _ = GLOBAL_TIMER.set(SendWrapper::new(RefCell::new(Timer { + scopes: vec![], + level: 0, + }))); +} + +pub fn push_scope<S: ToString>(name: S) -> Option<usize> { + if let Some(timer) = GLOBAL_TIMER.get() { + let level = timer.borrow().level; + let mut timer = timer.borrow_mut(); + + timer.scopes.push(Scope { + name: name.to_string(), + start: Instant::now(), + duration: None, + level, + }); + + let id = timer.scopes.len() - 1; + + timer.level += 1; + + Some(id) + } else { + None + } +} + +pub fn pop_scope(id: Option<usize>) { + if let Some(timer) = GLOBAL_TIMER.get() { + let mut timer = timer.borrow_mut(); + + if let Some(id) = id { + let scope = &mut timer.scopes[id]; + + scope.duration = Some(Instant::now() - scope.start); + } + + timer.level -= 1; + } +} + +pub fn get_scopes() -> Rc<[Scope]> { + if let Some(timer) = GLOBAL_TIMER.get() { + timer.borrow().scopes.clone().into() + } else { + Rc::from([]) + } +} diff --git a/crates/executor/Cargo.toml b/crates/executor/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "kanit-executor" +version.workspace = true +edition.workspace = true + +[dependencies.futures-lite] +version = "2.3" + +[dependencies.send_wrapper] +version = "0.6" + +[dependencies.async-io] +version = "2.3" + +[dependencies.async-executor] +version = "1.12" diff --git a/crates/executor/src/lib.rs b/crates/executor/src/lib.rs @@ -0,0 +1,33 @@ +use std::future::Future; +use std::sync::OnceLock; + +use async_executor::{LocalExecutor, Task}; +use futures_lite::StreamExt; +use futures_lite::stream::iter; +use send_wrapper::SendWrapper; + +static GLOBAL_EXECUTOR: OnceLock<SendWrapper<LocalExecutor<'static>>> = OnceLock::new(); + +pub fn spawn<T: 'static>(future: impl Future<Output = T> + 'static) -> Task<T> { + GLOBAL_EXECUTOR + .get_or_init(|| SendWrapper::new(LocalExecutor::new())) + .spawn(future) +} + +pub fn block<T: 'static>(future: impl Future<Output = T> + 'static) -> T { + async_io::block_on( + GLOBAL_EXECUTOR + .get_or_init(|| SendWrapper::new(LocalExecutor::new())) + .run(future), + ) +} + +pub async fn join_all<I, F, R: 'static>(futures: I) -> Vec<R> +where + I: IntoIterator<Item = F>, + F: Future<Output = R> + 'static, +{ + let handles: Vec<_> = futures.into_iter().map(spawn).collect(); + + iter(handles).then(|f| f).collect().await +} diff --git a/crates/init/Cargo.toml b/crates/init/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "kanit-init" +version.workspace = true +edition.workspace = true + +[features] +baked-rc = ["kanit-rc"] +timings = ["kanit-diagnostics/timings"] +testing = ["kanit-diagnostics/tap", "kanit-rc?/testing"] + +[dependencies.futures-lite] +version = "2.3" + +[dependencies.async-fs] +version = "2.1" + +[dependencies.async-signal] +version = "0.2" + +[dependencies.nix] +version = "0.29" +features = [ + "reboot", + "signal", + "fs", + "feature", + "process", + "ioctl" +] + +[dependencies.libc] +version = "0.2" + +[dependencies.log] +version = "0.4" + +[dependencies.kanit-diagnostics] +path = "../diagnostics" + +[dependencies.kanit-executor] +path = "../executor" + +[dependencies.kanit-common] +path = "../common" + +[dependencies.kanit-rc] +path = "../rc" +optional = true diff --git a/crates/init/src/baked_rc.rs b/crates/init/src/baked_rc.rs @@ -0,0 +1,20 @@ +use kanit_common::error::Result; +use kanit_executor::block; + +pub fn teardown_rc() -> Result<()> { + block(kanit_rc::teardown())?; + + Ok(()) +} + +pub fn initialize_rc() -> Result<()> { + block(kanit_rc::start())?; + + Ok(()) +} + +#[inline] +#[cfg(not(feature = "testing"))] +pub async fn event_rc(ev: Vec<u8>) -> Result<()> { + kanit_rc::event(ev).await +} diff --git a/crates/init/src/bsod.rs b/crates/init/src/bsod.rs @@ -0,0 +1,111 @@ +// we all love to see a bsod + +use std::backtrace::Backtrace; +use std::io::{Write, stdin, stdout}; +use std::os::fd::RawFd; +use std::os::unix::process::CommandExt; +use std::panic::PanicHookInfo; +use std::process::Command; + +use nix::fcntl::{OFlag, open}; +use nix::ioctl_write_int_bad; +use nix::sys::reboot::{RebootMode, reboot}; +use nix::sys::stat::Mode; +use nix::unistd::isatty; + +const BLUE_BG: &str = "\x1b]P0000080"; +const WHITE_TEXT: &str = "\x1b]P7FFFFFF"; + +const CLEAR_SCREEN: &str = "\x1b[1;1H\x1b[2J"; + +const VT_ACTIVATE: i32 = 0x5606; +const VT_WAIT_ACTIVE: i32 = 0x5607; + +ioctl_write_int_bad!(vt_activate, VT_ACTIVATE); +ioctl_write_int_bad!(vt_wait_activate, VT_WAIT_ACTIVE); + +fn print_info(info: &PanicHookInfo, tty: bool) { + let mut stdout = stdout(); + + if tty { + let _ = write!(&mut stdout, "{BLUE_BG}{WHITE_TEXT}{CLEAR_SCREEN}"); + } + + let _ = writeln!( + &mut stdout, + "Kanit Panic\n===========\nKanit experienced an unrecoverable error and cannot continue." + ); + let _ = writeln!( + &mut stdout, + "Consider checking the system journal or the following backtrace.\n" + ); + + if let Some(s) = info.payload().downcast_ref::<&str>() { + let _ = writeln!(&mut stdout, "Message: {s}"); + } + + if let Some(loc) = info.location() { + let _ = writeln!( + &mut stdout, + "File: {}:{}:{}", + loc.file(), + loc.line(), + loc.column() + ); + } + + let mut junk = String::new(); + + if tty { + let _ = writeln!(&mut stdout, "Press enter to view backtrace..."); + + let _ = stdout.flush(); + + let _ = stdin().read_line(&mut junk); + } + + let backtrace = Backtrace::force_capture(); + + let _ = writeln!(&mut stdout, "-- backtrace --\n{backtrace}"); + + if tty { + let _ = writeln!( + &mut stdout, + "Init is now considered unstable, press enter to enter into emergency shell..." + ); + + let _ = stdout.flush(); + let _ = stdin().read_line(&mut junk); + } + + let _ = Command::new("sh").exec(); + + if tty { + let _ = writeln!( + &mut stdout, + "failed to enter emergency shell, press enter to restart..." + ); + + let _ = stdout.flush(); + let _ = stdin().read_line(&mut junk); + } + + let _ = reboot(RebootMode::RB_AUTOBOOT); +} + +pub fn bsod(info: &PanicHookInfo) { + if !isatty(RawFd::from(1)).unwrap_or(false) { + return print_info(info, false); + } + + // change virtual terminal + if let Ok(fd) = open("/dev/console", OFlag::O_RDONLY, Mode::empty()) { + // SAFETY: we ensure `fd` is valid + unsafe { + let _ = vt_activate(fd, 1); + let _ = vt_wait_activate(fd, 1); + } + } + + print_info(info, false); +} diff --git a/crates/init/src/ev_loop.rs b/crates/init/src/ev_loop.rs @@ -0,0 +1,89 @@ +use async_fs::File; +use async_signal::{Signal, Signals}; +use futures_lite::{AsyncReadExt, StreamExt}; +use log::debug; +use nix::errno::Errno; +use nix::sys::reboot::RebootMode; +use nix::sys::stat::Mode; +use nix::unistd::mkfifo; + +use kanit_common::constants; +use kanit_common::error::{Context, Result}; +use kanit_executor::{block, spawn}; + +use crate::{event_rc, teardown}; + +async fn pipe_handler(data: Vec<u8>) -> Result<()> { + if data.starts_with(b"halt") { + teardown(Some(RebootMode::RB_HALT_SYSTEM))?; + } else if data.starts_with(b"poweroff") { + teardown(Some(RebootMode::RB_POWER_OFF))?; + } else if data.starts_with(b"reboot") { + teardown(Some(RebootMode::RB_AUTOBOOT))?; + } else if data.starts_with(b"kexec") { + teardown(Some(RebootMode::RB_KEXEC))?; + } else { + event_rc(data).await?; + } + + Ok(()) +} + +async fn listen_signal() -> Result<()> { + let mut signals = Signals::new([Signal::Int, Signal::Term, Signal::Child]) + .context("failed to register signals")?; + + while let Some(signal) = signals.next().await { + let signal = if let Ok(signal) = signal { + signal + } else { + continue; + }; + + debug!("*boop* {signal:?}"); + } + + Ok(()) +} + +async fn listen_file() -> Result<()> { + mkfifo(constants::KAN_PIPE, Mode::S_IRUSR | Mode::S_IWUSR).context("failed to create pipe")?; + + loop { + let mut buff = vec![0u8; 512]; + + let mut file = match File::open(constants::KAN_PIPE).await { + Ok(f) => f, + Err(e) => match e.raw_os_error() { + None => return Err(e.into()), + Some(n) => { + if Errno::from_raw(n) == Errno::EINTR { + continue; + } else { + return Err(e.into()); + } + } + }, + }; + + file.read(&mut buff).await.context("failed to read pipe")?; + + spawn(pipe_handler(buff)).detach(); + } +} + +async fn inner_ev_loop() -> Result<()> { + let handles = [spawn(listen_signal()), spawn(listen_file())]; + + for handle in handles { + handle.await?; + } + + Ok(()) +} + +pub fn ev_loop() -> Result<()> { + block(inner_ev_loop())?; + + Ok(()) +} diff --git a/crates/init/src/lib.rs b/crates/init/src/lib.rs @@ -0,0 +1,213 @@ +#[cfg(feature = "timings")] +use std::fs::File; +#[cfg(not(feature = "testing"))] +use std::io; +#[cfg(feature = "timings")] +use std::io::Write; +#[cfg(not(feature = "testing"))] +use std::os::unix::process::CommandExt; +#[cfg(not(feature = "testing"))] +use std::process::Command; +use std::process::ExitCode; +use std::thread::sleep; +use std::time::Duration; +use std::{env, panic, process}; + +#[cfg(not(feature = "testing"))] +use log::LevelFilter; +#[cfg(feature = "timings")] +use log::warn; +use log::{error, info}; +use nix::sys::reboot::{RebootMode, reboot, set_cad_enabled}; +use nix::sys::signal::{SigSet, Signal, kill}; +#[cfg(not(feature = "testing"))] +use nix::sys::utsname::uname; +use nix::unistd::{Pid, sync}; + +#[cfg(feature = "baked-rc")] +use baked_rc::*; +#[cfg(not(feature = "testing"))] +use ev_loop::ev_loop; +use kanit_common::constants; +use kanit_common::error::{Context, Result}; +#[cfg(not(feature = "testing"))] +use kanit_diagnostics::Colors; +#[cfg(feature = "testing")] +use kanit_diagnostics::tap as kanit_tap; +use kanit_diagnostics::timing as kanit_timing; +#[cfg(feature = "timings")] +use kanit_timing::Scope; +#[cfg(not(feature = "baked-rc"))] +use rc::*; + +#[cfg(feature = "baked-rc")] +mod baked_rc; +#[cfg(not(feature = "testing"))] +mod bsod; +#[cfg(not(feature = "testing"))] +mod ev_loop; +#[cfg(not(feature = "baked-rc"))] +mod rc; + +#[cfg(feature = "timings")] +fn write_scope(file: &mut File, scope: &Scope) -> Result<()> { + let scope_fmt = format!( + "{} {} {}\n", + scope.name, + scope.duration.unwrap_or(Duration::from_secs(0)).as_micros(), + scope.level + ); + + file.write(scope_fmt.as_bytes()) + .context("failed to write scope")?; + Ok(()) +} + +#[cfg(feature = "timings")] +fn write_timing() -> Result<()> { + let mut file = File::create(constants::KAN_TIMINGS).context("failed to open times file")?; + + for scope in kanit_timing::get_scopes().iter() { + write_scope(&mut file, scope)?; + } + + Ok(()) +} + +fn initialize() -> Result<()> { + let id = kanit_timing::push_scope("initialize"); + + #[cfg(not(feature = "testing"))] + let platform = uname() + .map(|u| { + format!( + "{} {} ({})", + u.sysname().to_string_lossy(), + u.release().to_string_lossy(), + u.machine().to_string_lossy() + ) + }) + .unwrap_or_else(|_| "unknown".to_string()); + + #[cfg(not(feature = "testing"))] + info!( + "== Kanit {}{}{} on {}{}{} ==\n", + Colors::BrightGreen, + constants::KAN_VERSION, + Colors::reset(), + Colors::BrightMagenta, + platform, + Colors::reset() + ); + + let mut set = SigSet::all(); + set.remove(Signal::SIGCHLD); + + set.thread_block().context("failed to block signals")?; + + set_cad_enabled(false).context("failed to ignore CAD")?; + + // SAFETY: single threaded + unsafe { + env::set_var("PATH", constants::KAN_PATH); + } + + initialize_rc()?; + + kanit_timing::pop_scope(id); + + #[cfg(feature = "timings")] + if let Err(e) = write_timing() { + warn!("failed to write timings: {e}"); + }; + + Ok(()) +} + +pub fn teardown(cmd: Option<RebootMode>) -> Result<()> { + teardown_rc()?; + + info!("terminating remaining processes"); + + kill(Pid::from_raw(-1), Signal::SIGTERM).context("failed to terminate all processes")?; + + sleep(Duration::from_secs(3)); + + info!("killing remaining processes"); + + kill(Pid::from_raw(-1), Signal::SIGKILL).context("failed to kill all processes")?; + + sync(); + + // reboot will always return an error + if let Some(cmd) = cmd { + let _ = reboot(cmd); + } + + Ok(()) +} + +#[cfg(feature = "testing")] +fn failure_handle() { + kanit_tap::bail::<&str>(None); + let _ = reboot(RebootMode::RB_POWER_OFF); +} + +#[cfg(not(feature = "testing"))] +fn failure_handle() { + error!("dropping into emergency shell"); + + let _ = Command::new("sh").exec(); + + error!("failed to drop into emergency shell; good luck o7"); + + loop { + // kernel panic if this loop doesn't exist + sleep(Duration::from_secs(1)); + } +} + +pub fn handle_cli() -> ExitCode { + if process::id() != 1 { + eprintln!("init must be ran as PID 1"); + return ExitCode::FAILURE; + } + + #[cfg(feature = "testing")] + panic::set_hook(Box::new(|info| { + kanit_tap::bail(info.payload().downcast_ref::<&str>()); + let _ = reboot(RebootMode::RB_POWER_OFF); + })); + + #[cfg(not(feature = "testing"))] + panic::set_hook(Box::new(|info| { + error!("panic detected; tearing down"); + let _ = teardown(None); // attempt a teardown + bsod::bsod(info); + })); + + kanit_diagnostics::tap::header(); + + #[cfg(not(feature = "testing"))] + if let Err(e) = kanit_diagnostics::InitializationLogger::init(LevelFilter::Trace) { + let _ = write!(&mut io::stdout().lock(), "{e}"); + } + + kanit_timing::register(); + + if let Err(e) = initialize() { + error!("failed to initialize: {e}"); + failure_handle(); + } + + #[cfg(not(feature = "testing"))] // no way to test yet + if let Err(e) = ev_loop() { + error!("event loop failed: {e}"); + failure_handle(); + } + + #[cfg(feature = "testing")] + teardown(Some(RebootMode::RB_POWER_OFF)).expect("failed to unwrap"); + + ExitCode::SUCCESS +} diff --git a/crates/init/src/main.rs b/crates/init/src/main.rs @@ -0,0 +1,5 @@ +use std::process::ExitCode; + +fn main() -> ExitCode { + kanit_init::handle_cli() +} diff --git a/crates/init/src/rc.rs b/crates/init/src/rc.rs @@ -0,0 +1,42 @@ +use std::path::Path; +use std::process::Command; + +use kanit_common::error::{Context, ErrorKind, Result, StaticError}; + +pub fn initialize_rc() -> Result<()> { + if !Path::new("/etc/rc.start").exists() { + Err(StaticError("failed to find a start script")).kind(ErrorKind::Unrecoverable)?; + } + + Command::new("/etc/rc.start") + .spawn() + .context("failed to start rc.start")?; + + Ok(()) +} + +pub fn teardown_rc() -> Result<()> { + if !Path::new("/etc/rc.stop").exists() { + Err(StaticError("failed to find a stop script")).kind(ErrorKind::Unrecoverable)?; + } + + Command::new("/etc/rc.stop") + .spawn() + .context("failed to start rc.start")?; + + Ok(()) +} + +#[cfg(not(feature = "testing"))] +pub async fn event_rc(ev: Vec<u8>) -> Result<()> { + if !Path::new("/etc/rc.event").exists() { + return Ok(()); + } + + Command::new("/etc/rc.event") + .arg(String::from_utf8_lossy(&ev).to_string()) + .spawn() + .context_kind("failed to start rc.event", ErrorKind::Recoverable)?; + + Ok(()) +} diff --git a/crates/rc/Cargo.toml b/crates/rc/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "kanit-rc" +version.workspace = true +edition.workspace = true + +[features] +default = ["units"] +units = ["kanit-units"] +testing = ["kanit-units?/testing"] + +[dependencies.sha2] +version = "0.10" + +[dependencies.walkdir] +version = "2.5" + +[dependencies.send_wrapper] +version = "0.6" + +[dependencies.async-lock] +version = "3.4" + +[dependencies.async-process] +version = "2.2" + +[dependencies.async-trait] +version = "0.1" + +[dependencies.log] +version = "0.4" +features = ["std"] + +[dependencies.nix] +version = "0.29" +features = [ + "signal", + "process" +] + +[dependencies.kanit-units] +path = "../units" +optional = true + +[dependencies.kanit-unit] +path = "../unit" + +[dependencies.kanit-diagnostics] +path = "../diagnostics" + +[dependencies.kanit-executor] +path = "../executor" + +[dependencies.kanit-supervisor] +path = "../supervisor" + +[dependencies.kanit-common] +path = "../common" + diff --git a/crates/rc/src/control.rs b/crates/rc/src/control.rs @@ -0,0 +1,217 @@ +#[cfg(not(feature = "testing"))] +use std::io::{Write, stdin, stdout}; + +use async_process::driver; +use log::{debug, error, info, warn}; + +#[cfg(not(feature = "testing"))] +use kanit_common::error::Context; +use kanit_common::error::{Error, Result}; +use kanit_diagnostics::tap as kanit_tap; +use kanit_diagnostics::timing as kanit_timing; +use kanit_executor::{join_all, spawn}; +use kanit_unit::{RcUnit, UnitName}; + +use crate::loader; +use crate::loader::Loader; + +#[cfg(not(feature = "testing"))] +fn critical_unit_fail(err: Error) -> Result<()> { + loop { + error!("a critical unit failed to start, continue? [y/n]: "); + + let _ = stdout().flush(); + + let mut input = String::new(); + + match stdin().read_line(&mut input) { + Ok(_) => match input.chars().next() { + Some('y') => return Ok(()), + Some('n') => return Err(err), + _ => {} + }, + Err(e) => return Err(e).context("failed to read stdin"), + } + } +} + +#[cfg(feature = "testing")] +fn critical_unit_fail(err: Error) -> Result<()> { + kanit_tap::bail(Some(err.to_string())); + + Err(err) +} + +async fn start_unit(tuple: (usize, RcUnit)) -> Result<Option<UnitName>> { + let (j, unit) = tuple; + + let mut unit_b = unit.borrow_mut(); + + debug!("loading unit {}", unit_b.name()); + + let id = kanit_timing::push_scope(format!("unit:{}", unit_b.name())); + + if !unit_b.prepare().await? { + warn!("failed preparations for {}", unit_b.name()); + kanit_tap::not_ok(j + 1, Some("failed preparations")); + return Ok(None); + } + + if let Err(e) = unit_b.start().await { + if e.is_recoverable() { + warn!("{e}"); + kanit_tap::not_ok(j + 1, Some(e)); + } else { + error!("{e}"); + critical_unit_fail(e)?; + } + + kanit_timing::pop_scope(id); + + return Ok(None); + } + + kanit_timing::pop_scope(id); + + kanit_tap::ok(j + 1, Some(unit_b.name())); + + debug!("finished loading unit {}", unit_b.name()); + + Ok(Some(unit_b.name().clone())) +} + +async fn start_level(i: usize, name: Box<str>) -> Result<()> { + let mut loader = Loader::obtain()?.borrow_mut(); + let level = loader.grouping.get(&name).cloned().unwrap_or_else(Vec::new); + + info!("starting level {name}"); + + let scope_str = format!("level:{name}"); + + kanit_tap::enter_subtest(Some(&scope_str)); + + let id = kanit_timing::push_scope(&scope_str); + + kanit_tap::plan(level.len()); + + for (j, group) in level.into_iter().enumerate() { + let group_str = format!("group:{j}"); + + kanit_tap::enter_subtest(Some(&group_str)); + kanit_tap::plan(group.len()); + + let handles = join_all(group.into_iter().enumerate().map(start_unit)).await; + + for handle in handles { + if let Some(n) = handle? { + loader.mark_started(n); + } + } + + kanit_tap::exit_subtest(); + kanit_tap::ok(j + 1, Some(&group_str)); + } + + kanit_timing::pop_scope(id); + + kanit_tap::exit_subtest(); + kanit_tap::ok(i + 1, Some(&scope_str)); + + Ok(()) +} + +pub async fn start() -> Result<()> { + kanit_timing::register(); + + loader::init_loader()?; + + // include teardown as well + // sysboot, boot, default * 2 + kanit_tap::plan(6); + + let driver_task = spawn(driver()); + + start_level(0, Box::from("sysboot")).await?; + start_level(1, Box::from("boot")).await?; + start_level(2, Box::from("default")).await?; + + driver_task.cancel().await; + + { + let loader = Loader::obtain()?; + + let _ = loader.borrow().save(); + } + + Ok(()) +} + +async fn stop_unit(tuple: (usize, RcUnit)) -> Result<()> { + let (j, unit) = tuple; + + let mut unit_b = unit.borrow_mut(); + + debug!("unloading unit {}", unit_b.name()); + + match unit_b.stop().await { + Ok(_) => kanit_tap::ok(j + 1, Some(unit_b.name())), + Err(e) => { + kanit_tap::not_ok(j + 1, Some(unit_b.name())); + + if e.is_recoverable() { + warn!("{e}") + } else { + error!("{e}") // won't stop still + } + } + } + + debug!("finished unloading unit {}", unit_b.name()); + + Ok(()) +} + +async fn teardown_level(total: usize, i: usize, name: Box<str>) -> Result<()> { + let loader = Loader::obtain()?.borrow(); + let level = loader.grouping.get(&name).cloned().unwrap_or_else(Vec::new); + + info!("stopping level {name}"); + + let scope_str = format!("level:{name}-stop"); + kanit_tap::enter_subtest(Some(&scope_str)); + + kanit_tap::plan(level.len()); + + for (j, group) in level.into_iter().enumerate() { + let group_str = format!("group:{j}"); + + kanit_tap::enter_subtest(Some(&group_str)); + kanit_tap::plan(group.len()); + + let handles = join_all(group.into_iter().enumerate().map(stop_unit)).await; + + for handle in handles { + handle?; + } + + kanit_tap::exit_subtest(); + kanit_tap::ok(j + 1, Some(&group_str)); + } + + kanit_tap::exit_subtest(); + kanit_tap::ok(total + (total - i), Some(&scope_str)); + + Ok(()) +} + +pub async fn teardown() -> Result<()> { + let driver_task = spawn(driver()); + + teardown_level(3, 2, Box::from("default")).await?; + teardown_level(3, 1, Box::from("boot")).await?; + teardown_level(3, 0, Box::from("sysboot")).await?; + + driver_task.cancel().await; + + Ok(()) +} diff --git a/crates/rc/src/event.rs b/crates/rc/src/event.rs @@ -0,0 +1,143 @@ +use std::collections::HashSet; + +use kanit_common::error::{Context, Result, StaticError}; +use kanit_unit::UnitName; +use log::warn; + +use crate::loader::Loader; + +async fn modify_service(start: bool, level: usize, name: &[u8]) -> Result<()> { + // this is horrible but it makes the compiler happy + let (diff, groups) = { + let loader = Loader::obtain()?.borrow(); + + let unit_name = UnitName::from(String::from_utf8_lossy(name)); + + if (start && loader.is_started(level, &unit_name)) + || (!start && !loader.is_started(level, &unit_name)) + { + Err(StaticError("unit already started/stopped"))?; + } + + // rebuild database and diff to find out what needs to start + let mut db = loader.database().clone(); + + { + let enabled = db.enabled.get_mut(level).context("failed to get level")?; + + if start { + enabled.insert(unit_name.clone()); + } else { + enabled.remove(&unit_name); + } + } + + db.rebuild_levels()?; + + if !db.unit_infos.contains_key(&unit_name) { + Err(StaticError("failed to find unit in database"))?; + } + + let started = loader.started.get(level).context("failed to get level")?; + + // unwrap: we `get_mut` earlier + let enabled = db.enabled.get(level).unwrap(); + + let (diff, levels) = if start { + ( + enabled.difference(started).cloned().collect::<HashSet<_>>(), + db.levels, + ) + } else { + ( + started.difference(enabled).cloned().collect::<HashSet<_>>(), + loader.database().clone().levels, + ) + }; + + let groups = levels + .get(level) + .context("failed to get level")? + .get_order(); + + (diff, groups.clone()) + }; + + let mut loader = Loader::obtain()?.borrow_mut(); + + if start { + for group in groups { + for unit_n in group.iter().filter(|u| diff.contains(*u)) { + let unit = loader.get_unit(unit_n).context("failed to get unit")?; + + let mut unit_b = unit.borrow_mut(); + + if !unit_b.prepare().await? { + warn!("failed preparations for {}", unit_b.name()); + + continue; + } + + if let Err(e) = unit_b.start().await { + warn!("{e}"); + return Err(e); + } else { + loader.mark_started(level, unit_b.name()); + } + } + } + } else { + for group in groups.iter().rev() { + for unit_n in group.iter().filter(|u| diff.contains(*u)) { + let unit = loader.get_unit(unit_n).context("failed to get unit")?; + + let mut unit_b = unit.borrow_mut(); + + if let Err(e) = unit_b.stop().await { + warn!("{e}"); + return Err(e); + } else { + loader.mark_stopped(level, &unit_b.name()); + } + } + } + } + + Ok(()) +} + +pub async fn event(data: Vec<u8>) -> Result<()> { + if data.starts_with(b"db-reload") { + let mut loader = Loader::obtain()?.borrow_mut(); + + let ev_lock = loader.ev_lock.clone(); + + let lock = ev_lock.lock().await; + + loader.reload()?; + + drop(lock); + } else if data.starts_with(b"start") || data.starts_with(b"stop") { + // start:tty:1 + let mut parts = data.split(|b| *b == b':'); + + parts.next(); // forward start/stop + + let name = parts.next().context("failed to get name")?; + + let level = String::from_utf8_lossy(parts.next().context("failed to get level")?) + .trim() + .parse::<usize>() + .context("failed to parse level")?; + + let ev_lock = Loader::obtain()?.borrow().ev_lock.clone(); + + let lock = ev_lock.lock().await; // get lock to ensure no one else is using the loader + + modify_service(data.starts_with(b"start"), level, name).await?; + + drop(lock); + } + + Ok(()) +} diff --git a/crates/rc/src/lib.rs b/crates/rc/src/lib.rs @@ -0,0 +1,14 @@ +// since the loader is locked in the ev loop, only 1 reference to a unit can be held at await points +// in start/teardown, only 1 reference is held as well +#![allow(clippy::await_holding_refcell_ref)] + +pub use control::*; + +mod control; +// mod event; +mod loader; + +// TODO; this will be revamped anyway +pub async fn event(_data: Vec<u8>) -> kanit_common::error::Result<()> { + Ok(()) +} diff --git a/crates/rc/src/loader/mod.rs b/crates/rc/src/loader/mod.rs @@ -0,0 +1,389 @@ +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::os::unix::fs::MetadataExt; +use std::sync::OnceLock; + +use log::{trace, warn}; +use send_wrapper::SendWrapper; +use sha2::{Digest, Sha256}; +use walkdir::WalkDir; + +use kanit_common::constants; +use kanit_common::error::{Context, Result, StaticError}; +use kanit_unit::formats::{DependencyGrouping, Unit}; +use kanit_unit::{RcUnit, UnitName, wrap_unit}; +use sort::{SortableUnit, obtain_load_order}; + +mod sort; + +static LOADER: OnceLock<SendWrapper<RefCell<Loader>>> = OnceLock::new(); + +pub struct Loader { + pub started: HashSet<UnitName>, // TODO; should become statuses + pub enabled: HashMap<Box<str>, HashSet<UnitName>>, + pub grouping: HashMap<Box<str>, Vec<Vec<RcUnit>>>, + pub units: HashMap<UnitName, RcUnit>, + // pub ev_lock: Rc<Mutex<()>>, // i am pro at rust +} + +enum UnitLoad { + FailedRead(String), + FailedParse(String), + Loaded(Box<Unit>), +} + +#[derive(Copy, Clone, Eq, PartialEq)] +enum DrillRound { + All, + Positional, + Needs, +} + +impl Loader { + // initialize populates the loader with units and if they're enabled + // it won't create the load order as `units` will add more units + pub fn initialize() -> Result<()> { + let mut loader = Loader { + started: HashSet::new(), + enabled: HashMap::new(), + grouping: HashMap::new(), + units: HashMap::new(), + // ev_lock: Rc::new(Mutex::new(())), + }; + + loader.reload()?; + + let _ = LOADER.set(SendWrapper::new(RefCell::new(loader))); + + Ok(()) + } + + // TODO; should we separate reload + pub fn reload(&mut self) -> Result<()> { + // TODO; async? + self.units = fs::read_dir(constants::KAN_UNIT_DIR) + .context("failed to read units directory")? + .filter_map(|e| e.ok()) + .map(|f| { + fs::read_to_string(f.path()) + .map(|c| { + ( + UnitName::from(f.file_name().to_string_lossy()), + c.parse::<Unit>() + .map(|u| UnitLoad::Loaded(Box::new(u))) + .unwrap_or_else(|e| UnitLoad::FailedParse(e.to_string())), + ) + }) + .unwrap_or_else(|e| { + ( + UnitName::from(f.file_name().to_string_lossy()), + UnitLoad::FailedRead(e.to_string()), + ) + }) + }) + .filter_map(|(name, load)| match load { + UnitLoad::FailedRead(e) => { + warn!("failed to read unit {name}: {e}"); + None + } + UnitLoad::FailedParse(e) => { + warn!("failed to parse unit {name}: {e}"); + None + } + UnitLoad::Loaded(u) => { + trace!("loaded unit {name}"); + Some((name.clone(), wrap_unit((name, *u)))) + } + }) + .collect::<HashMap<_, _>>(); + + // technically we don't care about the contents of the file + self.enabled = fs::read_dir(constants::KAN_ENABLED_DIR) + .context("failed to read enabled dir")? + .filter_map(|e| { + e.ok().and_then(|f| { + f.file_type() + .map(|t| t.is_dir()) + .ok() + .filter(|t| *t) + .map(|_| f) + }) + }) + .filter_map(|f| { + fs::read_dir(f.path()) + .ok() + .map(|r| { + r.filter_map(|e| e.ok()) + .map(|e| UnitName::from(e.file_name().to_string_lossy())) + .collect::<HashSet<_>>() + }) + .map(|e| (Box::from(f.file_name().to_string_lossy()), e)) + }) + .collect::<HashMap<_, _>>(); + + Ok(()) + } + + // rebuilding levels is such an inefficient process + // that's why we cache + // we attempt: + // * all requirements fulfilled (needs, wants, before, after) + // * no positional (needs, wants) + // * only needs + // give up if it fails + + // recursively + fn get_edges(unit: &SortableUnit, round: DrillRound) -> Vec<UnitName> { + let deps = unit.dependencies.clone(); + + match round { + DrillRound::Needs => deps.needs.clone(), + DrillRound::Positional => deps + .needs + .iter() + .chain(deps.wants.iter()) + .cloned() + .collect(), + DrillRound::All => deps + .needs + .iter() + .chain(deps.wants.iter()) + .chain(deps.before.iter()) + .chain(deps.after.iter()) + .cloned() + .collect(), + } + } + + fn drill( + to_drill: Vec<UnitName>, + info: &HashMap<UnitName, SortableUnit>, + round: DrillRound, + seen: &mut HashSet<UnitName>, + ) -> Result<()> { + for unit in to_drill { + if seen.contains(&unit) { + continue; + } + + seen.insert(unit.clone()); + + let unit_info = info + .get(&unit) + .with_context(move || format!("failed to find unit `{unit}`"))?; + + Self::drill(Self::get_edges(unit_info, round), info, round, seen)?; + } + + Ok(()) + } + + fn rebuild_level( + &self, + info: &HashMap<UnitName, SortableUnit>, + enabled: &HashSet<UnitName>, + ) -> Result<Vec<Vec<RcUnit>>> { + let mut to_load = HashSet::new(); + + let rounds = [DrillRound::All, DrillRound::Positional, DrillRound::Needs]; + + for round in rounds { + if Self::drill(enabled.iter().cloned().collect(), info, round, &mut to_load).is_err() { + continue; + } + + let units = to_load + .iter() + .filter_map(|n| info.get(n)) + .cloned() + .collect(); + + match obtain_load_order(units) { + Ok(order) => { + return Ok(order + .iter() + .map(|n| { + n.iter() + .map(|m| self.units.get(&m.name).cloned().unwrap()) + .collect::<Vec<_>>() + }) + .collect::<Vec<_>>()); + } + Err(e) => { + if round == DrillRound::Needs { + return Err(e); + } else { + continue; + } + } + } + } + + unreachable!() + } + + fn hash_enable_dir() -> [u8; 32] { + WalkDir::new(constants::KAN_ENABLED_DIR) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + .filter_map(|e| e.metadata().ok().map(|e| e.mtime())) + .fold(Sha256::new(), |acc, m| acc.chain_update(m.to_le_bytes())) + .finalize() + .as_slice() + .try_into() + .unwrap() // sha256 hash is always 32 bytes (32 * 8 = 256) + } + + fn try_cache_map(&mut self) -> Result<()> { + let grouping = fs::read_to_string(constants::KAN_DEPENDENCY_MAP) + .context("failed to read dependency map")? + .parse::<DependencyGrouping>() + .context("failed to parse grouping")?; + + let hash = Self::hash_enable_dir(); + + if hash.as_slice() == grouping.hash.as_slice() { + self.grouping = grouping + .groups + .into_iter() + .map(|(k, v)| -> Result<_> { + Ok(( + k, + v.into_iter() + .map(|v| { + v.into_iter() + .map(|u| { + self.units.get(&u).cloned().context("failed to find unit") + }) + .collect::<Result<Vec<_>>>() + }) + .collect::<Result<Vec<_>>>()?, + )) + }) + .collect::<Result<HashMap<_, _>>>()?; + } else { + Err(StaticError("invalid hashes"))?; + } + + Ok(()) + } + + pub fn rebuild(&mut self, force: bool) -> Result<()> { + if !force && self.try_cache_map().is_ok() { + return Ok(()); + } + + let info = self + .units + .iter() + .map(|(k, v)| (k.clone(), v.into())) + .collect::<HashMap<_, SortableUnit>>(); + + for (name, level) in self.enabled.iter() { + let grouping = self.rebuild_level(&info, level)?; + + self.grouping.insert(name.clone(), grouping); + } + + Ok(()) + } + + pub fn save(&self) -> Result<()> { + let groups = self + .grouping + .iter() + .map(|(k, v)| { + ( + k.clone(), + v.iter() + .map(|g| g.iter().map(|u| u.borrow().name()).collect()) + .collect(), + ) + }) + .collect(); + + let group = DependencyGrouping { + hash: Self::hash_enable_dir(), + groups, + }; + + fs::write(constants::KAN_DEPENDENCY_MAP, group.to_string()) + .context("failed to write dependency map")?; + + Ok(()) + } + + pub fn obtain() -> Result<&'static SendWrapper<RefCell<Self>>> { + let loader = LOADER.get().context("loader not initialized")?; + + if !loader.valid() { + Err(StaticError("cannot obtain loader from different thread"))?; + } + + Ok(loader) + } + + pub fn extend_units<T>(&mut self, iter: T) + where + T: IntoIterator<Item = (UnitName, RcUnit)>, + { + self.units.extend(iter); + } + + pub fn extend_enabled<T>(&mut self, iter: T) + where + T: IntoIterator<Item = (Box<str>, HashSet<UnitName>)>, + { + iter.into_iter().for_each(|(k, v)| { + if let Some(enabled) = self.enabled.get_mut(&k) { + enabled.extend(v); + } else { + self.enabled.insert(k, v); + } + }) + } + + pub fn mark_started(&mut self, name: UnitName) { + self.started.insert(name); + } + + // pub fn mark_stopped(&mut self, name: &UnitName) { + // self.started.remove(name); + // } + // + // pub fn is_started(&self, name: &UnitName) -> bool { + // self.started.contains(name) + // } +} + +pub fn init_loader() -> Result<()> { + Loader::initialize()?; + + let mut loader = Loader::obtain()?.borrow_mut(); + + #[cfg(feature = "units")] + { + loader.extend_units(kanit_units::units().map(|u| { + let name = u.borrow().name(); + (name, u) + })); + + loader.extend_enabled(kanit_units::default_enabled()); + + for (unit, _) in loader.units.iter() { + trace!("has unit {unit}"); + } + + for (level, group) in loader.enabled.iter() { + group + .iter() + .for_each(|unit| trace!("enabling unit {level}->{unit}")); + } + + loader.rebuild(false)?; + } + + Ok(()) +} diff --git a/crates/rc/src/loader/sort.rs b/crates/rc/src/loader/sort.rs @@ -0,0 +1,252 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use kanit_common::error::{Result, StaticError, WithError}; +use kanit_unit::{Dependencies, RcUnit, UnitName}; + +#[derive(Debug, Clone)] +pub struct SortableUnit { + pub name: UnitName, + pub dependencies: Rc<Dependencies>, +} + +impl From<&RcUnit> for SortableUnit { + fn from(unit: &RcUnit) -> Self { + SortableUnit { + name: unit.borrow().name(), + dependencies: Rc::new(unit.borrow().dependencies()), + } + } +} + +#[derive(Clone, Debug)] +struct Edge<T> { + from: Rc<RefCell<Node<T>>>, + deleted: bool, +} + +#[derive(Debug)] +struct Node<T> { + data: T, + edges: Vec<Rc<RefCell<Edge<T>>>>, + idx: usize, +} + +pub fn obtain_load_order(units: Vec<SortableUnit>) -> Result<Vec<Vec<SortableUnit>>> { + let mut nodes = vec![]; + let mut map = HashMap::new(); + + for (idx, unit) in units.iter().enumerate() { + let node = Rc::new(RefCell::new(Node { + data: unit.clone(), + edges: vec![], + idx, + })); + + nodes.push(node.clone()); + map.insert(unit.name.clone(), node); + } + + for node in nodes.iter() { + let mut node_b = node.borrow_mut(); + let dependencies = node_b.data.dependencies.clone(); + + for dep in dependencies.needs.iter() { + if let Some(unit) = map.get(&dep.clone()) { + let edge = Rc::new(RefCell::new(Edge { + from: node.clone(), + deleted: false, + })); + + node_b.edges.push(edge.clone()); + unit.borrow_mut().edges.push(edge); + } else { + let dep = dep.to_string(); + Err(WithError::with(move || { + format!("failed to find needed dependency `{dep}`") + }))?; + } + } + + for dep in dependencies.wants.iter().chain(dependencies.after.iter()) { + if let Some(unit) = map.get(dep) { + let edge = Rc::new(RefCell::new(Edge { + from: node.clone(), + deleted: false, + })); + + node_b.edges.push(edge.clone()); + unit.borrow_mut().edges.push(edge); + } + } + + for before in dependencies.before.iter() { + if let Some(unit) = map.get(before) { + let mut unit_b = unit.borrow_mut(); + + let edge = Rc::new(RefCell::new(Edge { + from: unit.clone(), + deleted: false, + })); + + node_b.edges.push(edge.clone()); + unit_b.edges.push(edge); + } + } + } + + let mut order = vec![]; + + while !nodes.is_empty() { + let starting_amount = nodes.len(); + let nodes_c = nodes.clone(); + + let mut without_incoming: Vec<_> = nodes_c + .iter() + .filter(|n| { + !n.borrow().edges.iter().any(|e| { + let e_b = e.borrow(); + !e_b.deleted && e_b.from.borrow().idx == n.borrow().idx + }) + }) + .collect(); + + let mut round = vec![]; + + while let Some(node) = without_incoming.pop() { + round.push(node.clone()); + + for edge in node.borrow().edges.iter() { + let mut edge_b = edge.borrow_mut(); + edge_b.deleted = true; + } + + if let Some(pos) = nodes + .iter() + .position(|n| n.borrow().idx == node.borrow().idx) + { + nodes.remove(pos); + } + } + + order.push(round); + + if starting_amount == nodes.len() { + Err(StaticError("cyclic dependency detected"))?; + } + } + + Ok(order + .iter() + .map(|r| r.iter().map(|n| n.borrow().data.clone()).collect()) + .collect()) +} + +// TODO; proper testing +// these are more like a scratch pad +#[cfg(test)] +mod tests { + use async_trait::async_trait; + + use kanit_unit::{Dependencies, RcUnit, Unit, UnitName, wrap_unit}; + + use super::*; + + struct NullUnit(&'static str, Dependencies); + + #[async_trait] + impl Unit for NullUnit { + fn name(&self) -> UnitName { + UnitName::from(self.0) + } + + fn dependencies(&self) -> Dependencies { + self.1.clone() + } + + async fn start(&mut self) -> Result<()> { + Ok(()) + } + } + + fn print_plan(units: Vec<Vec<SortableUnit>>) { + for (i, order) in units.iter().enumerate() { + println!("group {i}"); + order.iter().for_each(|s| println!("|> {}", s.name)); + } + } + + fn to_unit_info(units: Vec<RcUnit>) -> Vec<SortableUnit> { + units.iter().map(SortableUnit::from).collect() + } + + #[test] + fn simple_generate_order() { + let c = NullUnit("c", Dependencies::new()); + let d = NullUnit("d", Dependencies::new()); + let b = NullUnit("b", Dependencies::new().need(d.name()).clone()); + let a = NullUnit( + "a", + Dependencies::new().need(b.name()).need(c.name()).clone(), + ); + + let units = vec![wrap_unit(a), wrap_unit(b), wrap_unit(c), wrap_unit(d)]; + + if let Ok(order) = obtain_load_order(to_unit_info(units)) { + print_plan(order); + } + } + + #[test] + fn complex_generate_order() { + let f = NullUnit("2", Dependencies::new()); + let g = NullUnit("9", Dependencies::new()); + let h = NullUnit("10", Dependencies::new()); + let e = NullUnit("8", Dependencies::new().need(g.name()).clone()); + + let c = NullUnit( + "3", + Dependencies::new().need(e.name()).need(h.name()).clone(), + ); + let d = NullUnit( + "11", + Dependencies::new() + .need(f.name()) + .need(g.name()) + .need(h.name()) + .clone(), + ); + let b = NullUnit( + "7", + Dependencies::new().need(d.name()).need(e.name()).clone(), + ); + + let a = NullUnit("5", Dependencies::new().need(d.name()).clone()); + + let units = vec![ + wrap_unit(a), + wrap_unit(b), + wrap_unit(c), + wrap_unit(d), + wrap_unit(e), + wrap_unit(f), + wrap_unit(g), + wrap_unit(h), + ]; + + if let Ok(order) = obtain_load_order(to_unit_info(units)) { + print_plan(order); + } + } + + #[test] + fn cyclic_chain() { + let a = NullUnit("a", Dependencies::new().need(UnitName::from("b")).clone()); + let b = NullUnit("b", Dependencies::new().need(UnitName::from("a")).clone()); + + let units = vec![wrap_unit(a), wrap_unit(b)]; + + assert!(obtain_load_order(to_unit_info(units)).is_err()); + } +} diff --git a/crates/supervisor/Cargo.toml b/crates/supervisor/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "kanit-supervisor" +version.workspace = true +edition.workspace = true + +[features] +cli = ["dep:xflags", "nix/signal"] + +[dependencies.async-process] +version = "2.2" + +[dependencies.xflags] +version = "0.3" +optional = true + +[dependencies.nix] +version = "0.29" +features = ["user", "sched", "fs"] + +[dependencies.kanit-common] +path = "../common" diff --git a/crates/supervisor/src/builder.rs b/crates/supervisor/src/builder.rs @@ -0,0 +1,161 @@ +use async_process::{Child, Command}; + +use kanit_common::error::{Context, ErrorKind, Result}; + +use crate::{RestartPolicy, Supervisor}; + +pub struct SupervisorBuilder(Supervisor); + +#[allow(dead_code)] +impl SupervisorBuilder { + pub fn new<S: ToString, I: IntoIterator<Item = S>>(cmd: S, args: I) -> Self { + Self(Supervisor { + exec: cmd.to_string(), + args: args.into_iter().map(|s| s.to_string()).collect(), + restart_delay: None, + restart_attempts: None, + restart_policy: None, + cgroup: None, + pwd: None, + root: None, + env: vec![], + group: None, + user: None, + stdout: None, + stderr: None, + }) + } + + pub fn from_supervisor(supervisor: Supervisor) -> Self { + Self(supervisor) + } + + pub fn build(self) -> Supervisor { + self.0 + } + + pub fn spawn(self) -> Result<Child> { + let mut args = vec![]; + + if let Some(delay) = self.0.restart_delay { + args.push("-d".to_string()); + args.push(delay.to_string()); + } + + if let Some(attempts) = self.0.restart_attempts { + args.push("-a".to_string()); + args.push(attempts.to_string()); + } + + if let Some(policy) = self.0.restart_policy { + args.push("-P".to_string()); + args.push(policy.to_string()); + } + + if let Some(pwd) = self.0.pwd { + args.push("-p".to_string()); + args.push(pwd); + } + + if let Some(root) = self.0.root { + args.push("-r".to_string()); + args.push(root); + } + + for pair in self.0.env { + args.push("-e".to_string()); + args.push(pair); + } + + if let Some(group) = self.0.group { + args.push("-g".to_string()); + args.push(group); + } + + if let Some(user) = self.0.user { + args.push("-u".to_string()); + args.push(user); + } + + if let Some(stdout) = self.0.stdout { + args.push("--stdout".to_string()); + args.push(stdout); + } + + if let Some(stderr) = self.0.stderr { + args.push("--stderr".to_string()); + args.push(stderr); + } + + if let Some(cgroup) = self.0.cgroup { + args.push("--cgroup".to_string()); + args.push(cgroup); + } + + args.push("--".to_string()); + + args.push(self.0.exec); + + args.extend_from_slice(&self.0.args); + + Command::new("kanit-supervisor") + .args(args) + .spawn() + .context_kind("failed to spawn supervisor", ErrorKind::Recoverable) + } + + pub fn restart_delay(mut self, delay: u64) -> Self { + self.0.restart_delay = Some(delay); + self + } + + pub fn restart_attempts(mut self, attempts: u64) -> Self { + self.0.restart_attempts = Some(attempts); + self + } + + pub fn restart_policy(mut self, policy: RestartPolicy) -> Self { + self.0.restart_policy = Some(policy); + self + } + + pub fn cgroup(mut self, cgroup: String) -> Self { + self.0.cgroup = Some(cgroup); + self + } + + pub fn pwd(mut self, pwd: String) -> Self { + self.0.pwd = Some(pwd); + self + } + + pub fn root(mut self, root: String) -> Self { + self.0.root = Some(root); + self + } + + pub fn env(mut self, key: String, value: String) -> Self { + self.0.env.push(format!("{key}={value}")); + self + } + + pub fn group(mut self, group: String) -> Self { + self.0.group = Some(group); + self + } + + pub fn user(mut self, user: String) -> Self { + self.0.user = Some(user); + self + } + + pub fn stdout(mut self, stdout: String) -> Self { + self.0.stdout = Some(stdout); + self + } + + pub fn stderr(mut self, stderr: String) -> Self { + self.0.stderr = Some(stderr); + self + } +} diff --git a/crates/supervisor/src/cli.rs b/crates/supervisor/src/cli.rs @@ -0,0 +1,124 @@ +use std::fs::{read_to_string, write}; +use std::os::unix::prelude::ExitStatusExt; +use std::path::Path; +use std::process; +use std::process::{ExitCode, ExitStatus}; + +use nix::errno::Errno; +use nix::libc::pid_t; +use nix::sched::{CloneFlags, unshare}; +use nix::sys::signal; +use nix::sys::signal::{Signal, kill}; +use nix::sys::signalfd::{SfdFlags, SigSet, SignalFd}; +use nix::sys::stat::Mode; +use nix::sys::wait::{WaitPidFlag, WaitStatus, waitpid}; +use nix::unistd::{Pid, mkdir}; + +use kanit_common::error::{Context, Result}; + +use crate::{Supervisor, spawn, spawn_restart}; + +fn cgroup_signal(name: &str, signal: Signal) -> Result<()> { + let procs_path = Path::new("/sys/fs/cgroup/system.slice") + .join(name) + .join("cgroup.procs"); + + read_to_string(procs_path) + .context("failed to read procs")? + .split('\n') + .filter_map(|pid| pid.parse::<u32>().ok()) + .try_for_each(|pid| { + if pid == process::id() { + return Ok(()); + } + + let _ = kill(Pid::from_raw(pid as pid_t), signal); + + Ok(()) + }) +} + +pub fn handle_cli() -> ExitCode { + match Supervisor::from_env() { + Ok(mut cfg) => { + let mut mask = SigSet::empty(); + mask.add(signal::SIGCHLD); + mask.add(signal::SIGTERM); + mask.thread_block().unwrap(); + + let sfd = SignalFd::with_flags(&mask, SfdFlags::empty()).unwrap(); + + if let Some(ref cgroup) = cfg.cgroup { + unshare(CloneFlags::CLONE_NEWCGROUP).expect("unshare cgroup"); + + let supervisor_cgroup = Path::new("/sys/fs/cgroup/system.slice").join(cgroup); + + mkdir( + &supervisor_cgroup, + Mode::S_IRWXU | Mode::S_IRGRP | Mode::S_IRUSR, + ) + .expect("create directory"); + + write( + supervisor_cgroup.join("cgroup.procs"), + process::id().to_string(), + ) + .expect("write cgroup proc"); + } + + #[allow(clippy::zombie_processes)] // we use `waitpid` and not `.wait` + let mut child = spawn(&cfg).expect("spawn child"); + + loop { + match sfd.read_signal() { + Ok(Some(sig)) => match Signal::try_from(sig.ssi_signo as i32) { + Ok(Signal::SIGCHLD) => { + loop { + let pid = waitpid(None, Some(WaitPidFlag::WNOHANG)); + + match pid { + Ok(WaitStatus::StillAlive) => break, + Err(Errno::ECHILD) => break, + Err(e) => { + eprintln!("failed to waitpid: {e}"); + return ExitCode::FAILURE; + } + _ => {} + } + } + + if let Some(c) = + spawn_restart(&mut cfg, ExitStatus::from_raw(sig.ssi_status), true) + .expect("restart child") + { + child = c; + } else { + return ExitCode::SUCCESS; + } + } + Ok(Signal::SIGTERM) => { + child.kill().expect("kill child"); + if let Some(attempts) = cfg.restart_attempts { + cfg.restart_attempts = Some(attempts + 1); + } + if let Some(ref cgroup) = cfg.cgroup { + cgroup_signal(cgroup.as_ref(), Signal::SIGKILL) + .expect("kill child"); + } + } + _ => {} + }, + Ok(None) => unreachable!(), + Err(e) => { + eprintln!("{e}"); + return ExitCode::FAILURE; + } + } + } + } + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/supervisor/src/flags.rs b/crates/supervisor/src/flags.rs @@ -0,0 +1,127 @@ +use std::fmt::Formatter; +use std::str::FromStr; +use std::{error, fmt}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RestartPolicy { + Never, + Always, + OnSuccess, + OnFailure, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ParseRestartModeError; + +impl fmt::Display for ParseRestartModeError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!( + f, + "expected `never`, `always`, `on-success`, or `on-failure`" + ) + } +} + +impl error::Error for ParseRestartModeError {} + +impl FromStr for RestartPolicy { + type Err = ParseRestartModeError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s { + "never" => Ok(Self::Never), + "always" => Ok(Self::Always), + "on-success" => Ok(Self::OnSuccess), + "on-failure" => Ok(Self::OnFailure), + _ => Err(ParseRestartModeError), + } + } +} + +impl fmt::Display for RestartPolicy { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Never => write!(f, "never"), + Self::Always => write!(f, "always"), + Self::OnSuccess => write!(f, "on-success"), + Self::OnFailure => write!(f, "on-failure"), + } + } +} + +#[cfg(feature = "cli")] +xflags::xflags! { + src "./src/flags.rs" + + cmd supervisor { + /// Delay used before restarting an exited process. + optional -d, --restart-delay delay: u64 + /// Amount of times to restart a process before giving up. + optional -a, --restart-attempts attempts: u64 + /// Policy to use before restarting, either `never`, `always`, `on-success`, or `on-failure`. + optional -P, --restart-policy policy: RestartPolicy + /// Set the working directory. + optional -p, --pwd pwd: String + /// Set the root directory. + optional -r, --root root: String + /// Set an environment variable (NAME=VAR). + repeated -e, --env pair: String + /// Set the process group. + optional -g, --group gid: String + /// Set the process user. + optional -u, --user uid: String + /// Redirect stdout to path. + optional --stdout path: String + /// Redirect stderr to path. + optional --stderr path: String + /// Cgroup to create. + optional --cgroup cgroup: String + /// Command to execute. + required exec: String + /// Arguments passed to the command. + repeated args: String + } +} + +#[derive(Clone, Default)] +// TODO; paths should be paths +// impl needs to be marked `#[cfg(feature = "cli")]` +// generated start +// The following code is generated by `xflags` macro. +// Run `env UPDATE_XFLAGS=1 cargo build` to regenerate. +#[derive(Debug)] +pub struct Supervisor { + pub exec: String, + pub args: Vec<String>, + + pub restart_delay: Option<u64>, + pub restart_attempts: Option<u64>, + pub restart_policy: Option<RestartPolicy>, + pub cgroup: Option<String>, + pub pwd: Option<String>, + pub root: Option<String>, + pub env: Vec<String>, + pub group: Option<String>, + pub user: Option<String>, + pub stdout: Option<String>, + pub stderr: Option<String>, +} + +#[cfg(feature = "cli")] +impl Supervisor { + #[allow(dead_code)] + pub fn from_env_or_exit() -> Self { + Self::from_env_or_exit_() + } + + #[allow(dead_code)] + pub fn from_env() -> xflags::Result<Self> { + Self::from_env_() + } + + #[allow(dead_code)] + pub fn from_vec(args: Vec<std::ffi::OsString>) -> xflags::Result<Self> { + Self::from_vec_(args) + } +} +// generated end diff --git a/crates/supervisor/src/lib.rs b/crates/supervisor/src/lib.rs @@ -0,0 +1,11 @@ +pub use builder::*; +#[cfg(feature = "cli")] +pub use cli::handle_cli; +pub use flags::*; +pub use supervisor::*; + +mod builder; +#[cfg(feature = "cli")] +mod cli; +mod flags; +mod supervisor; diff --git a/crates/supervisor/src/main.rs b/crates/supervisor/src/main.rs @@ -0,0 +1,12 @@ +use std::process::ExitCode; + +#[cfg(feature = "cli")] +fn main() -> ExitCode { + kanit_supervisor::handle_cli() +} + +#[cfg(not(feature = "cli"))] +fn main() -> ExitCode { + eprintln!("supervisor compiled without command line"); + ExitCode::FAILURE +} diff --git a/crates/supervisor/src/supervisor.rs b/crates/supervisor/src/supervisor.rs @@ -0,0 +1,115 @@ +use std::fs::OpenOptions; +use std::os::unix::fs::chroot; +use std::os::unix::process::CommandExt; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::thread::sleep; +use std::time::Duration; + +use nix::unistd::{Group, User}; + +use kanit_common::error::{Context, Result}; + +use crate::flags::{RestartPolicy, Supervisor}; + +pub fn spawn_restart( + cfg: &mut Supervisor, + status: ExitStatus, + delay: bool, +) -> Result<Option<Child>> { + match cfg.restart_policy.unwrap_or(RestartPolicy::Never) { + RestartPolicy::Never => return Ok(None), + RestartPolicy::OnFailure if status.success() => return Ok(None), + RestartPolicy::OnSuccess if !status.success() => return Ok(None), + _ => {} + } + + if let Some(attempts) = cfg.restart_attempts { + if attempts == 0 { + return Ok(None); + } else { + cfg.restart_attempts = Some(attempts - 1); + } + } + + if let Some(delay_sec) = cfg.restart_delay { + if delay { + sleep(Duration::from_secs(delay_sec)) + } + } + + spawn(cfg).map(Some) +} + +pub fn spawn(cfg: &Supervisor) -> Result<Child> { + let mut cmd = Command::new(&cfg.exec); + + cmd.args(&cfg.args); + cmd.envs(cfg.env.iter().filter_map(|pair| pair.split_once('='))); + + if let Some(ref dir) = cfg.pwd { + cmd.current_dir(dir); + } + + if let Some(ref dir) = cfg.root { + let dir = dir.clone(); + + // SAFETY: we only call async-signal-safe functions (chroot) + unsafe { + cmd.pre_exec(move || chroot(&dir)); + } + } + + if let Some(ref user) = cfg.user { + let uid = user + .parse::<u32>() + .map(Some) + .unwrap_or_else(|_| { + User::from_name(user) + .map(|u| u.map(|u| u.uid.as_raw())) + .unwrap_or(None) + }) + .context("failed to parse/locate user")?; + + cmd.uid(uid); + } + + if let Some(ref group) = cfg.group { + let gid = group + .parse::<u32>() + .map(Some) + .unwrap_or_else(|_| { + Group::from_name(group) + .map(|g| g.map(|g| g.gid.as_raw())) + .unwrap_or(None) + }) + .context("failed to parse/locate group")?; + + cmd.gid(gid); + } + + if let Some(ref stdout) = cfg.stdout { + let f = OpenOptions::new() + .create(true) + .append(true) + .open(stdout) + .context("failed to open stdout")?; + + cmd.stdout(f); + } else { + cmd.stdout(Stdio::null()); + } + + if let Some(ref stderr) = cfg.stderr { + let f = OpenOptions::new() + .create(true) + .append(true) + .open(stderr) + .context("failed to open stderr")?; + + cmd.stderr(f); + } else { + cmd.stderr(Stdio::null()); + } + + cmd.spawn().context("failed to spawn child") +} diff --git a/crates/unit/Cargo.toml b/crates/unit/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "kanit-unit" +version.workspace = true +edition.workspace = true + +[dependencies.log] +version = "0.4" + +[dependencies.base64] +version = "0.22" + +[dependencies.nix] +version = "0.29" +features = ["sched", "signal"] + +[dependencies.blocking] +version = "1.6" + +[dependencies.nom] +version = "7.1" + +[dependencies.async-trait] +version = "0.1" + +[dependencies.async-fs] +version = "2.1" + +[dependencies.send_wrapper] +version = "0.6" + +[dependencies.kanit-supervisor] +path = "../supervisor" + +[dependencies.kanit-common] +path = "../common" diff --git a/crates/unit/src/dependencies.rs b/crates/unit/src/dependencies.rs @@ -0,0 +1,54 @@ +// A needs B | A -> B +// A uses B | <ignored> +// A wants B | A -> B (if impossible tree, it will be discarded) +// A before B | B -> A +// A after B | A -> B + +use crate::UnitName; + +#[derive(Default, Debug, Clone)] +pub struct Dependencies { + /// The unit requires a previous unit to be started before it. + /// The unit will fail to start if any of its needs fail to start. + pub needs: Vec<UnitName>, + /// The unit uses another unit but doesn't require it. + /// The used unit will not be started, however. + pub uses: Vec<UnitName>, + /// Similar to `uses` with the exception that wanted unit will be started. + pub wants: Vec<UnitName>, + /// The unit should run before another unit. + pub before: Vec<UnitName>, + /// The unit should run after another unit. + pub after: Vec<UnitName>, +} + +impl Dependencies { + pub fn new() -> Self { + Self::default() + } + + pub fn need(&mut self, dependency: UnitName) -> &mut Self { + self.needs.push(dependency); + self + } + + pub fn uses(&mut self, dependency: UnitName) -> &mut Self { + self.uses.push(dependency); + self + } + + pub fn want(&mut self, dependency: UnitName) -> &mut Self { + self.wants.push(dependency); + self + } + + pub fn before(&mut self, dependency: UnitName) -> &mut Self { + self.before.push(dependency); + self + } + + pub fn after(&mut self, dependency: UnitName) -> &mut Self { + self.after.push(dependency); + self + } +} diff --git a/crates/unit/src/formats/config.rs b/crates/unit/src/formats/config.rs @@ -0,0 +1,201 @@ +// generic config handling +// # comment +// [Header] +// key = [a-zA-Z_][a-zA-Z_-0-9]* + +use std::collections::HashMap; + +use nom::IResult; +use nom::branch::alt; +use nom::bytes::complete::{is_a, tag}; +use nom::character::complete::{ + alpha1, alphanumeric1, char, multispace0, multispace1, not_line_ending, +}; +use nom::combinator::{all_consuming, map, recognize, value}; +use nom::multi::many0; +use nom::sequence::{delimited, pair, separated_pair}; + +pub fn config_file(input: &str) -> IResult<&str, HashMap<&str, HashMap<&str, &str>>> { + all_consuming(map(pair(body, many0(category_body)), |(root, mut rest)| { + rest.push((".root", root)); + rest.into_iter().collect() + }))(input) +} + +fn category_body(input: &str) -> IResult<&str, (&str, HashMap<&str, &str>)> { + pair(category, body)(input) +} + +fn category(input: &str) -> IResult<&str, &str> { + delimited(char('['), identifier, char(']'))(input) +} + +fn body(input: &str) -> IResult<&str, HashMap<&str, &str>> { + map(many0(delimited(ignored, key_value, ignored)), |map| { + map.into_iter().collect() + })(input) +} + +fn ignored(input: &str) -> IResult<&str, ()> { + value((), many0(alt((comment, value((), multispace1)))))(input) +} + +fn comment(input: &str) -> IResult<&str, ()> { + value((), pair(char('#'), not_line_ending))(input) +} + +fn key_value(input: &str) -> IResult<&str, (&str, &str)> { + separated_pair( + identifier, + delimited(multispace0, char('='), multispace0), + not_line_ending, + )(input) +} + +fn identifier(input: &str) -> IResult<&str, &str> { + recognize(pair( + alt((alpha1, tag("_"))), + many0(alt((alphanumeric1, is_a("-_")))), + ))(input) +} + +#[cfg(test)] +mod tests { + use nom::error::Error; + + use crate::parser_test; + + use super::*; + + #[test] + fn parse_identifier() -> Result<(), Error<&'static str>> { + parser_test!( + identifier, + [ + "foo" => "foo", + "foo2" => "foo2", + "foo_bar" => "foo_bar", + "foo-bar" => "foo-bar" + ] + ); + + Ok(()) + } + + #[test] + fn parse_key_value() -> Result<(), Error<&'static str>> { + parser_test!( + key_value, + [ + "foo=bar" => ("foo", "bar"), + "foo = bar" => ("foo", "bar") + ] + ); + + Ok(()) + } + + #[test] + fn parse_comment() -> Result<(), Error<&'static str>> { + parser_test!( + comment, + [ + "#foo" => (), + "# foo" => () + ] + ); + + Ok(()) + } + + #[test] + fn parse_ignored() -> Result<(), Error<&'static str>> { + parser_test!( + ignored, + [ + "#foo" => (), + "# foo" => (), + " " => (), + " \t \n" => () + ] + ); + + Ok(()) + } + + #[test] + fn parse_body() -> Result<(), Error<&'static str>> { + parser_test!( + body, + [ + "x = y\ny=z" => HashMap::from([ + ("x", "y"), + ("y", "z") + ]), + "x = y\n# foo\n\ny=z" => HashMap::from([ + ("x", "y"), + ("y", "z") + ]) + ] + ); + + Ok(()) + } + + #[test] + fn parse_category() -> Result<(), Error<&'static str>> { + parser_test!( + category, + [ + "[foo]" => "foo", + "[foo-bar]" => "foo-bar" + ] + ); + + Ok(()) + } + + #[test] + fn parse_category_body() -> Result<(), Error<&'static str>> { + parser_test!( + category_body, + [ + "[foo]\nx = y\n# foo\ny=z" => ( + "foo", + HashMap::from([ + ("x", "y"), + ("y", "z") + ]) + ) + ] + ); + + Ok(()) + } + + #[test] + fn parse_config_file() -> Result<(), Error<&'static str>> { + parser_test!( + config_file, + [ + "# foo\n\nx = y\n[foo]\nx = y\n# foo\ny=z" => HashMap::from([ + ( + ".root", + HashMap::from([ + ("x", "y") + ]) + ), + ( + "foo", + HashMap::from([ + ("x", "y"), + ("y", "z") + ]) + ) + ]) + ] + ); + + Ok(()) + } +} diff --git a/crates/unit/src/formats/grouping.rs b/crates/unit/src/formats/grouping.rs @@ -0,0 +1,280 @@ +// grouping + +// @<mtime> +// :sysboot +// procfs,modules,devfs +// run,clock,sysfs +// hostname,hwdrivers,mdev,rootfs +// swap +// localmount +// syslog,seed +// :boot +// :default +// getty@tty1 + +use std::collections::HashMap; +use std::fmt; +use std::fmt::Formatter; +use std::str::FromStr; + +use base64::Engine; +use base64::prelude::BASE64_STANDARD; +use nom::branch::alt; +use nom::bytes::complete::is_a; +use nom::character::complete::{alpha1, alphanumeric1, char, newline, not_line_ending}; +use nom::combinator::{all_consuming, map, map_res, opt, recognize}; +use nom::error::Error; +use nom::multi::{many0, separated_list0, separated_list1}; +use nom::sequence::{delimited, pair, preceded, terminated}; +use nom::{Finish, IResult}; + +use crate::UnitName; + +type Sha256Hash = [u8; 32]; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct DependencyGrouping { + pub hash: Sha256Hash, + pub groups: HashMap<Box<str>, Vec<Vec<UnitName>>>, +} + +impl fmt::Display for DependencyGrouping { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + writeln!(f, "@{}", BASE64_STANDARD.encode(self.hash))?; + + for (name, levels) in self.groups.iter() { + writeln!(f, ":{name}")?; + + for level in levels.iter() { + writeln!(f, "{}", level.join(","))?; + } + } + + Ok(()) + } +} + +impl FromStr for DependencyGrouping { + type Err = Error<String>; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match grouping(s).finish() { + Ok((_remaining, grouping)) => Ok(grouping), + Err(e) => Err(Error { + input: e.input.to_string(), + code: e.code, + }), + } + } +} + +fn grouping(input: &str) -> IResult<&str, DependencyGrouping> { + all_consuming(map( + pair(header, separated_list1(newline, group)), + |(hash, groups)| DependencyGrouping { + hash, + groups: groups.into_iter().collect(), + }, + ))(input) +} + +fn header(input: &str) -> IResult<&str, Sha256Hash> { + delimited(char('@'), map_res(not_line_ending, hash), newline)(input) +} + +fn hash(input: &str) -> Result<Sha256Hash, base64::DecodeSliceError> { + let mut buff: Sha256Hash = [0; 32]; + + BASE64_STANDARD.decode_slice(input, &mut buff)?; + + Ok(buff) +} + +fn group(input: &str) -> IResult<&str, (Box<str>, Vec<Vec<UnitName>>)> { + pair( + terminated(group_name, opt(newline)), + separated_list0(newline, level), + )(input) +} + +fn group_name(input: &str) -> IResult<&str, Box<str>> { + preceded( + char(':'), + map( + recognize(pair(alpha1, many0(alt((alphanumeric1, is_a("-_")))))), + Box::from, + ), + )(input) +} + +fn level(input: &str) -> IResult<&str, Vec<UnitName>> { + separated_list1(char(','), unit_name)(input) +} + +fn unit_name(input: &str) -> IResult<&str, UnitName> { + map( + recognize(pair(alpha1, many0(alt((alphanumeric1, is_a("-_.@")))))), + UnitName::from, + )(input) +} + +#[cfg(test)] +mod tests { + use crate::parser_test; + + use super::*; + + // TODO; we should have negative tests + #[test] + fn parse_unit_name() -> Result<(), Error<&'static str>> { + parser_test!( + unit_name, + [ + "foo" => UnitName::from("foo"), + "foo2" => UnitName::from("foo2"), + "foo_bar" => UnitName::from("foo_bar"), + "foo-bar" => UnitName::from("foo-bar"), + "foo@bar" => UnitName::from("foo@bar") + ] + ); + + Ok(()) + } + + #[test] + fn parse_level() -> Result<(), Error<&'static str>> { + parser_test!( + level, + [ + "foo,bar,baz" => vec![ + UnitName::from("foo"), + UnitName::from("bar"), + UnitName::from("baz"), + ], + "foo" => vec![ + UnitName::from("foo") + ] + ] + ); + + Ok(()) + } + + #[test] + fn parse_group_name() -> Result<(), Error<&'static str>> { + parser_test!( + group_name, + [ + ":foo" => Box::from("foo"), + ":foo2" => Box::from("foo2"), + ":foo_bar" => Box::from("foo_bar"), + ":foo-bar" => Box::from("foo-bar") + ] + ); + + Ok(()) + } + + #[test] + fn parse_group() -> Result<(), Error<&'static str>> { + parser_test!( + group, + [ + ":foo" => (Box::from("foo"), vec![]), + ":foo\nbar,baz" => ( + Box::from("foo"), + vec![vec![UnitName::from("bar"), UnitName::from("baz")]] + ) + ] + ); + + Ok(()) + } + + #[test] + fn parse_header() -> Result<(), Error<&'static str>> { + parser_test!( + header, + [ + "@AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" => [0; 32], + "@AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=\n" => [1; 32] + ] + ); + + Ok(()) + } + + #[test] + fn parse_grouping() -> Result<(), Error<&'static str>> { + parser_test!( + grouping, + [ + "@AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n:sysboot\nprocfs,modules\nrun" => DependencyGrouping { + hash: [0; 32], + groups: HashMap::from([ + ( + Box::from("sysboot"), + vec![ + vec![UnitName::from("procfs"), UnitName::from("modules")], + vec![UnitName::from("run")] + ] + ) + ]) + }, + "@AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=\n:sysboot\nprocfs,modules\nrun\n:default\ngetty@tty1" => DependencyGrouping { + hash: [1; 32], + groups: HashMap::from([ + ( + Box::from("sysboot"), + vec![ + vec![UnitName::from("procfs"), UnitName::from("modules")], + vec![UnitName::from("run")] + ] + ), + ( + Box::from("default"), + vec![ + vec![UnitName::from("getty@tty1")] + ] + ) + ]) + } + ] + ); + + Ok(()) + } + + #[test] + fn display_grouping() { + let grouping = DependencyGrouping { + hash: [2; 32], + groups: HashMap::from([ + ( + Box::from("sysboot"), + vec![ + vec![UnitName::from("procfs"), UnitName::from("modules")], + vec![UnitName::from("run")], + ], + ), + ( + Box::from("default"), + vec![ + vec![UnitName::from("syslog")], + vec![UnitName::from("getty@tty1")], + ], + ), + ]), + }; + + // hashmap ordering leads to 2 possible orderings + let output = grouping.to_string(); + + match output.as_str() { + "@AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=\n:default\nsyslog\ngetty@tty1\n:sysboot\nprocfs,modules\nrun\n" + | "@AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=\n:sysboot\nprocfs,modules\nrun\n:default\nsyslog\ngetty@tty1\n" => + {} + s => panic!("did not expect {s}"), + } + } +} diff --git a/crates/unit/src/formats/mod.rs b/crates/unit/src/formats/mod.rs @@ -0,0 +1,8 @@ +pub use config::config_file; +pub use grouping::DependencyGrouping; +pub use unit::{Unit, UnitKind}; + +mod config; +mod grouping; +mod testing; +mod unit; diff --git a/crates/unit/src/formats/testing.rs b/crates/unit/src/formats/testing.rs @@ -0,0 +1,25 @@ +#[cfg(test)] +#[macro_export] +macro_rules! assert_parser { + ($e:expr $(,)?) => { + assert_parser!($e, "parser did not complete") + }; + ($e:expr, $($arg:tt)+) => { + if let ::std::result::Result::Ok((i, r)) = $e { + assert!(i.is_empty(), "did not read EOF, remaining `{i}`"); + r + } else { + panic!($($arg)+) + } + }; +} + +#[cfg(test)] +#[macro_export] +macro_rules! parser_test { + ($p:expr, [ $( $i:literal => $e:expr ),* ]) => { + $( + assert_eq!($crate::assert_parser!($p($i), "failed with `{}`", $i), $e); + )* + } +} diff --git a/crates/unit/src/formats/unit.rs b/crates/unit/src/formats/unit.rs @@ -0,0 +1,397 @@ +// rewrite cmd => exec, args + +use std::collections::HashMap; +use std::fmt::Formatter; +use std::path::Path; +use std::str::FromStr; +use std::{error, fmt}; + +use async_trait::async_trait; +use blocking::unblock; +use log::warn; +use nix::libc::pid_t; +use nix::sys::signal::{Signal, kill}; +use nix::unistd::Pid; +use nom::branch::alt; +use nom::bytes::complete::{is_a, take_while, take_while1}; +use nom::character::complete::{char, multispace0, multispace1}; +use nom::combinator::{all_consuming, cut, map, map_res, value, verify}; +use nom::error::Error; +use nom::multi::{fold_many0, separated_list0}; +use nom::sequence::{preceded, separated_pair, terminated}; +use nom::{Finish, IResult}; + +use kanit_common::constants; +use kanit_common::error::{Context, Result}; +use kanit_supervisor::{RestartPolicy, Supervisor, SupervisorBuilder}; + +use crate::formats::config_file; +use crate::{Dependencies, UnitName}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnitKind { + Oneshot, + Daemon, + Builtin, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ParseUnitKindError; + +impl fmt::Display for ParseUnitKindError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "expected `oneshot`, `daemon`, or `builtin`") + } +} + +impl error::Error for ParseUnitKindError {} + +impl FromStr for UnitKind { + type Err = ParseUnitKindError; + + fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { + match s { + "oneshot" => Ok(Self::Oneshot), + "daemon" => Ok(Self::Daemon), + "builtin" => Ok(Self::Builtin), + _ => Err(ParseUnitKindError), + } + } +} + +fn unit_name_vec(val: Option<&&str>) -> Vec<UnitName> { + val.map(|s| s.split(',').map(UnitName::from).collect()) + .unwrap_or_default() +} + +impl Dependencies { + fn from_body(body: &HashMap<&str, &str>) -> Self { + Self { + before: unit_name_vec(body.get("before")), + after: unit_name_vec(body.get("after")), + needs: unit_name_vec(body.get("needs")), + uses: unit_name_vec(body.get("uses")), + wants: unit_name_vec(body.get("wants")), + } + } +} + +// this is stupid but it works so it isn't stupid +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Command(String, Vec<String>); + +impl FromStr for Command { + type Err = Error<String>; + + fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { + match parse_cmd(s).finish() { + Ok((_remaining, cmd)) => Ok(cmd), + Err(e) => Err(Error { + input: e.input.to_string(), + code: e.code, + }), + } + } +} + +// could this be lowered? +// to be fair, using supervisor with a shell allows you to get all the benefits +// the shell will automatically do splitting +fn parse_cmd(input: &str) -> IResult<&str, Command> { + all_consuming(map(separated_pair(command, multispace0, args), |(c, a)| { + Command(c, a) + }))(input) +} + +fn command(input: &str) -> IResult<&str, String> { + map(take_while1(|c| c != ' '), String::from)(input) +} + +fn args(input: &str) -> IResult<&str, Vec<String>> { + separated_list0( + multispace1, + alt((quoted_arg, map(take_while1(|c| c != ' '), String::from))), + )(input) +} + +enum Fragment<'a> { + Char(char), + Str(&'a str), +} + +fn quoted_arg(input: &str) -> IResult<&str, String> { + let (input, open_quote) = is_a("'\"")(input)?; + let quote_char = open_quote.chars().next().unwrap(); + + let inner_str = fold_many0( + alt(( + map( + verify( + take_while(move |c| c != '\\' && c != quote_char), + |s: &str| !s.is_empty(), + ), + Fragment::Str, + ), + map( + preceded( + char('\\'), + alt(( + value('\n', char('n')), + value('\r', char('r')), + value('\t', char('t')), + value('\\', char('\\')), + value(quote_char, char(quote_char)), + )), + ), + Fragment::Char, + ), + )), + String::new, + |mut buff, fragment| { + match fragment { + Fragment::Char(c) => buff.push(c), + Fragment::Str(s) => buff.push_str(s), + } + buff + }, + ); + + cut(terminated(inner_str, char(quote_char)))(input) +} + +#[derive(Debug, Clone)] +pub struct Unit { + pub kind: UnitKind, + pub description: Option<Box<str>>, + pub dependencies: Option<Dependencies>, + pub supervisor: Option<Supervisor>, +} + +fn parse_unit(input: &str) -> IResult<&str, Unit> { + map_res(config_file, Unit::from_config)(input) +} + +impl FromStr for Unit { + type Err = Error<String>; + + fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { + match parse_unit(s).finish() { + Ok((_remaining, unit)) => Ok(unit), + Err(e) => Err(Error { + input: e.input.to_string(), + code: e.code, + }), + } + } +} + +impl Unit { + pub fn from_config(config: HashMap<&str, HashMap<&str, &str>>) -> Result<Self> { + let root: HashMap<String, String> = config + .get(".root") + .map(|n| { + n.iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + }) + .context("expected root node")?; // should be impossible + let kind = root + .get("kind") + .context("expected kind")? + .parse::<UnitKind>() + .context("failed to parse unit kind")?; + let description = root.get("description").map(|n| Box::from(n.as_str())); + let dependencies = config.get("depends").map(Dependencies::from_body); + let supervisor = if let UnitKind::Daemon | UnitKind::Oneshot = kind { + let (restart_delay, restart_attempts, restart_policy) = + if let Some(restart) = config.get("restart") { + let delay = if let Some(delay) = restart.get("delay") { + Some(delay.parse::<u64>().context("failed to parse delay")?) + } else { + None + }; + let attempts = if let Some(attempts) = restart.get("attempts") { + Some( + attempts + .parse::<u64>() + .context("failed to parse attempts")?, + ) + } else { + None + }; + let policy = if let Some(policy) = restart.get("policy") { + Some( + policy + .parse::<RestartPolicy>() + .context("failed to parse policy")?, + ) + } else { + Some(RestartPolicy::OnFailure) + }; + (delay, attempts, policy) + } else { + (None, None, Some(RestartPolicy::OnFailure)) + }; + + let pwd = root.get("pwd").map(|n| n.to_string()); + let root_dir = root.get("root").map(|n| n.to_string()); + let group = root.get("group").map(|n| n.to_string()); + let user = root.get("user").map(|n| n.to_string()); + let stdout = root.get("stdout").map(|n| n.to_string()); + let stderr = root.get("stderr").map(|n| n.to_string()); + + let cmd = root + .get("cmd") + .context("expected cmd")? + .parse::<Command>() + .context("failed to parse cmd")?; + + let env = config + .get("environment") + .map(|n| n.iter().map(|(k, v)| format!("{k}={v}")).collect()) + .unwrap_or_default(); + + Some(Supervisor { + exec: cmd.0, + args: cmd.1, + cgroup: None, + restart_delay, + restart_attempts, + restart_policy, + pwd, + root: root_dir, + env, + group, + user, + stdout, + stderr, + }) + } else { + None + }; + + Ok(Unit { + kind, + description, + dependencies, + supervisor, + }) + } +} + +#[async_trait] +impl crate::Unit for (UnitName, Unit) { + fn name(&self) -> UnitName { + self.0.clone() + } + + fn description(&self) -> Option<&str> { + self.1.description.as_deref() + } + + fn dependencies(&self) -> Dependencies { + self.1.dependencies.clone().unwrap_or_default() + } + + async fn start(&mut self) -> Result<()> { + if let Some(mut supervisor) = self.1.supervisor.clone() { + supervisor.cgroup = Some(self.0.to_string()); + + let child = SupervisorBuilder::from_supervisor(supervisor).spawn()?; + + async_fs::write( + Path::new(constants::KAN_PIDS).join(self.0.to_string()), + child.id().to_string(), + ) + .await + .context("failed to write pid")?; + } + + Ok(()) + } + + async fn stop(&mut self) -> Result<()> { + if self.1.supervisor.is_none() { + return Ok(()); + } + + if let Ok(pid) = + async_fs::read_to_string(Path::new(constants::KAN_PIDS).join(self.0.to_string())).await + { + unblock(move || { + kill( + Pid::from_raw(pid.parse::<u32>().context("failed to parse pid")? as pid_t), + Signal::SIGTERM, + ) + .context("failed to kill service") + }) + .await?; + } else { + warn!("failed to find pid file"); + } + + Ok(()) + } +} + +#[cfg(test)] +mod test { + use crate::parser_test; + + use super::*; + + #[test] + fn parse_unit_kind() { + assert_eq!("oneshot".parse::<UnitKind>().unwrap(), UnitKind::Oneshot); + assert_eq!("daemon".parse::<UnitKind>().unwrap(), UnitKind::Daemon); + assert_eq!("builtin".parse::<UnitKind>().unwrap(), UnitKind::Builtin); + assert_eq!("foo".parse::<UnitKind>(), Err(ParseUnitKindError)); + } + + #[test] + fn parse_command() -> std::result::Result<(), Error<&'static str>> { + parser_test!( + command, + [ + "foo" => "foo", + "foo2" => "foo2" + ] + ); + + Ok(()) + } + + #[test] + fn parse_args() -> std::result::Result<(), Error<&'static str>> { + parser_test!( + args, + [ + "foo" => vec!["foo"], + "foo2 \"bar\"" => vec!["foo2", "bar"], + "\"\\\"baz\\\"\"" => vec!["\"baz\""], + "'\\n'" => vec!["\n"], + "'hello\\nworld'" => vec!["hello\nworld"] + ] + ); + + Ok(()) + } + + #[test] + fn parse_parse_cmd() -> std::result::Result<(), Error<&'static str>> { + parser_test!( + parse_cmd, + [ + "foo" => Command(String::from("foo"), vec![]), + "foo bar" => Command(String::from("foo"), vec![String::from("bar")]), + "foo \"bar baz\"" => Command(String::from("foo"), vec![String::from("bar baz")]), + "foo \"bar baz\" \"\\n\"" => Command( + String::from("foo"), + vec![String::from("bar baz"), String::from("\n")] + ) + ] + ); + + Ok(()) + } +} diff --git a/crates/unit/src/lib.rs b/crates/unit/src/lib.rs @@ -0,0 +1,6 @@ +pub use dependencies::*; +pub use unit::*; + +mod dependencies; +pub mod formats; +mod unit; diff --git a/crates/unit/src/unit.rs b/crates/unit/src/unit.rs @@ -0,0 +1,75 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use async_trait::async_trait; +use send_wrapper::SendWrapper; + +use kanit_common::error::Result; + +use crate::Dependencies; + +// has to be async lock to allow querying without possible blocks +pub type RcUnit = SendWrapper<Rc<RefCell<dyn Unit>>>; +pub type UnitName = Arc<str>; + +/// Unit lifecycle: +/// +/// +/// Startup: +/// ```rs +/// if !unit.prepare().await? { return; } +/// +/// unit.start().await?; +/// ``` +/// +/// Stop: +/// ```rs +/// unit.stop().await?; +/// unit.teardown().await?; +/// ``` +/// +/// Restart: +/// ```rs +/// unit.stop().await?; +/// unit.start().await?; +/// ``` +#[async_trait] +pub trait Unit: Send + Sync { + /// The name of the unit. + fn name(&self) -> UnitName; + + /// A description of the unit. + fn description(&self) -> Option<&str> { + None + } + + /// Dependencies of the unit. + fn dependencies(&self) -> Dependencies { + Dependencies::new() + } + + /// Starts the unit. + async fn start(&mut self) -> Result<()> { + Ok(()) + } + + /// Called when a unit is ordered to stop or as a step in restarting. + async fn stop(&mut self) -> Result<()> { + Ok(()) + } + + /// Preconditions for starting a unit. + async fn prepare(&self) -> Result<bool> { + Ok(true) + } + + /// Tearing down a unit once finished. + async fn teardown(&self) -> Result<()> { + Ok(()) + } +} + +pub fn wrap_unit<U: Unit + 'static>(unit: U) -> RcUnit { + SendWrapper::new(Rc::new(RefCell::new(unit))) +} diff --git a/crates/units/Cargo.toml b/crates/units/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "kanit-units" +version.workspace = true +edition.workspace = true + +[features] +testing = [] + +[dependencies.async-process] +version = "2.2" + +[dependencies.async-trait] +version = "0.1" + +[dependencies.async-fs] +version = "2.1" + +[dependencies.futures-lite] +version = "2.3" + +[dependencies.blocking] +version = "1.6" + +[dependencies.fastrand] +version = "2.1" + +[dependencies.walkdir] +version = "2.5" + +[dependencies.log] +version = "0.4" + +[dependencies.nix] +version = "0.29" +features = [ + "signal", + "process", + "mount", + "hostname", + "time", + "ioctl", + "fs" +] + +[dependencies.libc] +version = "0.2" + +[dependencies.kanit-executor] +path = "../executor" + +[dependencies.kanit-unit] +path = "../unit" + +[dependencies.kanit-supervisor] +path = "../supervisor" + +[dependencies.kanit-common] +path = "../common" diff --git a/crates/units/src/lib.rs b/crates/units/src/lib.rs @@ -0,0 +1,6 @@ +pub use loader::{default_enabled, units}; + +mod loader; +mod mounts; +pub mod oneshot; +pub mod services; diff --git a/crates/units/src/loader.rs b/crates/units/src/loader.rs @@ -0,0 +1,52 @@ +use std::collections::{HashMap, HashSet}; + +use kanit_unit::{RcUnit, UnitName, wrap_unit}; + +use crate::oneshot::*; +use crate::services::GeTTY; + +// use crate::services::*; + +pub fn units() -> [RcUnit; 16] { + [ + wrap_unit(ProcFs), + wrap_unit(SysFs), + wrap_unit(Run), + wrap_unit(DevFs), + wrap_unit(MDev), + wrap_unit(HwDrivers), + wrap_unit(Modules), + wrap_unit(Clock), + wrap_unit(RootFs), + wrap_unit(Swap), + wrap_unit(LocalMount), + wrap_unit(Seed), + wrap_unit(Hostname), + wrap_unit(Cgroup), + // wrap_unit(Syslog::new()), + // TODO; getty kills init... as always + wrap_unit(GeTTY::new("tty1", false)), + wrap_unit(GeTTY::new("ttyS0", true)), + ] +} + +pub fn default_enabled() -> HashMap<Box<str>, HashSet<UnitName>> { + let sysboot = units() + .iter() + .map(|n| n.borrow().name()) + .filter(|n| !n.starts_with("getty")) + .collect::<HashSet<_>>(); + #[cfg(not(feature = "testing"))] + let default = units() + .iter() + .map(|n| n.borrow().name()) + .filter(|n| n.starts_with("getty")) + .collect::<HashSet<_>>(); + #[cfg(feature = "testing")] + let default = HashSet::new(); + + HashMap::from([ + (Box::from("sysboot"), sysboot), + (Box::from("default"), default), + ]) +} diff --git a/crates/units/src/mounts.rs b/crates/units/src/mounts.rs @@ -0,0 +1,158 @@ +use std::collections::HashMap; +use std::path::Path; + +use async_process::Command; + +use kanit_common::error::{Context, Result}; + +pub async fn is_fs_available(fs: &str) -> Result<bool> { + let filesystems = async_fs::read_to_string("/proc/filesystems") + .await + .context("failed to read filesystems")?; + + // prepend tab as the format is `nodev <fs>` or ` <fs>` + // TODO; maybe something a bit more elegant + Ok(filesystems.contains(&format!("\t{fs}"))) +} + +pub async fn is_fs_mounted<P: AsRef<Path>>(path: P) -> Result<bool> { + let mounted = async_fs::read_to_string("/proc/mounts") + .await + .context("failed to read mounts")?; + + let path = path.as_ref().to_string_lossy(); + + Ok(parse_mounts(&mounted)?.iter().any(|m| m.fs_file == path)) +} + +pub async fn try_mount_from_fstab<P: AsRef<Path>>(path: P) -> Result<bool> { + try_mount_from_fstab_action(path, MountAction::Mount).await +} + +pub async fn try_mount_from_fstab_action<P: AsRef<Path>>( + path: P, + action: MountAction, +) -> Result<bool> { + let fstab = async_fs::read_to_string("/etc/fstab") + .await + .context("failed to read fstab")?; + + let path = path.as_ref().to_string_lossy(); + + if let Some(entry) = parse_mounts(&fstab)?.iter().find(|m| m.fs_file == path) { + Ok(entry.mount(action).await?) + } else { + Ok(false) + } +} + +pub struct MountEntry<'a> { + pub fs_spec: &'a str, + pub fs_file: &'a str, + pub fs_vfstype: &'a str, + pub fs_mntopts: HashMap<&'a str, Option<&'a str>>, + // used by dump(8) and fsck(8) + pub _fs_freq: u8, + pub _fs_passno: u8, +} + +pub enum MountAction { + Mount, + Remount, +} + +impl<'a> MountEntry<'a> { + pub fn parse_single_mount(line: &'a str) -> Result<Self> { + let mut parts = line.split_whitespace(); + + let fs_spec = parts.next().context("expected `fs_spec`")?; + + let fs_file = parts.next().context("expected `fs_file`")?; + + let fs_vfstype = parts.next().context("expected `fs_vfstype`")?; + + let fs_mntopts = parts.next().context("expected `fs_mntopts`")?; + + let fs_mntopts: HashMap<&str, Option<&str>> = fs_mntopts + .split(',') + .map(|s| { + let mut split = s.splitn(2, '='); + // unwrap: split will always have at least 1 + let opt = split.next().unwrap(); + let val = split.next(); + + (opt, val) + }) + .collect(); + + let fs_freq = parts + .next() + .context("expected `fs_freq`")? + .parse::<u8>() + .context("failed to parse `fs_freq`")?; + + let fs_passno = parts + .next() + .context("expected `fs_passno`")? + .parse::<u8>() + .context("failed to parse `fs_passno`")?; + + Ok(Self { + fs_spec, + fs_file, + fs_vfstype, + fs_mntopts, + _fs_freq: fs_freq, + _fs_passno: fs_passno, + }) + } + + pub async fn mount(&self, action: MountAction) -> Result<bool> { + let mut opts = self + .fs_mntopts + .iter() + .map(|(k, v)| { + if let Some(v) = v { + format!("{k}={v}") + } else { + k.to_string() + } + }) + .collect::<Vec<_>>() + .join(","); + + if let MountAction::Remount = action { + if opts.is_empty() { + opts.push_str("remount"); + } else { + opts.push_str(",remount"); + } + } + + Ok(Command::new("mount") + .arg("-o") + .arg(opts) + .arg("-t") + .arg(self.fs_vfstype) + .arg(self.fs_spec) + .arg(self.fs_file) + .spawn() + .context("failed to start mount")? + .status() + .await + .context("failed to wait on mount")? + .success()) + } +} + +pub fn parse_mounts(lines: &str) -> Result<Vec<MountEntry>> { + lines + .lines() + .filter_map(|line| { + if line.starts_with('#') || line.is_empty() { + return None; + } + Some(MountEntry::parse_single_mount(line)) + }) + .collect() +} diff --git a/crates/units/src/oneshot/cgroup.rs b/crates/units/src/oneshot/cgroup.rs @@ -0,0 +1,77 @@ +use std::path::Path; + +use async_trait::async_trait; +use blocking::unblock; +use log::info; +use nix::mount::{MsFlags, mount}; +use nix::sys::stat::Mode; +use nix::unistd::mkdir; + +use kanit_common::error::{Context, ErrorKind, Result, StaticError}; +use kanit_unit::{Dependencies, Unit}; + +use crate::mounts::{is_fs_available, try_mount_from_fstab}; +use crate::oneshot::SysFs; +use crate::unit_name; + +pub struct Cgroup; + +impl Cgroup { + // TODO; cgroup1 + async fn mount_cgroup2() -> Result<()> { + if !is_fs_available("cgroup2").await? { + Err(StaticError("failed to mount cgroup")).kind(ErrorKind::Unrecoverable)?; + }; + + info!("mounting cgroup2"); + + let path = Path::new("/sys/fs/cgroup"); + + if try_mount_from_fstab(path).await? { + return Ok(()); + } + + unblock(move || { + mount( + Some("none"), + Path::new("/sys/fs/cgroup"), + Some("cgroup2"), + MsFlags::MS_NODEV | MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC, + Some("nsdelegate,memory_recursiveprot"), + ) + }) + .await + .context("failed to mount cgroup2")?; + + Ok(()) + } + + async fn init_cgroups() -> Result<()> { + let path = Path::new("/sys/fs/cgroup"); + + unblock(move || { + mkdir( + &path.join("system.slice"), + Mode::S_IRWXU | Mode::S_IRGRP | Mode::S_IRUSR, + ) + }) + .await + .context("failed to create system.slice")?; + + Ok(()) + } +} + +#[async_trait] +impl Unit for Cgroup { + unit_name!("cgroup"); + + fn dependencies(&self) -> Dependencies { + Dependencies::new().need(SysFs.name()).clone() + } + + async fn start(&mut self) -> Result<()> { + Self::mount_cgroup2().await?; + Self::init_cgroups().await + } +} diff --git a/crates/units/src/oneshot/clock.rs b/crates/units/src/oneshot/clock.rs @@ -0,0 +1,142 @@ +use std::path::Path; + +use async_process::{Command, Stdio}; +use async_trait::async_trait; +use futures_lite::StreamExt; +use log::{info, warn}; + +use kanit_common::error::{Context, ErrorKind, Result, StaticError}; +use kanit_unit::{Dependencies, Unit}; + +use crate::oneshot::Modules; +use crate::unit_name; + +// chronos <3 - Sparkles +pub struct Clock; + +fn check_rtc(name: &Path) -> bool { + let f_str = name.to_string_lossy(); + + if f_str == "/dev/rtc" + || f_str.starts_with("/dev/rtc") + && f_str + .chars() + .nth(8) + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + { + return true; + } + + false +} + +impl Clock { + async fn rtc_exists() -> Result<bool> { + Ok(async_fs::read_dir("/dev") + .await + .context("failed to open /dev")? + .filter_map(|res| res.map(|e| e.path()).ok()) + .find(|p| check_rtc(p)) + .await + .is_some()) + } +} + +const RTC_MODS: [&str; 3] = ["rtc-cmos", "rtc", "genrtc"]; + +#[async_trait] +impl Unit for Clock { + unit_name!("clock"); + + fn dependencies(&self) -> Dependencies { + Dependencies::new().want(Modules.name()).clone() + } + + async fn start(&mut self) -> Result<()> { + info!("setting time with hardware clock"); + + if !Self::rtc_exists().await? { + let mut loaded = false; + + for module in RTC_MODS { + let succ = Command::new("modprobe") + .args(["-q", module]) + .spawn() + .context("failed to spawn modprobe")? + .status() + .await + .context("failed to wait")? + .success(); + + if succ && Self::rtc_exists().await? { + warn!("module {module} should be built in or configured to load"); + loaded = true; + break; + } + } + + if !loaded { + Err(StaticError("failed to set hardware clock")).kind(ErrorKind::Recoverable)?; + } + } + + // todo; UTC + + let succ = Command::new("hwclock") + .args(["--systz", "--localtime"]) + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .context("failed to spawn hwclock")? + .output() + .await + .context("failed to wait")? + .stderr + .is_empty(); + + if !succ { + warn!("failed to set system timezone"); + } + + let succ = Command::new("hwclock") + .args(["--hctosys", "--localtime"]) + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .context("failed to spawn hwclock")? + .output() + .await + .context("failed to wait")? + .stderr + .is_empty(); + + if !succ { + warn!("failed to set system time"); + } + + Ok(()) + } + + async fn stop(&mut self) -> Result<()> { + info!("setting hardware clock with system time"); + + let succ = Command::new("hwclock") + .args(["--systohc", "--localtime"]) + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .context("failed to spawn hwclock")? + .output() + .await + .context("failed to wait")? + .stderr + .is_empty(); + + if !succ { + warn!("failed to set hardware clock"); + } + + Ok(()) + } +} diff --git a/crates/units/src/oneshot/devfs.rs b/crates/units/src/oneshot/devfs.rs @@ -0,0 +1,240 @@ +use std::os::unix::fs::FileTypeExt; +use std::path::Path; + +use async_fs::unix::symlink; +use async_trait::async_trait; +use blocking::unblock; +use libc::dev_t; +use log::info; +use nix::mount::{MsFlags, mount}; +use nix::sys::stat::{Mode, SFlag, makedev, mknod}; +use nix::unistd::mkdir; + +use kanit_common::error::{Context, ErrorKind, Result, StaticError}; +use kanit_executor::join_all; +use kanit_unit::{Dependencies, Unit}; + +use crate::mounts::{ + MountAction, is_fs_available, is_fs_mounted, try_mount_from_fstab, try_mount_from_fstab_action, +}; +use crate::oneshot::MDev; +use crate::unit_name; + +pub struct DevFs; + +async fn character_device<P: AsRef<Path>>(path: P) -> bool { + async_fs::metadata(path) + .await + .ok() + .map(|m| m.file_type().is_char_device()) + .unwrap_or(false) +} + +async fn exists<P: AsRef<Path>>(path: P) -> bool { + async_fs::metadata(path).await.is_ok() +} + +async fn mount_opt( + path: &'static str, + name: &'static str, + mode: Mode, + flags: MsFlags, + opts: &'static str, +) -> Result<()> { + if is_fs_available(name).await? && !is_fs_mounted(&path).await? { + if !exists(path).await { + unblock(move || mkdir(path, mode)) + .await + .context("failed to make directory")?; + } + + info!("mounting {path}"); + + if try_mount_from_fstab(&path).await? { + return Ok(()); + } + + unblock(move || { + mount( + Some("none"), + path, + Some(name), + flags | MsFlags::MS_NOEXEC | MsFlags::MS_NOSUID, + Some(opts), + ) + }) + .await + .with_context(move || format!("failed to mount {name}"))?; + } + + Ok(()) +} + +async fn dev_device(path: &'static str, kind: SFlag, perm: Mode, dev: dev_t) -> Result<()> { + if character_device(path).await { + return Ok(()); + } + + unblock(move || mknod(path, kind, perm, dev)) + .await + .with_context(move || format!("failed to create {path}")) +} + +async fn sym(src: &'static str, dst: &'static str) -> Result<()> { + if !exists(dst).await { + symlink(src, dst) + .await + .with_context(move || format!("failed to link {dst}"))?; + } + + Ok(()) +} + +impl DevFs { + async fn mount_dev() -> Result<()> { + let path = Path::new("/dev"); + + let mut opts = MsFlags::MS_NOSUID; + let mut action = MountAction::Mount; + + if is_fs_mounted(path).await? { + info!("remounting devfs"); + opts |= MsFlags::MS_REMOUNT; + action = MountAction::Remount; + } else { + info!("mounting devfs"); + } + + if try_mount_from_fstab_action(path, action).await? { + return Ok(()); + } + + let fs = if is_fs_available("devtmpfs").await? { + "devtmpfs" + } else if is_fs_available("tmpfs").await? { + "tmpfs" + } else { + Err(StaticError( + "devtmpfs, tmpfs, nor fstab entry available, /dev will not be mounted", + )) + .kind(ErrorKind::Recoverable)? + }; + + unblock(move || mount(Some("none"), path, Some(fs), opts, Some(""))) + .await + .context("failed to mount devfs")?; + + Ok(()) + } + + async fn populate_dev() -> Result<()> { + // create dev devices if they don't exist + + join_all([ + dev_device( + "/dev/console", + SFlag::S_IFCHR, + Mode::S_IRUSR | Mode::S_IWUSR, + makedev(5, 1), + ), + dev_device( + "/dev/tty1", + SFlag::S_IFCHR, + Mode::S_IRUSR | Mode::S_IWUSR | Mode::S_IWGRP, + makedev(4, 1), + ), + dev_device( + "/dev/tty", + SFlag::S_IFCHR, + Mode::S_IRUSR + | Mode::S_IWUSR + | Mode::S_IRGRP + | Mode::S_IWGRP + | Mode::S_IROTH + | Mode::S_IWOTH, + makedev(4, 1), + ), + dev_device( + "/dev/null", + SFlag::S_IFCHR, + Mode::S_IRUSR + | Mode::S_IWUSR + | Mode::S_IRGRP + | Mode::S_IWGRP + | Mode::S_IROTH + | Mode::S_IWOTH, + makedev(1, 3), + ), + dev_device( + "/dev/kmsg", + SFlag::S_IFCHR, + Mode::S_IRUSR | Mode::S_IWUSR | Mode::S_IRGRP | Mode::S_IWGRP, + makedev(1, 11), + ), + ]) + .await + .into_iter() + .collect::<Result<Vec<_>>>()?; + + join_all([ + sym("/proc/self/fd", "/dev/fd"), + sym("/proc/self/fd/0", "/dev/stdin"), + sym("/proc/self/fd/1", "/dev/stdout"), + sym("/proc/self/fd/2", "/dev/stderr"), + ]) + .await + .into_iter() + .collect::<Result<Vec<_>>>()?; + + join_all([ + mount_opt( + "/dev/mqueue", + "mqueue", + Mode::S_IRWXU | Mode::S_IRWXG | Mode::S_IRWXO | Mode::S_ISVTX, + MsFlags::MS_NODEV, + "", + ), + mount_opt( + "/dev/pts", + "devpts", + Mode::S_IRWXU | Mode::S_IRGRP | Mode::S_IXGRP | Mode::S_IROTH | Mode::S_IXOTH, + MsFlags::empty(), + "gid=5,mode=0620", + ), + mount_opt( + "/dev/shm", + "tmpfs", + Mode::S_IRWXU | Mode::S_IRWXG | Mode::S_IRWXO | Mode::S_ISVTX, + MsFlags::MS_NODEV, + "", + ), + ]) + .await + .into_iter() + .collect::<Result<Vec<_>>>()?; + + if Path::new("/proc/kcore").exists() { + symlink("/proc/kcore", "/dev/core") + .await + .context("failed to link kcore")?; + } + + Ok(()) + } +} + +#[async_trait] +impl Unit for DevFs { + unit_name!("devfs"); + + fn dependencies(&self) -> Dependencies { + Dependencies::new().before(MDev.name()).clone() + } + + async fn start(&mut self) -> Result<()> { + Self::mount_dev().await?; + Self::populate_dev().await?; + + Ok(()) + } +} diff --git a/crates/units/src/oneshot/hostname.rs b/crates/units/src/oneshot/hostname.rs @@ -0,0 +1,32 @@ +use async_trait::async_trait; +use blocking::unblock; +use nix::unistd::sethostname; + +use kanit_common::error::{Context, ErrorKind, Result}; +use kanit_unit::{Dependencies, Unit}; + +use crate::oneshot::Clock; +use crate::unit_name; + +pub struct Hostname; + +#[async_trait] +impl Unit for Hostname { + unit_name!("hostname"); + + fn dependencies(&self) -> Dependencies { + Dependencies::new().after(Clock.name()).clone() + } + + async fn start(&mut self) -> Result<()> { + let hostname = async_fs::read_to_string("/etc/hostname") + .await + .unwrap_or_else(|_| "homosexual".to_string()); + + unblock(move || sethostname(hostname.trim_start().trim_end())) + .await + .context_kind("failed to set hostname", ErrorKind::Recoverable)?; + + Ok(()) + } +} diff --git a/crates/units/src/oneshot/hwdrivers.rs b/crates/units/src/oneshot/hwdrivers.rs @@ -0,0 +1,70 @@ +use std::collections::HashSet; + +use async_process::{Command, Stdio}; +use async_trait::async_trait; +use blocking::unblock; +use futures_lite::StreamExt; +use futures_lite::stream::iter; +use log::info; +use walkdir::WalkDir; + +use kanit_common::error::{Context, Result}; +use kanit_unit::{Dependencies, Unit}; + +use crate::oneshot::{DevFs, SysFs}; +use crate::unit_name; + +pub struct HwDrivers; + +impl HwDrivers { + async fn load_modules() -> Result<()> { + // todo; better walker + let modules = iter( + unblock(move || { + WalkDir::new("/sys") + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file() && e.file_name() == "modalias") + }) + .await, + ) + .then(|e| async { async_fs::read_to_string(e.into_path()).await }) + .filter_map(|e| e.ok()) + .collect::<HashSet<String>>() + .await; + + // we don't care about status + Command::new("modprobe") + .stderr(Stdio::null()) + .args(["-b", "-a"]) + .args(modules) + .spawn() + .context("failed to spawn modprobe")? + .status() + .await + .context("failed to wait")?; + + Ok(()) + } +} + +#[async_trait] +impl Unit for HwDrivers { + unit_name!("hwdrivers"); + + fn dependencies(&self) -> Dependencies { + Dependencies::new() + .need(DevFs.name()) + .need(SysFs.name()) + .clone() + } + + async fn start(&mut self) -> Result<()> { + info!("loading modules for hardware"); + + Self::load_modules().await?; + Self::load_modules().await?; + + Ok(()) + } +} diff --git a/crates/units/src/oneshot/localmount.rs b/crates/units/src/oneshot/localmount.rs @@ -0,0 +1,127 @@ +use async_process::{Command, Stdio}; +use async_trait::async_trait; +use log::info; +use nix::unistd::sync; + +use kanit_common::error::{Context, ErrorKind, Result, StaticError}; +use kanit_unit::{Dependencies, Unit}; + +use crate::mounts::{is_fs_mounted, parse_mounts}; +use crate::oneshot::{Clock, Modules, RootFs}; +use crate::unit_name; + +pub struct LocalMount; + +async fn kill_fs_users(fs: &str) -> Result<()> { + Command::new("fuser") + .args(["-KILL", "-k", "-m", fs]) + .spawn() + .context("failed to spawn fuser")? + .status() + .await + .context("failed to wait fuser")?; + + Ok(()) +} + +#[async_trait] +impl Unit for LocalMount { + unit_name!("localmount"); + + fn dependencies(&self) -> Dependencies { + Dependencies::new() + .need(RootFs.name()) + .after(Clock.name()) + .after(Modules.name()) + .clone() + } + + async fn start(&mut self) -> Result<()> { + info!("mounting local filesystems"); + + let succ = Command::new("mount") + .stdout(Stdio::null()) + .args(["-a", "-t", "noproc"]) + .spawn() + .context("failed to spawn mount")? + .status() + .await + .context("failed to wait on mount")? + .success(); + + if !succ { + Err(StaticError("failed to mount local filesystems")).kind(ErrorKind::Recoverable)?; + } + + Ok(()) + } + + async fn stop(&mut self) -> Result<()> { + sync(); + + let mounted = async_fs::read_to_string("/proc/mounts") + .await + .context("failed to read mounts")?; + // loopback + info!("unmounting loopback"); + + for mount in parse_mounts(&mounted)? { + if !is_fs_mounted(mount.fs_file).await.unwrap_or(false) { + continue; + } + + if !mount.fs_file.starts_with("/dev/loop") { + continue; + } + + // should already be dead since init should've killed + kill_fs_users(mount.fs_file).await?; + + Command::new("umount") + .args(["-d", mount.fs_file]) + .spawn() + .context("failed to spawn umount")? + .status() + .await + .context("failed to wait umount")?; + } + + // now everything but network + info!("unmounting filesystems"); + for mount in parse_mounts(&mounted)? { + let n = mount.fs_file; + + if !is_fs_mounted(n).await.unwrap_or(false) { + continue; + } + + if n == "/" + || n == "/dev" + || n == "/sys" + || n == "/proc" + || n == "/run" + || n.starts_with("/dev/") + || n.starts_with("/sys/") + || n.starts_with("/proc/") + { + continue; + } + + if mount.fs_mntopts.contains_key("_netdev") { + continue; + } + + kill_fs_users(mount.fs_file).await?; + + Command::new("umount") + .arg(mount.fs_file) + .spawn() + .context("failed to spawn umount")? + .status() + .await + .context("failed to wait umount")?; + } + + Ok(()) + } +} diff --git a/crates/units/src/oneshot/mdev.rs b/crates/units/src/oneshot/mdev.rs @@ -0,0 +1,59 @@ +use async_process::Command; +use async_trait::async_trait; +use log::info; + +use kanit_common::error::{Context, ErrorKind, Result, StaticError}; +use kanit_unit::{Dependencies, Unit}; + +use crate::oneshot::{DevFs, SysFs}; +use crate::unit_name; + +// TODO; write one for udev as well +pub struct MDev; + +#[async_trait] +impl Unit for MDev { + unit_name!("mdev"); + + fn dependencies(&self) -> Dependencies { + Dependencies::new() + .need(SysFs.name()) + .need(DevFs.name()) + .clone() + } + + async fn start(&mut self) -> Result<()> { + info!("initializing mdev"); + + async_fs::write("/proc/sys/kernel/hotplug", "/sbin/mdev") + .await + .context("failed to initialize mdev for hotplug")?; + + info!("loading hardware for mdev"); + + let succ = Command::new("mdev") + .arg("-s") + .spawn() + .context("failed to spawn mdev")? + .status() + .await + .context("failed to wait")? + .success(); + + if !succ { + Err(StaticError("failed to spawn mdev")).kind(ErrorKind::Recoverable)?; + } + + Ok(()) + } + + async fn stop(&mut self) -> Result<()> { + info!("stopping mdev"); + + async_fs::write("/proc/sys/kernel/hotplug", "/sbin/mdev") + .await + .context_kind("failed to stop mdev", ErrorKind::Recoverable)?; + + Ok(()) + } +} diff --git a/crates/units/src/oneshot/mod.rs b/crates/units/src/oneshot/mod.rs @@ -0,0 +1,46 @@ +pub use cgroup::Cgroup; +pub use clock::Clock; +pub use devfs::DevFs; +pub use hostname::Hostname; +pub use hwdrivers::HwDrivers; +pub use localmount::LocalMount; +pub use mdev::MDev; +pub use modules::Modules; +pub use procfs::ProcFs; +pub use rootfs::RootFs; +pub use run::Run; +pub use seed::Seed; +pub use swap::Swap; +pub use sysfs::SysFs; + +mod cgroup; +mod clock; +mod devfs; +mod hostname; +mod hwdrivers; +mod localmount; +mod mdev; +mod modules; +mod procfs; +mod rootfs; +mod run; +mod seed; +mod swap; +mod sysctl; +mod sysfs; + +// deduplication of unit names +// as unit names are used as keys and things, having them deduplicated would be good +// it also allows comparisons to be O(1) by comparing addresses +#[macro_export] +macro_rules! unit_name { + ($name:literal) => { + fn name(&self) -> kanit_unit::UnitName { + static NAME: std::sync::OnceLock<kanit_unit::UnitName> = std::sync::OnceLock::new(); + + return NAME + .get_or_init(|| kanit_unit::UnitName::from($name)) + .clone(); + } + }; +} diff --git a/crates/units/src/oneshot/modules.rs b/crates/units/src/oneshot/modules.rs @@ -0,0 +1,77 @@ +use std::os::unix::ffi::OsStrExt; + +use async_process::Command; +use async_trait::async_trait; +use blocking::unblock; +use futures_lite::StreamExt; +use futures_lite::stream::iter; +use log::{info, warn}; +use walkdir::WalkDir; + +use kanit_common::error::{Context, Result}; +use kanit_unit::Unit; + +use crate::unit_name; + +pub struct Modules; + +const LOADED_FOLDERS: [&str; 3] = [ + "/etc/modules-load.d/", + "/run/modules-load./", + "/usr/lib/modules-load.d/", +]; + +#[async_trait] +impl Unit for Modules { + unit_name!("modules"); + + async fn start(&mut self) -> Result<()> { + if async_fs::metadata("/proc/modules").await.is_err() { + return Ok(()); + } + + for dir in LOADED_FOLDERS.iter() { + let modules = iter( + unblock(move || { + WalkDir::new(dir) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_type().is_file() && e.file_name().as_bytes().ends_with(b".conf") + }) + }) + .await, + ) + .then(|e| async { async_fs::read_to_string(e.into_path()).await }) + .filter_map(|e| e.ok()) + .collect::<Vec<String>>() + .await + .into_iter() + .flat_map(|e| { + e.lines() + .filter(|l| !l.starts_with('#') && !l.starts_with(';') && !l.is_empty()) + .map(|s| s.to_string()) + .collect::<Vec<String>>() + }); + + for module in modules { + info!("loading module {module}"); + + let succ = Command::new("modprobe") + .args(["-b", "-a", "-v", &module]) + .spawn() + .context("failed to spawn modprobe")? + .status() + .await + .context("failed to wait")? + .success(); + + if !succ { + warn!("failed to load module {module}"); + } + } + } + + Ok(()) + } +} diff --git a/crates/units/src/oneshot/procfs.rs b/crates/units/src/oneshot/procfs.rs @@ -0,0 +1,49 @@ +use std::path::Path; + +use async_trait::async_trait; +use blocking::unblock; +use log::info; +use nix::mount::{MsFlags, mount}; + +use kanit_common::error::{Context, Result}; +use kanit_unit::Unit; + +use crate::mounts::try_mount_from_fstab; +use crate::unit_name; + +pub struct ProcFs; + +#[async_trait] +impl Unit for ProcFs { + unit_name!("procfs"); + + async fn start(&mut self) -> Result<()> { + // check if proc is already mounted + if Path::new("/proc/mounts").exists() { + info!("procfs already mounted"); + return Ok(()); + } + + info!("mounting /proc"); + + let path = Path::new("/proc"); + + if try_mount_from_fstab(path).await? { + return Ok(()); + } + + unblock(move || { + mount( + Some("none"), + path, + Some("proc"), + MsFlags::MS_NODEV | MsFlags::MS_NOEXEC | MsFlags::MS_NOSUID, + Some(""), + ) + }) + .await + .context("failed to mount procfs")?; + + Ok(()) + } +} diff --git a/crates/units/src/oneshot/rootfs.rs b/crates/units/src/oneshot/rootfs.rs @@ -0,0 +1,63 @@ +use async_trait::async_trait; +use blocking::unblock; +use log::{info, warn}; +use nix::mount::{MsFlags, mount}; +use nix::unistd::{AccessFlags, access}; + +use kanit_common::error::{Context, ErrorKind, Result}; +use kanit_executor::join_all; +use kanit_unit::{Dependencies, Unit}; + +use crate::mounts::{MountAction, MountEntry, is_fs_mounted, parse_mounts}; +use crate::oneshot::Clock; +use crate::unit_name; + +pub struct RootFs; + +async fn remount_entry(entry: MountEntry<'_>) -> Result<()> { + if is_fs_mounted(entry.fs_file).await? && !entry.mount(MountAction::Remount).await? { + warn!("failed to remount {}", entry.fs_file); + } + + Ok(()) +} + +#[async_trait] +impl Unit for RootFs { + unit_name!("rootfs"); + + fn dependencies(&self) -> Dependencies { + Dependencies::new().after(Clock.name()).clone() + } + + async fn start(&mut self) -> Result<()> { + if access("/", AccessFlags::W_OK).is_ok() { + return Ok(()); // rootfs already writable + } + + info!("remounting rootfs as rw"); + + unblock(move || { + mount( + Some(""), // ignored in remount + "/", + Some(""), + MsFlags::MS_REMOUNT, + Some("rw"), + ) + }) + .await + .context("failed to remount rootfs")?; + + info!("remounting filesystems"); + + let fstab = async_fs::read_to_string("/etc/fstab") + .await + .context_kind("failed to read fstab", ErrorKind::Recoverable)?; + + // TODO; leak probably bad + join_all(parse_mounts(fstab.leak())?.into_iter().map(remount_entry)).await; + + Ok(()) + } +} diff --git a/crates/units/src/oneshot/run.rs b/crates/units/src/oneshot/run.rs @@ -0,0 +1,77 @@ +use std::path::Path; + +use async_trait::async_trait; +use blocking::unblock; +use log::info; +use nix::mount::{MsFlags, mount}; +use nix::sys::stat::Mode; +use nix::unistd::{Group, Uid, chown, mkdir}; + +use kanit_common::constants; +use kanit_common::error::{Context, ErrorKind, Result, StaticError}; +use kanit_unit::{Dependencies, Unit}; + +use crate::mounts::try_mount_from_fstab; +use crate::oneshot::ProcFs; +use crate::unit_name; + +pub struct Run; + +#[async_trait] +impl Unit for Run { + unit_name!("run"); + + fn dependencies(&self) -> Dependencies { + Dependencies::new().need(ProcFs.name()).clone() + } + + async fn start(&mut self) -> Result<()> { + let path = Path::new("/run"); + + if !path.exists() { + Err(StaticError("/run doesn't exist")).kind(ErrorKind::Unrecoverable)?; + } + + info!("mounting /run"); + + if try_mount_from_fstab(path).await? { + return Ok(()); + } + + unblock(move || { + mount( + Some("none"), + path, + Some("tmpfs"), + MsFlags::MS_NODEV | MsFlags::MS_STRICTATIME | MsFlags::MS_NOSUID, + Some("mode=0755,nr_inodes=500k,size=10%"), + ) + }) + .await + .context("failed to mount run")?; + + info!("creating /run/lock"); + + let lock = path.join("lock"); + + unblock(move || -> Result<()> { + mkdir(&lock, Mode::S_IROTH | Mode::S_IXOTH | Mode::S_IWUSR)?; + + let gid = Group::from_name("uucp") + .context("failed to get group uucp")? + .map(|g| g.gid); + + chown(&lock, Some(Uid::from_raw(0)), gid) + .context("failed to set permissions on /run/lock")?; + + Ok(()) + }) + .await?; + + info!("creating {}", constants::KAN_PIDS); + + unblock(move || mkdir(constants::KAN_PIDS, Mode::S_IRUSR | Mode::S_IWUSR)) + .await + .context("failed to create pid directory") + } +} diff --git a/crates/units/src/oneshot/seed.rs b/crates/units/src/oneshot/seed.rs @@ -0,0 +1,190 @@ +use std::cmp::Ordering; +use std::mem::size_of; +use std::os::unix::fs::PermissionsExt; + +use async_fs::File; +use async_trait::async_trait; +use blocking::unblock; +use futures_lite::{AsyncReadExt, AsyncWriteExt}; +use log::info; +use nix::errno::Errno; +use nix::fcntl::{OFlag, open}; +use nix::request_code_write; +use nix::sys::stat::Mode; +use nix::sys::time::{TimeSpec, TimeValLike}; +use nix::time::{ClockId, clock_gettime}; +use nix::unistd::close; + +use kanit_common::constants; +use kanit_common::error::{Context, ErrorKind, Result}; +use kanit_unit::{Dependencies, Unit}; + +use crate::oneshot::{Clock, LocalMount}; +use crate::unit_name; + +pub struct Seed; + +const RND_IOC_MAGIC: u8 = b'R'; +const RND_IOC_TYPE_MODE: u8 = 0x03; +const MAX_SEED_LEN: usize = 512; + +#[repr(C)] +struct RandPoolInfo { + entropy_count: libc::c_int, + buf_size: libc::c_int, + buf: [u8; MAX_SEED_LEN], // defined as u32 but it doesn't *really* matter +} + +impl Seed { + fn untrusted_seed() -> u64 { + let seconds = clock_gettime(ClockId::CLOCK_REALTIME) // TODO; unblock? + .unwrap_or_else(|_| { + clock_gettime(ClockId::CLOCK_BOOTTIME).unwrap_or(TimeSpec::new(0, 0)) + }) + .num_seconds(); + + u64::from_be_bytes(seconds.to_be_bytes()) + } + + async fn trusted_seed() -> Option<u64> { + if let Ok(mut data) = async_fs::read(constants::KAN_SEED).await { + data.resize(8, 0); + + // since the vector was resized, this shouldn't fail + Some(u64::from_be_bytes(data.as_slice().try_into().unwrap())) + } else { + None + } + } + + async fn write_bytes(bytes: &[u8], trusted: bool) -> Result<()> { + let buf: [u8; MAX_SEED_LEN] = match bytes.len().cmp(&MAX_SEED_LEN) { + Ordering::Greater => (&bytes[0..MAX_SEED_LEN]).try_into().unwrap(), + Ordering::Less => { + let mut buf = [0u8; MAX_SEED_LEN]; + + let view = &mut buf[0..bytes.len()]; + + view.copy_from_slice(bytes); + + buf + } + Ordering::Equal => bytes.try_into().unwrap(), + }; + + let entropy_count = if trusted { bytes.len() * 8 } else { 0 }; + + let info = RandPoolInfo { + entropy_count: entropy_count as libc::c_int, + buf_size: bytes.len() as libc::c_int, + buf, // unwrap: we ensure the size of `bytes` + }; + + unblock(move || { + let rand_fd = open("/dev/urandom", OFlag::O_RDONLY, Mode::empty()) + .context_kind("failed to open urandom", ErrorKind::Recoverable)?; + + unsafe { + Errno::result(libc::ioctl( + rand_fd, + request_code_write!( + RND_IOC_MAGIC, + RND_IOC_TYPE_MODE, + size_of::<libc::c_int>() * 2 + ), + &info as *const RandPoolInfo, + )) + } + .context_kind("failed to add entropy", ErrorKind::Recoverable)?; + + close(rand_fd).context_kind("failed to close urandom", ErrorKind::Recoverable)?; + + Ok(()) + }) + .await + } +} + +#[async_trait] +impl Unit for Seed { + unit_name!("seed"); + + fn dependencies(&self) -> Dependencies { + Dependencies::new() + .need(LocalMount.name()) + .after(Clock.name()) + .clone() + } + + async fn start(&mut self) -> Result<()> { + info!("seeding random number generator"); + + let t_seed = Self::trusted_seed().await; + + let seed = t_seed.unwrap_or_else(Self::untrusted_seed); + + let mut rand = fastrand::Rng::with_seed(seed); + + let bytes = async_fs::read_to_string("/proc/sys/kernel/random/poolsize") + .await + .unwrap_or_else(|_| "2048".to_string()) + .parse::<usize>() + .unwrap_or(2048) + / 8; + + // preload some bytes + let mut buf = Vec::with_capacity(bytes); + + rand.fill(buf.as_mut_slice()); + + Self::write_bytes(buf.as_slice(), t_seed.is_some()).await?; + + // load previous seed + if let Ok(mut previous_seed) = async_fs::read(constants::KAN_SEED).await { + let len = previous_seed.len(); + + previous_seed.resize(bytes, 0); + + if bytes > len { + rand.fill(&mut previous_seed[len..]); + } + + Self::write_bytes(previous_seed.as_slice(), true).await?; + } + + Ok(()) + } + + async fn stop(&mut self) -> Result<()> { + info!("saving random seed"); + + let mut f = File::open("/dev/urandom") + .await + .context_kind("failed to open urandom", ErrorKind::Recoverable)?; + + let mut buf = [0; 16]; + + f.read_exact(&mut buf) + .await + .context_kind("failed to save random seed", ErrorKind::Recoverable)?; + + let mut file = File::create(constants::KAN_SEED) + .await + .context_kind("failed to open seed file", ErrorKind::Recoverable)?; + + let metadata = file + .metadata() + .await + .context_kind("failed to get metadata", ErrorKind::Recoverable)?; + + metadata + .permissions() + .set_mode((Mode::S_IWUSR | Mode::S_IRUSR).bits()); + + file.write(&buf) + .await + .context_kind("failed to write seed", ErrorKind::Recoverable)?; + + Ok(()) + } +} diff --git a/crates/units/src/oneshot/swap.rs b/crates/units/src/oneshot/swap.rs @@ -0,0 +1,64 @@ +use async_process::{Command, Stdio}; +use async_trait::async_trait; +use log::info; + +use kanit_common::error::{Context, ErrorKind, Result, StaticError}; +use kanit_unit::{Dependencies, Unit}; + +use crate::oneshot::{Clock, LocalMount, RootFs}; +use crate::unit_name; + +pub struct Swap; + +#[async_trait] +impl Unit for Swap { + unit_name!("swap"); + + fn dependencies(&self) -> Dependencies { + Dependencies::new() + .need(RootFs.name()) + .after(Clock.name()) + .before(LocalMount.name()) + .clone() + } + + async fn start(&mut self) -> Result<()> { + info!("mounting swap"); + + let succ = Command::new("swapon") + .stdout(Stdio::null()) + .arg("-a") + .spawn() + .context("failed to spawn swapon")? + .status() + .await + .context("failed to wait on swapon")? + .success(); + + if !succ { + Err(StaticError("failed to enable swap")).kind(ErrorKind::Recoverable)?; + } + + Ok(()) + } + + async fn stop(&mut self) -> Result<()> { + info!("unmounting swap"); + + let succ = Command::new("swapoff") + .stdout(Stdio::null()) + .arg("-a") + .spawn() + .context("failed to spawn swapon")? + .status() + .await + .context("failed to wait on swapon")? + .success(); + + if !succ { + Err(StaticError("failed to disable swap")).kind(ErrorKind::Recoverable)?; + } + + Ok(()) + } +} diff --git a/crates/units/src/oneshot/sysctl.rs b/crates/units/src/oneshot/sysctl.rs @@ -0,0 +1,40 @@ +use async_process::{Command, Stdio}; +use async_trait::async_trait; +use log::info; + +use kanit_common::error::{Context, ErrorKind, Result, StaticError}; +use kanit_unit::{Dependencies, Unit}; + +use crate::oneshot::Clock; +use crate::unit_name; + +pub struct Sysctl; + +#[async_trait] +impl Unit for Sysctl { + unit_name!("sysctl"); + + fn dependencies(&self) -> Dependencies { + Dependencies::new().after(Clock.name()).clone() + } + + async fn start(&mut self) -> Result<()> { + info!("loading sysctl"); + + let succ = Command::new("sysctl") + .stdout(Stdio::null()) + .args(["-q", "--system"]) + .spawn() + .context("failed to spawn sysctl")? + .status() + .await + .context("failed to wait on sysctl")? + .success(); + + if !succ { + Err(StaticError("failed load sysctl")).kind(ErrorKind::Recoverable)?; + } + + Ok(()) + } +} diff --git a/crates/units/src/oneshot/sysfs.rs b/crates/units/src/oneshot/sysfs.rs @@ -0,0 +1,125 @@ +use std::path::Path; + +use async_trait::async_trait; +use blocking::unblock; +use log::{info, warn}; +use nix::mount::{MsFlags, mount}; + +use kanit_common::error::{Context, ErrorKind, Result, StaticError}; +use kanit_executor::join_all; +use kanit_unit::{Dependencies, Unit}; + +use crate::mounts::{is_fs_available, is_fs_mounted, try_mount_from_fstab}; +use crate::oneshot::ProcFs; +use crate::unit_name; + +pub struct SysFs; + +async fn mount_misc_fs<P: AsRef<Path>>(path: P, name: &'static str) -> Result<()> { + let path = path.as_ref().to_owned(); + + if path.exists() && is_fs_available(name).await? && !is_fs_mounted(&path).await? { + info!("mounting {name}"); + + unblock(move || { + mount( + Some("none"), + &path, + Some(name), + MsFlags::MS_NODEV | MsFlags::MS_NOEXEC | MsFlags::MS_NOSUID, + Some(""), + ) + }) + .await + .with_context(move || format!("failed to mount {name}"))?; + } + + Ok(()) +} + +impl SysFs { + async fn mount_sys() -> Result<()> { + // check if sysfs exists + if !is_fs_available("sysfs").await? { + Err(StaticError("failed to mount sysfs")).kind(ErrorKind::Unrecoverable)?; + }; + + // check if its mounted + let path = Path::new("/sys"); + + if is_fs_mounted(path).await? { + return Ok(()); + } + + // create /sys if it doesn't exist + if async_fs::metadata(path).await.is_err() { + async_fs::create_dir(path) + .await + .context("failed to create /sys")?; + } + + info!("mounting /sys"); + + // try mount from fstab + if try_mount_from_fstab(path).await? { + return Ok(()); + } + + // mount with sysfs fs + unblock(move || { + mount( + Some("none"), + path, + Some("sysfs"), + MsFlags::MS_NODEV | MsFlags::MS_NOEXEC | MsFlags::MS_NOSUID, + Some(""), + ) + }) + .await + .context("failed to mount sysfs")?; + + Ok(()) + } + + async fn mount_misc() -> Result<()> { + join_all([ + mount_misc_fs("/sys/kernel/security", "securityfs"), + mount_misc_fs("/sys/kernel/debug", "debugfs"), + mount_misc_fs("/sys/kernel/config", "configfs"), + mount_misc_fs("/sys/fs/fuse/connections", "fusectl"), + mount_misc_fs("/sys/fs/pstore", "pstore"), + ]) + .await + .into_iter() + .collect::<Result<Vec<_>>>()?; + + if Path::new("/sys/firmware/efi/efivars").exists() + && mount_misc_fs("/sys/firmware/efi/efivars", "efivarfs") + .await + .is_err() + { + // efivarfs can be disabled in kernel parameters + warn!("failed to mount efivarfs"); + } + + // TODO; SELinux + + Ok(()) + } +} + +#[async_trait] +impl Unit for SysFs { + unit_name!("sysfs"); + + fn dependencies(&self) -> Dependencies { + Dependencies::new().need(ProcFs.name()).clone() + } + + async fn start(&mut self) -> Result<()> { + Self::mount_sys().await?; + Self::mount_misc().await?; + + Ok(()) + } +} diff --git a/crates/units/src/services/getty.rs b/crates/units/src/services/getty.rs @@ -0,0 +1,60 @@ +use std::sync::OnceLock; + +use async_trait::async_trait; +use blocking::unblock; +use nix::sys::signal::{Signal, kill}; +use nix::unistd::Pid; + +use kanit_common::error::Result; +use kanit_supervisor::{RestartPolicy, SupervisorBuilder}; +use kanit_unit::{Unit, UnitName}; + +pub struct GeTTY { + name: String, + pid: u32, + tty: &'static str, + serial: bool, +} + +impl GeTTY { + pub fn new(tty: &'static str, serial: bool) -> Self { + Self { + name: format!("getty@{tty}"), + pid: 0, + tty, + serial, + } + } +} + +#[async_trait] +impl Unit for GeTTY { + fn name(&self) -> UnitName { + static NAME: OnceLock<UnitName> = OnceLock::new(); + + NAME.get_or_init(|| UnitName::from(self.name.clone())) + .clone() + } + + async fn start(&mut self) -> Result<()> { + let child = if self.serial { + SupervisorBuilder::new("getty", ["-L", "0", self.tty, "vt100"]) + } else { + SupervisorBuilder::new("getty", ["38400", self.tty]) + } + .restart_policy(RestartPolicy::Always) + .restart_delay(2) + .spawn()?; + + self.pid = child.id(); + + Ok(()) + } + + async fn stop(&mut self) -> Result<()> { + let pid = self.pid; + let _ = unblock(move || kill(Pid::from_raw(pid as i32), Signal::SIGKILL)).await; + + Ok(()) + } +} diff --git a/crates/units/src/services/mod.rs b/crates/units/src/services/mod.rs @@ -0,0 +1,5 @@ +pub use getty::GeTTY; +pub use syslog::Syslog; + +mod getty; +mod syslog; diff --git a/crates/units/src/services/syslog.rs b/crates/units/src/services/syslog.rs @@ -0,0 +1,55 @@ +use async_trait::async_trait; +use blocking::unblock; +use log::info; +use nix::sys::signal::{Signal, kill}; +use nix::unistd::Pid; + +use kanit_common::error::Result; +use kanit_supervisor::{RestartPolicy, SupervisorBuilder}; +use kanit_unit::{Dependencies, Unit}; + +use crate::oneshot::{Clock, Hostname, LocalMount}; +use crate::unit_name; + +pub struct Syslog { + pid: u32, +} + +impl Syslog { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self { pid: 0 } + } +} + +#[async_trait] +impl Unit for Syslog { + unit_name!("syslog"); + + fn dependencies(&self) -> Dependencies { + Dependencies::new() + .need(Clock.name()) + .need(Hostname.name()) + .need(LocalMount.name()) + .clone() + } + + async fn start(&mut self) -> Result<()> { + info!("starting syslog"); + + let child = SupervisorBuilder::new("syslogd", []) + .restart_policy(RestartPolicy::OnFailure) + .spawn()?; + + self.pid = child.id(); + + Ok(()) + } + + async fn stop(&mut self) -> Result<()> { + let pid = self.pid; + let _ = unblock(move || kill(Pid::from_raw(pid as i32), Signal::SIGKILL)).await; + + Ok(()) + } +} diff --git a/docs/async.md b/docs/async.md @@ -0,0 +1,17 @@ +# if or if not to async + +Async must be used in 2 spots: +* Units +* Event loop + +Any code that is called in these spots should be marked as async. If no code is executing concurrently, blocking may be +done in an async block. + +An example would be in startup's initialization of the loader. `Loader::initialize` does call a blocking function +(`std::fs::read`) although as no code is running concurrently, it is allowed. + +It is safe to use `SendWrapper` as the async executor is designated as a local executor (running in a single thread). +Due to the init's job being primarily IO-bound, multithreading only introduces slowdowns with the overhead of locking. + +Locks should only be used where required in cases where a `RefCell` might be held over an `await` point. `Loader` +contains an `ev_lock` which should be held during the event loop to ensure unit references are only held at one point. diff --git a/docs/man/kanit-unit.5 b/docs/man/kanit-unit.5 @@ -0,0 +1,157 @@ +.\" kanit.unit.5 - man page for Kanit unit file format +.\" SPDX-License-Identifier: CC0-1.0 +.TH KANIT.UNIT 5 "August 2025" "kanit 0.1.0" "File Formats and Conventions" +.SH NAME +kanit.unit \- unit file format for the Kanit init system +.SH SYNOPSIS +/etc/kanit/system/\fIunit\fR +.SH DESCRIPTION +The +.B kanit.unit +file format describes service units for the \fBkanit\fR(8) init system. Unit files define how services are launched, supervised, and related to each other. + +Each unit file is a plain-text configuration file, located under \fB/etc/kanit/system/\fIunit\fR\fR where \fIunit\fR is the name of the unit. + +.SH SYNTAX +Unit files use an INI-style format, with simple +.I key = value +assignments. Keys and values are parsed literally; quoting may be used for arguments that contain spaces or special characters. + +Comments begin with a +.B # +character and continue to the end of the line. + +.SH FIELDS +.SS Top-level fields +.TP +.B description +A human-readable summary of the unit's purpose. + +.TP +.B kind +Specifies the unit type. +Valid values are: +.BR oneshot , +.BR daemon , +or +.BR builtin . + +.TP +.B pwd +Working directory for the process. + +.TP +.B root +Root directory to chroot into before execution. + +.TP +.B group +Group identity to run as. Can be a numeric GID or group name. + +.TP +.B user +User identity to run as. Can be a numeric UID or username. + +.TP +.B stdout +Redirect standard output to a specified file. + +.TP +.B stderr +Redirect standard error to a specified file. + +.TP +.B cmd +Command to execute, with optional quoted or escaped arguments. Example: +.RS +.nf +cmd = /bin/echo "Hello world" 'escaped\\nnewline' +.fi +.RE + +.SS [depends] +Dependency relationships between units. + +.TP +.B before +List of units that this unit should start before. + +.TP +.B after +List of units that this unit should start after. + +.TP +.B needs +Hard dependencies. These units must be started successfully. + +.TP +.B uses +Soft dependencies. These units will be started if available, but do not fail the current unit on error. + +.TP +.B wants +Weak dependencies. These units may be started alongside, but are not required. + +.SS [environment] +Environment variables to set for the process. +.TP +Each line sets a +.B VAR = VALUE +pair. + +.SS [restart] +Restart behavior configuration. + +.TP +.B delay +Seconds to wait between restart attempts. + +.TP +.B attempts +Maximum number of restart attempts before giving up. + +.TP +.B policy +Restart policy: one of +.BR never , +.BR always , +.BR on-success , +or +.BR on-failure . + +.SH FILES +.TP +.B /etc/kanit/system/*.unit +Unit definition files. + +.SH EXAMPLE +.EX +# simple daemon +description = Example service +kind = daemon + +cmd = /usr/bin/exampled + +[depends] +after = :net +needs = logger + +[environment] +ENV_MODE = production + +[restart] +policy = on-failure +delay = 2 +attempts = 5 +.EE + +.SH AUTHOR +Sylvia Ivory <git@sivory.net> + +.SH ISSUES +Please report bugs or issues at: +.UR mailto:git@sivory.net +.UE + +.SH SEE ALSO +.BR kanit (8) diff --git a/docs/man/kanit.8 b/docs/man/kanit.8 @@ -0,0 +1,118 @@ +.TH KANIT 8 "2025-08-03" "kanit 0.1.0" "System Management Commands" + +.SH NAME +kanit \- minimal init system and service manager + +.SH SYNOPSIS +.B kanit +.RI [ options ] " subcommand " [ arguments ] + +.SH DESCRIPTION +\fBkanit\fR is a minimal, single-binary init daemon and service manager + +.SH OPTIONS +.TP +.BR \-h , " --help" +Display help information and exit. + +.SH SUBCOMMANDS + +.SS Power Commands + +.TP +.B kanit poweroff +Teardown and power off the system. +.RS +.TP +.BR \-f , " --force" +Skip teardown and forcibly power off. +.RE + +.TP +.B kanit reboot +Teardown and reboot the system. +.RS +.TP +.BR \-f , " --force" +Skip teardown and forcibly reboot. +.RE + +.TP +.B kanit halt +Teardown and halt the system. +.RS +.TP +.BR \-f , " --force" +Skip teardown and forcibly halt. +.RE + +.TP +.B kanit kexec +Teardown and reboot via kexec. +.RS +.TP +.BR \-f , " --force" +Force kexec reboot without teardown. +.RE + +.SS Diagnostic Commands + +.TP +.B kanit blame +Print unit startup times. +.RS +.TP +.BR \-s , " --sorted" +Sort by startup duration. +.RE + +.SS Service Commands + +.TP +.B kanit service +General service management utility. + +.TP +.B kanit service enable +Enable a unit at the specified runlevel. +.RS +.TP +.I <unit> +The name of the unit to enable. +.TP +.I [runlevel] +Optional runlevel. If omitted, uses the default. +.RE + +.TP +.B kanit service disable +Disable a unit at the specified runlevel. +.RS +.TP +.I <unit> +The name of the unit to disable. +.TP +.I [runlevel] +Optional runlevel. If omitted, disables from default. +.RE + +.TP +.B kanit service list +List enabled units. +.RS +.TP +.BR \-p , " --plan" +Show service groups and plan. +.RE + +.SH AUTHOR +Sylvia Ivory <git@sivory.net> + +.SH ISSUES +Please report bugs or issues at: +.UR mailto:git@sivory.net +.UE + +.SH SEE ALSO +.BR kanit-unit (5) + diff --git a/docs/unit.md b/docs/unit.md @@ -0,0 +1,31 @@ +# Unit fields + +```ini +# comments +description = lorem ipsum +kind = oneshot | daemon | builtin + +pwd = /directory +root = /directory +group = gid | group name +user = uid | user name +stdout = /file +stderr = /file + +cmd = /path/to/exec "argument" 'with \n escapes' and-unquoted + +[depends] +before = dep1,dep2,dep3 +after = ... +needs = ... +uses = ... +wants = ... + +[environment] +VAR = VALUE + +[restart] +delay = 5 +attempts = 3 +policy = never | always | on-success | on-failure +``` +\ No newline at end of file diff --git a/justfile b/justfile @@ -0,0 +1,25 @@ +debug: + cargo build --package kanit-multicall + just boot-vm debug + +release: + cargo build --release --package kanit-multicall + just boot-vm release + +min: + cargo +nightly build -Z build-std=std,panic_abort -Z \ + build-std-features=panic_immediate_abort --profile min --package kanit-multicall + just boot-vm min + +test: + cargo test --all + cargo build --package kanit-multicall --features testing + just boot-vm debug + +clean: + cargo clean + rm -rf rootfs alpine.qcow2 initramfs vmlinuz-virt + +boot-vm target: + [ -f "alpine.qcow2" ] || {{justfile_directory()}}/scripts/prepare-vm + {{justfile_directory()}}/scripts/start {{target}} diff --git a/readme.md b/readme.md @@ -0,0 +1,82 @@ +# Kanit + +Toy init system + +## Testing + +To test Kanit, the following dependencies are needed: +* `qemu` (`qemu-system-x86_64`) +* `expect` +* `curl` +* `rust` +* `just` + +Once all dependencies are installed, Kanit can be tested in a vm by running `just`. +The vm's password is set to nothing by default. + +## Controller + +Kanit can be controlled with `kanit`. + +### Units + +Units are written in TOML and can be loaded for next boot with `kanit service enable <unit> [level]`. +They must be stored at `/etc/kanit` to be found by `kanit`. Units are only ran at boot. + +Units can be disabled at next boot with `kanit service disable <unit> [level]` and all enabled units can be +displayed with `kanit service list`. + +### Blame + +The time each unit takes to run can be viewed with `kanit blame` (or sorted with `kanit blame -s`). + +### Power + +The system can be powered off, rebooted, halted, and rebooted with kexec with `kanit <op>` with an +optional `-f` flag to bypass the init. In addition, the standard `poweroff`, `reboot`, and `halt` +commands can be used. + +## Goal + +Kanit aims to be minimal with the ability to be rolled into a single executable (multicall). + +## Todo + +(In no particular order) + +* [x] Fix compiling with `x86_64-unknown-linux-gnu` +* [ ] Service supervision + * [x] `kanit-supervisor` + * [ ] Avoid spawning new process and integrate directly into `init` + * [ ] Record logs +* [ ] Dynamically loading units + * [ ] Move `kanit-rc/services/*` to unit files instead + * [ ] Allow unit files to be baked into the init + * [x] Allow unit files to be loaded at startup +* [ ] (Re)loading units +* [ ] Configuration +* [x] Enabled units list +* [x] Concurrency +* [ ] More graceful fail over + * [x] Emergency shell + * [x] Allow for continuing when unit fails to start + * [ ] Allow for panic recovery (unable to do with `panic = "abort"`) +* [x] Split `kanit-rc/src/init` to separate crate +* [ ] Consider moving `kanit-rc/init/*` to unit files +* [ ] `*dev` + * [x] `mdev` + * [ ] `udev` +* [ ] Testing + * [x] Smoke test + * [ ] Unit tests +* [ ] Syslog + * [x] Busybox + * [ ] Custom +* [ ] Logging + * [x] Startup logger + * [ ] Runtime logger +* [x] Shutdown + +## Credit + +* OpenRC/Alpine for init scripts diff --git a/scripts/answers b/scripts/answers @@ -0,0 +1,19 @@ +KEYMAPOPTS="us us" +HOSTNAMEOPTS=alpine +DEVDOPTS=mdev +INTERFACESOPTS="auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet dhcp +hostname alpine-test +" +TIMEZONEOPTS="UTC" +PROXYOPTS=none +APKREPOSOPTS="-1" +USEROPTS=none +SSHDOPTS=none +NTPOPTS="openntpd" +DISKOPTS="-m sys /dev/sda" +LBUOPTS=none +APKCACHEOPTS=none diff --git a/scripts/controller b/scripts/controller @@ -0,0 +1,75 @@ +#!/usr/bin/env lexpect + +local function read_file(name) + local f, err = io.open(name, 'r') + if not f then + error(err) + end + + local contents = f:read('*a') + + f:close() + + return contents +end + +local function file_exists(name) + assert(name, 'expected file name path') + local f = io.open(name, 'r') + + if f then + f:close() + return true + end + + return false +end + +local args = { + '-m', + '512', + '-nic', + 'user', + '-boot', + 'd', + '-cdrom', + assert(file_exists(arg[1]), 'expected image path to exist') and arg[1], + '-hda', + 'alpine.qcow2', + '-nographic', + '-virtfs', + 'local,path=./tmp.share,mount_tag=host0,security_model=mapped,id=host0,multidevs=remap', +} + +if file_exists('/dev/kvm') then + table.insert(args, '-enable-kvm') +end + +--- + +local lexpect = require('lexpect') + +local session = lexpect.spawn_args('qemu-system-x86_64', args, { + passthrough = true +}) + +local function command(cmd) + session:exp_string('# ') + session:send_line(cmd) +end + +session:exp_string('login: ') +session:send_line('root') + +command('echo \'' .. read_file('./initializer') .. '\' > initializer') +command('chmod +x ./initializer') +command('./initializer prep') +command('setup-alpine -f answers -e') + +session:exp_string('[n] ') +session:send_line('y') + +command('./initializer initramfs') +command('./initializer teardown') + +session:exp_eof() diff --git a/scripts/init b/scripts/init @@ -0,0 +1,31 @@ +#!/bin/sh +export PATH="$PATH:/usr/bin:/bin:/usr/sbin:/sbin" + +/bin/busybox mkdir -p /usr/bin /usr/sbin +/bin/busybox --install -s + +mount -n -t proc proc /proc +mount -n -t sysfs sysfs /sys +mount -n -t devtmpfs devtmpfs /dev || + mount -n -t tmpfs tmpfs /dev + +ln -sf /bin/kmod /bin/modprobe + +modprobe -a sd_mod virtio ext4 9p overlay + +# heh bottoms +mkdir /top /9p-bottom /qcow2-bottom + +mount -t 9p -o trans=virtio,version=9p2000.L,msize=512000,cache=loose,posixacl host0 /9p-bottom +mount /dev/sda3 /qcow2-bottom +mount -t tmpfs none /top + +mkdir -p /top/work /top/root + +mount -t overlay overlay -o ro,lowerdir=/9p-bottom:/qcow2-bottom,upperdir=/top/root,workdir=/top/work /newroot + +mount --move /proc /newroot/proc +mount --move /sys /newroot/sys +mount --move /dev /newroot/dev + +exec switch_root -c /dev/ttyS0 /newroot /sbin/init diff --git a/scripts/initializer b/scripts/initializer @@ -0,0 +1,18 @@ +#!/bin/sh + +case $1 in + prep) + mkdir /tmp/host0 + mount -t 9p -o trans=virtio host0 /tmp/host0 + cp /tmp/host0/* . + ;; + teardown) + umount /tmp/host0 + sync + poweroff + ;; + initramfs) + mkinitfs -i ./init -o /tmp/host0/initramfs -F "base scsi virtio ext4 9p" + cp /media/cdrom/boot/vmlinuz-virt /tmp/host0/ + ;; +esac diff --git a/scripts/prepare-vm b/scripts/prepare-vm @@ -0,0 +1,53 @@ +#!/bin/sh +set -eE + +trap 'cleanup' ERR + +# Initializes an Alpine Linux vm for testing +IMAGE_URL="https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-virt-3.20.1-x86_64.iso" +IMAGE=${IMAGE_URL##*/} + +### + +former_pwd=$(pwd) +cd -P -- "$(dirname "$0")" || return + +download_image() { + echo "downloading alpine image" + + [ ! -f "$IMAGE" ] && curl -o "$IMAGE" $IMAGE_URL + [ ! -f "$IMAGE.sha256" ] && curl -o "$IMAGE.sha256" "$IMAGE_URL.sha256" + + sha256sum -c "$IMAGE.sha256" +} + +create_vm() { + echo "initializing alpine vm" + + [ ! -f "alpine.qcow2" ] && qemu-img create -f qcow2 alpine.qcow2 8G + + mkdir ./tmp.share + + cp ./init ./answers ./tmp.share + + ./controller $IMAGE + + if [ -f "alpine.qcow2" ]; then + echo "generated alpine.qcow2" + mv ./alpine.qcow2 ./tmp.share/initramfs ./tmp.share/vmlinuz-virt "$former_pwd" + fi +} + +cleanup() { + echo "cleaning up" + + rm -fv "$IMAGE" "$IMAGE.sha256" ./alpine.qcow2 + + if [ -d ./tmp.share ]; then + rm -rfv ./tmp.share + fi +} + +download_image +create_vm +cleanup diff --git a/scripts/start b/scripts/start @@ -0,0 +1,30 @@ +#!/bin/sh +set -ex + +MULTICALL="target/x86_64-unknown-linux-musl/$1/kanit-multicall" + +# clean rootfs +rm -rf rootfs + +mkdir -p rootfs/sbin rootfs/etc/kanit/enabled +mkdir -p rootfs/sbin rootfs/etc/kanit/system + +mkdir -p rootfs/etc/kanit/enabled/boot +mkdir -p rootfs/etc/kanit/enabled/default + +cp units/* rootfs/etc/kanit/system + +# symlinks no work +touch rootfs/etc/kanit/enabled/boot/syslog +# getty is the devil +# touch rootfs/etc/kanit/enabled/default/{getty@tty1,getty@ttyS0} + +cp "$MULTICALL" rootfs/sbin/init +cp "$MULTICALL" rootfs/sbin/kanit +cp "$MULTICALL" rootfs/sbin/kanit-supervisor + +[[ -e "/dev/kvm" ]] && KVM=-enable-kvm || KVM="" + +qemu-system-x86_64 -m 512 -nic user -kernel ./vmlinuz-virt -initrd ./initramfs -hda ./alpine.qcow2 \ + -virtfs local,path=./rootfs,mount_tag=host0,security_model=mapped,id=host0,multidevs=remap -nographic $KVM \ + -append "quiet console=ttyS0" diff --git a/src/main.rs b/src/main.rs @@ -0,0 +1,29 @@ +// me when the binary is plural + +use std::env::args; +use std::path::Path; +use std::process::ExitCode; + +fn main() -> ExitCode { + let name = args().next().expect("first arg"); + let name_as_path = Path::new(&name); + let bin_name = name_as_path.file_name().expect("file name").to_str(); + + match bin_name { + Some("init") => kanit_init::handle_cli(), + #[cfg(feature = "cli")] + Some("kanit") => kanit_cli::handle_cli(true), + #[cfg(feature = "cli")] + Some("poweroff") => kanit_cli::handle_cli(false), + #[cfg(feature = "cli")] + Some("reboot") => kanit_cli::handle_cli(false), + #[cfg(feature = "cli")] + Some("halt") => kanit_cli::handle_cli(false), + + Some("kanit-supervisor") => kanit_supervisor::handle_cli(), + _ => { + eprintln!("was unable to locate `{}`", bin_name.unwrap_or("")); + ExitCode::FAILURE + } + } +} diff --git a/units/syslog b/units/syslog @@ -0,0 +1,5 @@ +cmd = syslogd +kind = daemon + +[restart] +policy = on-failure