commit 653b8d161250766258368c50c5e58e1c508af76f Author: Niko Marmeladkov Date: Fri Jul 3 10:47:25 2026 +0300 initial: per-user process supervisor (rss) - start/stop/restart/status/logs/enable/disable/list/daemon - PID-file based state, no daemon, no root - config.toml with placeholders, restart policies, env vars - log tailing with -n/-f and stderr colorization - supervisor per service with SIGTERM handling diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfde7a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +config.toml +*.pid +*.log diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..af420a2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,601 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[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 = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rss" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "nix", + "serde", + "toml", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1f08332 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "rss" +version = "0.1.0" +edition = "2021" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" +chrono = "0.4" +nix = { version = "0.29", default-features = false, features = ["signal", "fs"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b2689e2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 niko + +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/README.md b/README.md new file mode 100644 index 0000000..b1497bf --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# rss — RuSt Supervisor + +Lightweight, per-user process supervisor. No daemon, no root, no virtualization. + +## Philosophy + +- **KISS** — one binary, one job. Each service gets its own supervisor process. +- **UNIX way** — PID files for state, no IPC, no sockets. +- **User-space** — everything under `~/.config/rss/` and `~/.local/share/rss/`. +- **No root** — runs entirely as your user. + +## Usage + +``` +rss start Start a service +rss stop Stop a service +rss restart Restart a service +rss status [name] Show service status +rss logs [-n N] [-f] View logs (last N, follow) +rss enable [--now] Enable auto-start +rss disable Disable auto-start +rss list List all services +rss daemon Start all enabled services +``` + +## Configuration + +Place `config.toml` in `~/.config/rss/config.toml`. + +```toml +[service.NAME] +workdir = "/path/to/project" +command = "your command here" +stop_command = "optional stop command" +timeout_start = 15 +timeout_stop = 10 +restart = "on-failure" # no, always, on-failure +restart_delay = 3 +restart_max = 5 # 0 = unlimited +oneshot = false +stop_signal = "TERM" +env = { KEY = "value" } +``` + +Placeholders in `command` / `stop_command`: `{name}`, `{workdir}`, `{pid}`, `{log_file}`, plus any env var. + +See `config.example.toml` for a full example. + +## Install + +```sh +cargo install --path . +``` + +## License + +MIT diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..b2e1468 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,30 @@ +[service.web] +workdir = "/home/user/projects/web" +command = "python3 -m http.server {port}" +stop_command = "curl -s -X POST http://localhost:{port}/shutdown" +timeout_start = 15 +timeout_stop = 10 +restart = "on-failure" +restart_delay = 3 +restart_max = 5 +oneshot = false +stop_signal = "TERM" +env = { port = "8080", HOST = "0.0.0.0" } + +[service.backup] +workdir = "/home/user" +command = "tar czf /tmp/backup.tar.gz ." +oneshot = true +restart = "no" + +[service.app] +workdir = "/home/user/app" +command = "node server.js --port {PORT}" +stop_command = "kill -INT {pid}" +restart = "always" +restart_delay = 2 +restart_max = 10 +timeout_start = 30 +timeout_stop = 15 +stop_signal = "INT" +env = { PORT = "3000", NODE_ENV = "production" } diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..e60f663 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,58 @@ +use clap::Parser; + +#[derive(Parser)] +#[command(name = "rss", version = "0.1.0", about = "Lightweight process supervisor")] +pub struct Cli { + #[arg(short = 'C', long, global = true, help = "Path to config file")] + pub config: Option, + + #[command(subcommand)] + pub command: Commands, +} + +#[derive(clap::Subcommand)] +pub enum Commands { + /// Start a service + Start { + name: String, + }, + /// Stop a service + Stop { + name: String, + }, + /// Restart a service + Restart { + name: String, + }, + /// Show service status + Status { + name: Option, + }, + /// Show service logs + Logs { + name: String, + #[arg(short = 'n', value_name = "N", help = "Show last N lines")] + lines: Option, + #[arg(short = 'f', help = "Follow new log entries")] + follow: bool, + }, + /// Enable auto-start for a service + Enable { + name: String, + #[arg(long, help = "Start the service immediately")] + now: bool, + }, + /// Disable auto-start for a service + Disable { + name: String, + }, + /// List all configured services + List, + /// Start all enabled services + Daemon, + /// Internal: run supervisor for a service + #[command(hide = true)] + Supervise { + name: String, + }, +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..c3fdd4d --- /dev/null +++ b/src/config.rs @@ -0,0 +1,99 @@ +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Debug, Deserialize)] +pub struct Config { + pub service: HashMap, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ServiceConfig { + pub workdir: String, + pub command: String, + #[serde(default)] + pub stop_command: Option, + #[serde(default = "default_timeout_start")] + #[allow(dead_code)] + pub timeout_start: u64, + #[serde(default = "default_timeout_stop")] + pub timeout_stop: u64, + #[serde(default = "default_restart")] + pub restart: RestartPolicy, + #[serde(default = "default_restart_delay")] + pub restart_delay: u64, + #[serde(default = "default_restart_max")] + pub restart_max: u32, + #[serde(default)] + pub oneshot: bool, + #[serde(default = "default_stop_signal")] + pub stop_signal: String, + #[serde(default)] + pub env: HashMap, +} + +fn default_timeout_start() -> u64 { + 15 +} +fn default_timeout_stop() -> u64 { + 10 +} +fn default_restart() -> RestartPolicy { + RestartPolicy::OnFailure +} +fn default_restart_delay() -> u64 { + 3 +} +fn default_restart_max() -> u32 { + 5 +} +fn default_stop_signal() -> String { + "TERM".to_string() +} + +#[derive(Debug, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum RestartPolicy { + No, + Always, + OnFailure, +} + +impl Config { + pub fn load(path: &str) -> Result> { + let content = std::fs::read_to_string(path)?; + let config: Config = toml::from_str(&content)?; + if config.service.is_empty() { + return Err("no services defined in config".into()); + } + Ok(config) + } +} + +impl ServiceConfig { + pub fn resolve_command(&self, name: &str, log_file: &str) -> String { + let mut cmd = self + .command + .replace("{name}", name) + .replace("{workdir}", &self.workdir) + .replace("{log_file}", log_file); + for (k, v) in &self.env { + cmd = cmd.replace(&format!("{{{}}}", k), v); + } + cmd + } + + pub fn resolve_stop_command(&self, name: &str, pid: u32, log_file: &str) -> Option { + let pid_str = pid.to_string(); + self.stop_command.as_ref().map(|s| { + let mut s = s + .replace("{name}", name) + .replace("{workdir}", &self.workdir) + .replace("{pid}", &pid_str) + .replace("{log_file}", log_file); + for (k, v) in &self.env { + s = s.replace(&format!("{{{}}}", k), v); + } + s + }) + } +} diff --git a/src/log.rs b/src/log.rs new file mode 100644 index 0000000..3eb4c9c --- /dev/null +++ b/src/log.rs @@ -0,0 +1,120 @@ +use std::fs::File; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; +use std::time::Duration; + +const RED: &str = "\x1b[31m"; +const RESET: &str = "\x1b[0m"; + +pub fn show_logs(path: &str, lines: Option, follow: bool) -> std::io::Result<()> { + let color = use_color(); + + if let Some(n) = lines { + print_last_n(path, n, color)?; + } else { + let content = std::fs::read_to_string(path)?; + for line in content.lines() { + println!("{}", colorize(line, color)); + } + } + + if follow { + follow_logs(path, lines.unwrap_or(0), color)?; + } + + Ok(()) +} + +fn print_last_n(path: &str, n: usize, color: bool) -> std::io::Result<()> { + let mut file = File::open(path)?; + let file_size = file.seek(SeekFrom::End(0))?; + if file_size == 0 { + return Ok(()); + } + + let mut buf = Vec::new(); + let mut pos = file_size; + let block = 4096.min(file_size as usize); + let mut newline_count = 0; + + while pos > 0 { + let read_size = block.min(pos as usize); + pos -= read_size as u64; + file.seek(SeekFrom::Start(pos))?; + let mut chunk = vec![0u8; read_size]; + file.read_exact(&mut chunk)?; + buf.splice(0..0, chunk); + + newline_count = buf.iter().filter(|&&b| b == b'\n').count(); + if newline_count > n { + break; + } + } + + let start = if newline_count > n { + let mut skip = newline_count - n; + let mut idx = 0; + for (i, &b) in buf.iter().enumerate() { + if b == b'\n' { + skip -= 1; + if skip == 0 { + idx = i + 1; + break; + } + } + } + idx + } else { + 0 + }; + + if let Ok(s) = std::str::from_utf8(&buf[start..]) { + for line in s.lines() { + println!("{}", colorize(line, color)); + } + } + + Ok(()) +} + +fn follow_logs(path: &str, _printed: usize, color: bool) -> std::io::Result<()> { + let mut file = File::open(path)?; + file.seek(SeekFrom::End(0))?; + let mut reader = BufReader::new(file); + + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => { + std::thread::sleep(Duration::from_millis(100)); + } + Ok(_) => { + let trimmed = line.trim_end_matches('\n').trim_end_matches('\r'); + if !trimmed.is_empty() { + println!("{}", colorize(trimmed, color)); + std::io::stdout().flush().ok(); + } + } + Err(_) => break, + } + } + + Ok(()) +} + +fn colorize(line: &str, color: bool) -> String { + if !color { + return line.to_string(); + } + if line.contains("E: ") || line.starts_with("E:") { + format!("{}{}{}", RED, line, RESET) + } else { + line.to_string() + } +} + +fn use_color() -> bool { + if std::env::var("NO_COLOR").is_ok() { + return false; + } + nix::unistd::isatty(1).unwrap_or(false) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..7cbe50d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,314 @@ +mod cli; +mod config; +mod log; +mod state; +mod supervisor; + +use std::process::{Command, Stdio}; + +use clap::Parser; +use cli::{Cli, Commands}; +use config::Config; +use state::State; +use supervisor::{parse_signal, process_running, wait_for_exit}; + +fn main() { + let cli = Cli::parse(); + + // Internal supervise subcommand — must run before config loading? + if let Commands::Supervise { ref name } = cli.command { + let config_path = resolve_config_path(cli.config.as_deref()); + let config = Config::load(&config_path).unwrap_or_else(|e| { + eprintln!("error: config: {}", e); + std::process::exit(1); + }); + let cfg = match config.service.get(name) { + Some(c) => c, + None => { + eprintln!("error: service '{}' not found in config", name); + std::process::exit(1); + } + }; + supervisor::run_supervisor(name, cfg); + return; + } + + let config_path = resolve_config_path(cli.config.as_deref()); + let config = Config::load(&config_path).unwrap_or_else(|e| { + eprintln!("error: config: {}", e); + std::process::exit(1); + }); + + let state = State::new(); + + match cli.command { + Commands::Start { name } => cmd_start(&name, &config, &config_path), + Commands::Stop { name } => cmd_stop(&name, &config, &state), + Commands::Restart { name } => { + cmd_stop(&name, &config, &state); + cmd_start(&name, &config, &config_path); + } + Commands::Status { name } => cmd_status(name.as_deref(), &config, &state), + Commands::Logs { name, lines, follow } => cmd_logs(&name, lines, follow), + Commands::Enable { name, now } => cmd_enable(&name, now, &config, &state, &config_path), + Commands::Disable { name } => cmd_disable(&name, &state), + Commands::List => cmd_list(&config, &state), + Commands::Daemon => cmd_daemon(&config, &state, &config_path), + Commands::Supervise { .. } => unreachable!(), + } +} + +fn resolve_config_path(custom: Option<&str>) -> String { + if let Some(path) = custom { + return path.to_string(); + } + if let Ok(path) = std::env::var("RSS_CONFIG") { + return path; + } + let home = std::env::var("HOME").expect("HOME not set"); + format!("{}/.config/rss/config.toml", home) +} + +fn cmd_start(name: &str, config: &Config, config_path: &str) { + if !config.service.contains_key(name) { + eprintln!("error: service '{}' not found in config", name); + std::process::exit(1); + } + let self_exe = std::env::current_exe().unwrap_or_else(|_| { + eprintln!("error: cannot determine binary path"); + std::process::exit(1); + }); + + let child = Command::new(&self_exe) + .arg("supervise") + .arg(name) + .env("RSS_CONFIG", config_path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .stdin(Stdio::null()) + .spawn() + .unwrap_or_else(|e| { + eprintln!("error: failed to start supervisor: {}", e); + std::process::exit(1); + }); + + println!("{}", child.id()); + // Detach — forget the child so it becomes orphaned and adopted by init + std::mem::forget(child); +} + +fn cmd_stop(name: &str, config: &Config, state: &State) { + let cfg = match config.service.get(name) { + Some(c) => c, + None => { + eprintln!("error: service '{}' not found", name); + return; + } + }; + + let svc_pid_path = state.service_pid_path(name); + let sup_pid_path = state.supervise_pid_path(name); + let log_path = state.log_path(name); + let log_str = log_path.to_str().unwrap_or(""); + + // Tell supervisor to stop (sets TERMINATE flag) first + if let Some(pid) = State::read_pid(&sup_pid_path) { + if process_running(pid) { + let _ = nix::sys::signal::kill( + nix::unistd::Pid::from_raw(pid as i32), + nix::sys::signal::Signal::SIGTERM, + ); + } + } + + // Stop child process + if let Some(pid) = State::read_pid(&svc_pid_path) { + if process_running(pid) { + // Run stop_command if configured + if let Some(stop_cmd) = cfg.resolve_stop_command(name, pid, log_str) { + let _ = Command::new("sh").arg("-c").arg(&stop_cmd).current_dir(&cfg.workdir).output(); + } + + // Send configured signal + let sig = parse_signal(&cfg.stop_signal); + let _ = nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid as i32), sig); + + // Wait for graceful exit + wait_for_exit(pid, cfg.timeout_stop); + + // Force kill if still alive + if process_running(pid) { + let _ = nix::sys::signal::kill( + nix::unistd::Pid::from_raw(pid as i32), + nix::sys::signal::Signal::SIGKILL, + ); + wait_for_exit(pid, 3); + } + } + } + + // Wait for supervisor to exit (it sees TERMINATE + child dead) + if let Some(pid) = State::read_pid(&sup_pid_path) { + if process_running(pid) { + wait_for_exit(pid, 3); + if process_running(pid) { + let _ = nix::sys::signal::kill( + nix::unistd::Pid::from_raw(pid as i32), + nix::sys::signal::Signal::SIGKILL, + ); + wait_for_exit(pid, 3); + } + } + } + + // Cleanup PID files + State::remove_pid(&svc_pid_path); + State::remove_pid(&sup_pid_path); +} + +fn cmd_status(name: Option<&str>, config: &Config, state: &State) { + if let Some(name) = name { + print_service_status(name, config, state); + } else { + let mut names: Vec<&String> = config.service.keys().collect(); + names.sort(); + for name in names { + print_service_status(name, config, state); + } + } +} + +fn print_service_status(name: &str, _config: &Config, state: &State) { + let svc_pid_path = state.service_pid_path(name); + let sup_pid_path = state.supervise_pid_path(name); + let enabled = state.is_enabled(name); + + let svc_pid = State::read_pid(&svc_pid_path); + let sup_pid = State::read_pid(&sup_pid_path); + + let svc_running = svc_pid.map_or(false, process_running); + let sup_running = sup_pid.map_or(false, process_running); + + let status = if svc_running { + "running" + } else if sup_running { + "restarting" + } else { + "stopped" + }; + + let enabled_str = if enabled { "+" } else { "-" }; + println!( + "{} {} pid={} supervisor={} enabled={}", + status, + name, + svc_pid.map_or(0, |p| p), + sup_pid.map_or(0, |p| p), + enabled_str, + ); +} + +fn cmd_logs(name: &str, mut lines: Option, follow: bool) { + if lines.is_none() && follow { + lines = Some(10); + } + let state = State::new(); + let log_path = state.log_path(name); + let log_str = log_path.to_str().unwrap_or(""); + + if !log_path.exists() { + eprintln!("error: no logs for '{}'", name); + std::process::exit(1); + } + + if let Err(e) = log::show_logs(log_str, lines, follow) { + eprintln!("error: reading logs: {}", e); + std::process::exit(1); + } +} + +fn cmd_enable(name: &str, now: bool, config: &Config, state: &State, config_path: &str) { + if !config.service.contains_key(name) { + eprintln!("error: service '{}' not found in config", name); + return; + } + + if let Err(e) = state.enable(name) { + eprintln!("error: enabling '{}': {}", name, e); + return; + } + + println!("enabled {}", name); + + if now { + cmd_start(name, config, config_path); + } +} + +fn cmd_disable(name: &str, state: &State) { + state.disable(name); + println!("disabled {}", name); +} + +fn cmd_list(config: &Config, state: &State) { + let mut names: Vec<&String> = config.service.keys().collect(); + names.sort(); + + if names.is_empty() { + println!("no services configured"); + return; + } + + println!("{:<12} {:<7} {:>6} {:>6} {}", "status", "name", "pid", "super", "enabled"); + println!("{}", "-".repeat(50)); + for name in names { + let svc_pid_path = state.service_pid_path(name); + let sup_pid_path = state.supervise_pid_path(name); + let enabled = state.is_enabled(name); + + let svc_pid = State::read_pid(&svc_pid_path); + let sup_pid = State::read_pid(&sup_pid_path); + + let svc_running = svc_pid.map_or(false, process_running); + let sup_running = sup_pid.map_or(false, process_running); + + let status = if svc_running { + "running" + } else if sup_running { + "restart" + } else { + "stopped" + }; + + let enabled_str = if enabled { "yes" } else { "no" }; + println!( + "{:<12} {:<7} {:>6} {:>6} {}", + status, + name, + svc_pid.map_or(0, |p| p), + sup_pid.map_or(0, |p| p), + enabled_str, + ); + } +} + +fn cmd_daemon(config: &Config, state: &State, config_path: &str) { + let enabled = state.list_enabled().unwrap_or_default(); + if enabled.is_empty() { + println!("no enabled services"); + return; + } + + for name in &enabled { + if !config.service.contains_key(name) { + continue; + } + let svc_pid_path = state.service_pid_path(name); + let already = State::read_pid(&svc_pid_path).map_or(false, |p| process_running(p)); + if already { + continue; + } + println!("starting {}...", name); + cmd_start(name, config, config_path); + } +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..de1f57a --- /dev/null +++ b/src/state.rs @@ -0,0 +1,89 @@ +use std::path::PathBuf; + +pub struct State { + state_dir: PathBuf, + config_dir: PathBuf, +} + +impl State { + pub fn new() -> Self { + let home = std::env::var("HOME").expect("HOME not set"); + State { + state_dir: PathBuf::from(format!("{}/.local/share/rss", home)), + config_dir: PathBuf::from(format!("{}/.config/rss", home)), + } + } + + pub fn supervise_pid_path(&self, name: &str) -> PathBuf { + self.state_dir.join("supervise").join(format!("{}.pid", name)) + } + + pub fn service_pid_path(&self, name: &str) -> PathBuf { + self.state_dir.join("service").join(format!("{}.pid", name)) + } + + pub fn log_path(&self, name: &str) -> PathBuf { + self.state_dir.join("logs").join(format!("{}.log", name)) + } + + pub fn enabled_path(&self, name: &str) -> PathBuf { + self.config_dir.join("enabled").join(name) + } + + pub fn write_pid(path: &PathBuf) -> std::io::Result<()> { + Self::write_pid_with(path, std::process::id()) + } + + pub fn write_pid_with(path: &PathBuf, pid: u32) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, format!("{}", pid)) + } + + pub fn read_pid(path: &PathBuf) -> Option { + let content = std::fs::read_to_string(path).ok()?; + content.trim().parse().ok() + } + + pub fn remove_pid(path: &PathBuf) { + let _ = std::fs::remove_file(path); + } + + pub fn process_exists(pid: u32) -> bool { + std::path::Path::new(&format!("/proc/{}", pid)).exists() + } + + pub fn is_enabled(&self, name: &str) -> bool { + self.enabled_path(name).exists() + } + + pub fn enable(&self, name: &str) -> std::io::Result<()> { + let path = self.enabled_path(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, "")?; + Ok(()) + } + + pub fn disable(&self, name: &str) { + let _ = std::fs::remove_file(self.enabled_path(name)); + } + + pub fn list_enabled(&self) -> std::io::Result> { + let dir = self.config_dir.join("enabled"); + if !dir.exists() { + return Ok(Vec::new()); + } + let mut names = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + if let Some(name) = entry.file_name().to_str() { + names.push(name.to_string()); + } + } + names.sort(); + Ok(names) + } +} diff --git a/src/supervisor.rs b/src/supervisor.rs new file mode 100644 index 0000000..6f6820c --- /dev/null +++ b/src/supervisor.rs @@ -0,0 +1,196 @@ +use std::io::{BufRead, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use chrono::Local; +use nix::sys::signal::{self, SigAction, SigHandler, Signal}; + +use crate::config::{RestartPolicy, ServiceConfig}; +use crate::state::State; + +pub static TERMINATE: AtomicBool = AtomicBool::new(false); + +extern "C" fn sigterm_handler(_: i32) { + TERMINATE.store(true, Ordering::Relaxed); +} + +pub fn setup_signal_handler() { + let handler = SigHandler::Handler(sigterm_handler); + let action = SigAction::new(handler, nix::sys::signal::SaFlags::SA_RESTART, signal::SigSet::empty()); + unsafe { + let _ = signal::sigaction(Signal::SIGTERM, &action); + let _ = signal::sigaction(Signal::SIGINT, &action); + } +} + +pub fn run_supervisor(name: &str, cfg: &ServiceConfig) { + setup_signal_handler(); + + let state = State::new(); + let supervise_pid = state.supervise_pid_path(name); + let service_pid = state.service_pid_path(name); + let log_path = state.log_path(name); + + State::write_pid(&supervise_pid).ok(); + + if let Some(parent) = log_path.parent() { + std::fs::create_dir_all(parent).ok(); + } + + let mut restarts: u32 = 0; + let max_restarts = if cfg.restart_max == 0 { u32::MAX } else { cfg.restart_max }; + + loop { + let cmd = cfg.resolve_command(name, log_path.to_str().unwrap_or("")); + let log_file = log_path.to_str().unwrap_or("").to_string(); + + let mut child = match spawn_child(name, cfg, &cmd, &log_file) { + Ok(c) => c, + Err(e) => { + append_log(&log_file, &format!("E: failed to spawn: {}", e)); + break; + } + }; + + let child_pid = child.id(); + State::write_pid_with(&service_pid, child_pid).ok(); + + append_log(&log_file, &format!("O: started (pid {})", child_pid)); + + let status = child.wait(); + + match status { + Ok(status) => { + let code = status.code().map(|c| format!("code {}", c)).unwrap_or_else(|| "signal".to_string()); + append_log(&log_file, &format!("O: exited with {}", code)); + + if TERMINATE.load(Ordering::Relaxed) { + append_log(&log_file, "O: supervisor received stop signal, exiting"); + break; + } + + if cfg.oneshot { + append_log(&log_file, "O: oneshot service completed, exiting"); + break; + } + + let should_restart = match cfg.restart { + RestartPolicy::No => false, + RestartPolicy::Always => true, + RestartPolicy::OnFailure => !status.success(), + }; + + if should_restart && restarts < max_restarts { + restarts += 1; + let max_str = if cfg.restart_max == 0 { + "unlimited".to_string() + } else { + cfg.restart_max.to_string() + }; + append_log( + &log_file, + &format!("O: restarting in {}s (attempt {}/{})", cfg.restart_delay, restarts, max_str), + ); + std::thread::sleep(Duration::from_secs(cfg.restart_delay)); + } else { + if cfg.restart != RestartPolicy::No { + append_log(&log_file, "O: max restarts reached, exiting"); + } + break; + } + } + Err(e) => { + append_log(&log_file, &format!("E: wait error: {}", e)); + break; + } + } + } + + State::remove_pid(&service_pid); + State::remove_pid(&supervise_pid); + + // Give pipe-reader threads time to flush + std::thread::sleep(Duration::from_millis(200)); +} + +fn spawn_child( + _name: &str, + cfg: &ServiceConfig, + cmd: &str, + log_file: &str, +) -> std::io::Result { + let mut child = Command::new("sh") + .arg("-c") + .arg(cmd) + .current_dir(&cfg.workdir) + .envs(&cfg.env) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .stdin(Stdio::null()) + .spawn()?; + + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + + let log_out = log_file.to_string(); + std::thread::spawn(move || { + let reader = std::io::BufReader::new(stdout); + for line in reader.lines() { + if let Ok(line) = line { + append_log(&log_out, &format!("O: {}", line)); + } + } + }); + + let log_err = log_file.to_string(); + std::thread::spawn(move || { + let reader = std::io::BufReader::new(stderr); + for line in reader.lines() { + if let Ok(line) = line { + append_log(&log_err, &format!("E: {}", line)); + } + } + }); + + Ok(child) +} + +fn append_log(path: &str, msg: &str) { + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let ts = Local::now().format("%Y-%m-%d %H:%M:%S"); + let _ = writeln!(file, "[{}] {}", ts, msg); + let _ = file.flush(); + } +} + +pub fn parse_signal(name: &str) -> Signal { + match name.to_uppercase().as_str() { + "TERM" => Signal::SIGTERM, + "INT" => Signal::SIGINT, + "QUIT" => Signal::SIGQUIT, + "KILL" => Signal::SIGKILL, + "HUP" => Signal::SIGHUP, + "USR1" => Signal::SIGUSR1, + "USR2" => Signal::SIGUSR2, + _ => Signal::SIGTERM, + } +} + +pub fn process_running(pid: u32) -> bool { + State::process_exists(pid) +} + +pub fn wait_for_exit(pid: u32, timeout_secs: u64) { + let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs); + while std::time::Instant::now() < deadline { + if !process_running(pid) { + return; + } + std::thread::sleep(Duration::from_millis(100)); + } +}