From f8254883517f530bc74183605a7607e74e930981 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 25 Jun 2026 16:59:46 +0200 Subject: [PATCH] init port --- .gitignore | 1 + Cargo.lock | 979 +++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 26 ++ src/check.rs | 535 +++++++++++++++++++++++ src/checkstatus.rs | 145 +++++++ src/cli.rs | 161 +++++++ src/error.rs | 50 +++ src/job.rs | 457 ++++++++++++++++++++ src/jobs_list.rs | 131 ++++++ src/lib.rs | 8 + src/main.rs | 107 +++++ src/modes.rs | 262 ++++++++++++ src/table.rs | 132 ++++++ src/util.rs | 71 ++++ tests/checks.rs | 237 +++++++++++ tests/checkstatus.rs | 132 ++++++ tests/cli_smoke.rs | 79 ++++ 17 files changed, 3513 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/check.rs create mode 100644 src/checkstatus.rs create mode 100644 src/cli.rs create mode 100644 src/error.rs create mode 100644 src/job.rs create mode 100644 src/jobs_list.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/modes.rs create mode 100644 src/table.rs create mode 100644 src/util.rs create mode 100644 tests/checks.rs create mode 100644 tests/checkstatus.rs create mode 100644 tests/cli_smoke.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e210311 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,979 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[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 = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[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 = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "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 = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + +[[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 = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + +[[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 = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "match_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[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 = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[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 = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scriptherder" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "env_logger", + "libc", + "log", + "rand", + "regex", + "rust-ini", + "serde", + "serde_json", + "syslog", + "thiserror", +] + +[[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_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "syslog" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc7e95b5b795122fafe6519e27629b5ab4232c73ebb2428f568e82b1a457ad3" +dependencies = [ + "error-chain", + "hostname", + "libc", + "log", + "time", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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 = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-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 = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f71844c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "scriptherder" +version = "0.1.0" +edition = "2021" + +[lib] +name = "scriptherder" +path = "src/lib.rs" + +[[bin]] +name = "scriptherder" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +regex = "1" +rust-ini = "0.21" +chrono = { version = "0.4", default-features = false, features = ["clock"] } +syslog = "6" +log = "0.4" +env_logger = "0.11" +libc = "0.2" +thiserror = "2" +rand = "0.8" diff --git a/src/check.rs b/src/check.rs new file mode 100644 index 0000000..de3d9df --- /dev/null +++ b/src/check.rs @@ -0,0 +1,535 @@ +use crate::error::ScriptHerderError; +use crate::job::Job; +use crate::util::{parse_time_value, time_to_str}; +use std::collections::BTreeMap; + +/// Python boolean repr used verbatim in evaluation messages. +fn py_bool(b: bool) -> &'static str { + if b { + "True" + } else { + "False" + } +} + +pub const CHECK_DEFAULT_OK: &str = "exit_status=0,max_age=8h"; +pub const CHECK_DEFAULT_WARNING: &str = "exit_status=0,max_age=24h"; + +/// Apply ConfigParser BasicInterpolation to a value. +/// `%%`->`%`; `%(key)s`->value of key (case-folded), recursive; bare `%` is an error. +fn interpolate( + raw: &str, + section: &BTreeMap, + defaults: &BTreeMap, + depth: u8, + filename: &str, +) -> Result { + if depth > 10 { + return Err(ScriptHerderError::check_load( + "Interpolation too deep", + filename, + )); + } + let bytes: Vec = raw.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] != '%' { + out.push(bytes[i]); + i += 1; + continue; + } + // at '%' + if i + 1 >= bytes.len() { + return Err(ScriptHerderError::check_load( + "Bad interpolation: trailing %", + filename, + )); + } + match bytes[i + 1] { + '%' => { + out.push('%'); + i += 2; + } + '(' => { + // read until ')s' + let mut j = i + 2; + let mut key = String::new(); + while j < bytes.len() && bytes[j] != ')' { + key.push(bytes[j]); + j += 1; + } + if j + 1 >= bytes.len() || bytes[j] != ')' || bytes[j + 1] != 's' { + return Err(ScriptHerderError::check_load( + "Bad interpolation syntax", + filename, + )); + } + let key_lc = key.to_lowercase(); + let val = section + .get(&key_lc) + .or_else(|| defaults.get(&key_lc)) + .ok_or_else(|| { + ScriptHerderError::check_load( + format!("Bad interpolation key: {key}"), + filename, + ) + })?; + let resolved = interpolate(val, section, defaults, depth + 1, filename)?; + out.push_str(&resolved); + i = j + 2; + } + _ => { + return Err(ScriptHerderError::check_load( + "Bad interpolation: bare %", + filename, + )) + } + } + } + Ok(out) +} + +/// A single parsed criterion token from an ok/warning string. +#[derive(Debug, Clone)] +pub struct Criterion { + pub what: String, + pub value: Option, + pub negate: bool, +} + +/// Parse a comma-separated criteria string into `Criterion` items. +/// Applies backwards-compat renames, leading-`!` negation, runtime filtering, +/// and in non-runtime mode appends a `stored_status=OK` guard (see `Check::new`). +fn parse_criteria( + data_str: &str, + runtime_mode: bool, + filename: &str, +) -> Result, ScriptHerderError> { + let mut res = Vec::new(); + for raw in data_str.split(',') { + let mut this = raw.trim().to_string(); + if this.is_empty() { + continue; + } + // backwards-compat renames (prefix-aware) + for (old, new) in [ + ("not_running", "!OR_running"), + ("output_not_contains", "!output_contains"), + ] { + if this == old || this.starts_with(&format!("{old}=")) { + this = format!("{new}{}", &this[old.len()..]); + } + } + let mut negate = false; + if let Some(stripped) = this.strip_prefix('!') { + negate = true; + this = stripped.to_string(); + } + if !this.contains('=') { + if this != "OR_running" { + return Err(ScriptHerderError::check_load( + format!("Bad criteria: {this:?}"), + filename, + )); + } + res.push(Criterion { + what: this, + value: None, + negate, + }); + continue; + } + let (what, value) = this.split_once('=').unwrap(); + let what = what.trim().to_string(); + let value = value.trim().to_string(); + let is_runtime_check = what != "max_age" && what != "OR_file_exists"; + if runtime_mode != is_runtime_check { + continue; + } + res.push(Criterion { + what, + value: Some(value), + negate, + }); + } + Ok(res) +} + +/// Parsed check with ok and warning criteria. +#[derive(Clone)] +pub struct Check { + pub filename: String, + pub ok_criteria: Vec, + pub warning_criteria: Vec, +} + +impl Check { + /// Build a `Check` from raw ok/warning strings. + /// In non-runtime mode, appends `stored_status=OK` to both criteria lists. + pub fn new( + ok_str: &str, + warning_str: &str, + filename: &str, + runtime_mode: bool, + ) -> Result { + let mut ok_criteria = parse_criteria(ok_str, runtime_mode, filename)?; + let mut warning_criteria = parse_criteria(warning_str, runtime_mode, filename)?; + if !runtime_mode { + let stored = Criterion { + what: "stored_status".into(), + value: Some("OK".into()), + negate: false, + }; + ok_criteria.push(stored.clone()); + warning_criteria.push(stored); + } + Ok(Check { + filename: filename.to_string(), + ok_criteria, + warning_criteria, + }) + } + + /// Load a `Check` from a `.check` INI file using `load_check_strings`. + pub fn from_file(filename: &str, runtime_mode: bool) -> Result { + let (ok, warning) = load_check_strings(filename)?; + Check::new(&ok, &warning, filename, runtime_mode) + } + + /// Evaluate a job against the OK criteria. + pub fn job_is_ok(&self, job: &mut Job) -> (bool, Vec) { + self.evaluate(&self.ok_criteria.clone(), job) + } + + /// Evaluate a job against the WARNING criteria. + pub fn job_is_warning(&self, job: &mut Job) -> (bool, Vec) { + self.evaluate(&self.warning_criteria.clone(), job) + } + + fn evaluate(&self, criteria: &[Criterion], job: &mut Job) -> (bool, Vec) { + let mut ok_msgs: Vec = Vec::new(); + let mut fail_msgs: Vec = Vec::new(); + let (or_c, and_c): (Vec<&Criterion>, Vec<&Criterion>) = + criteria.iter().partition(|c| c.what.starts_with("OR_")); + + for c in &or_c { + let (status, msg) = self.call_check(c, job); + if status { + return (true, vec![msg]); + } + fail_msgs.push(msg); + } + if and_c.is_empty() { + return (false, fail_msgs); + } + + let mut res = true; + for c in &and_c { + let (status, msg) = self.call_check(c, job); + if !status { + res = false; + fail_msgs.push(msg); + } else { + ok_msgs.push(msg); + } + } + if res { + (true, ok_msgs) + } else { + (false, fail_msgs) + } + } + + fn call_check(&self, c: &Criterion, job: &mut Job) -> (bool, String) { + let (status, mut msg) = match c.what.as_str() { + "exit_status" => check_exit_status(job, c), + "max_age" => check_max_age(job, c), + "output_contains" => check_output_contains(job, c), + "output_matches" => check_output_matches(job, c), + "OR_running" => check_or_running(job, c), + "OR_file_exists" => check_or_file_exists(job, c), + "stored_status" => check_stored_status(job, c), + other => return (false, format!("{other}=unknown_criteria")), + }; + if msg.is_empty() { + let neg = if c.negate { "!" } else { "" }; + msg = format!("{neg}{}={}", c.what, c.value.clone().unwrap_or_default()); + } + (status, msg) + } +} + +fn check_exit_status(job: &mut Job, c: &Criterion) -> (bool, String) { + let value = c.value.clone().unwrap_or_default(); + let mut res = job.exit_status() == value.parse::().ok(); + if c.negate { + res = !res; + } + if res { + return (true, format!("exit={value}")); + } + let actual = job + .exit_status() + .map(|e| e.to_string()) + .unwrap_or_else(|| "None".into()); + if c.negate { + (false, format!("exit={actual}=={value}")) + } else { + (false, format!("exit={actual}!={value}")) + } +} + +fn check_max_age(job: &mut Job, c: &Criterion) -> (bool, String) { + let value = c.value.clone().unwrap_or_default(); + let secs = parse_time_value(&value).expect("max_age value parses"); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let mut res = match job.end_time() { + None => false, + Some(e) => e > (now - secs) as f64, + }; + if c.negate { + res = !res; + } + if res { + return (true, String::new()); + } + if c.negate { + ( + false, + format!("age={}<={}", job.age(), time_to_str(secs as f64)), + ) + } else { + ( + false, + format!("age={}>{}", job.age(), time_to_str(secs as f64)), + ) + } +} + +fn check_output_contains(job: &mut Job, c: &Criterion) -> (bool, String) { + let value = c.value.clone().unwrap_or_default(); + let out = job.output().unwrap_or_default(); + let mut res = out + .windows(value.len().max(1)) + .any(|w| w == value.as_bytes()) + || value.is_empty(); + if c.negate { + res = !res; + } + let neg = if c.negate { "!" } else { "" }; + ( + res, + format!("{neg}output_contains={value}=={}", py_bool(res)), + ) +} + +fn check_output_matches(job: &mut Job, c: &Criterion) -> (bool, String) { + use regex::bytes::Regex; + let value = c.value.clone().unwrap_or_default(); + let out = job.output().unwrap_or_default(); + // Python re.match = anchored at start + let re = Regex::new(&format!("^(?:{value})")).unwrap(); + let mut res = re.is_match(&out); + if c.negate { + res = !res; + } + let neg = if c.negate { "!" } else { "" }; + ( + res, + format!("{neg}output_matches={value}=={}", py_bool(res)), + ) +} + +fn check_or_running(job: &mut Job, c: &Criterion) -> (bool, String) { + let mut res = job.is_running(); + let msg = if res { "is_running" } else { "not_running" }.to_string(); + if c.negate { + res = !res; + } + (res, msg) +} + +fn check_or_file_exists(_job: &mut Job, c: &Criterion) -> (bool, String) { + let value = c.value.clone().unwrap_or_default(); + let mut res = std::path::Path::new(&value).is_file(); + let msg = if res { + format!("file_exists={value}") + } else { + format!("file_does_not_exist={value}") + }; + if c.negate { + res = !res; + } + (res, msg) +} + +fn check_stored_status(job: &mut Job, c: &Criterion) -> (bool, String) { + let value = c.value.clone().unwrap_or_default(); + let mut res = job.check_status() == Some(value.as_str()); + if c.negate { + res = !res; + } + let neg = if c.negate { "!" } else { "" }; + (res, format!("{neg}stored_status={value}=={}", py_bool(res))) +} + +/// Read `[check]` ok/warning from an INI file (rust-ini handles sections, +/// comments, `=`/`:`, continuations). Apply defaults, merge [DEFAULT], interpolate. +pub fn load_check_strings(filename: &str) -> Result<(String, String), ScriptHerderError> { + use ini::Ini; + let conf = Ini::load_from_file(filename) + .map_err(|_| ScriptHerderError::check_load("Failed reading file", filename))?; + + // Build case-folded defaults map: programmatic defaults first, then [DEFAULT] section overrides. + // rust-ini does NOT treat [DEFAULT] specially (unlike Python ConfigParser); + // we look it up as a regular named section. + let mut defaults: BTreeMap = BTreeMap::new(); + defaults.insert("ok".into(), CHECK_DEFAULT_OK.into()); + defaults.insert("warning".into(), CHECK_DEFAULT_WARNING.into()); + if let Some(def) = conf.section(Some("DEFAULT")) { + for (k, v) in def.iter() { + defaults.insert(k.to_lowercase(), v.to_string()); + } + } + + // Build section map: start from defaults, then overlay [check] keys. + let mut section: BTreeMap = defaults.clone(); + let check = conf + .section(Some("check")) + .ok_or_else(|| ScriptHerderError::check_load("Failed loading file", filename))?; + for (k, v) in check.iter() { + section.insert(k.to_lowercase(), v.to_string()); + } + + let ok_raw = section + .get("ok") + .cloned() + .ok_or_else(|| ScriptHerderError::check_load("Failed loading file", filename))?; + let warn_raw = section + .get("warning") + .cloned() + .ok_or_else(|| ScriptHerderError::check_load("Failed loading file", filename))?; + + let ok = interpolate(&ok_raw, §ion, &defaults, 0, filename)?; + let warning = interpolate(&warn_raw, §ion, &defaults, 0, filename)?; + Ok((ok, warning)) +} + +#[cfg(test)] +mod parse_tests { + use super::*; + + #[test] + fn rename_not_running() { + let c = parse_criteria("not_running", true, "f").unwrap(); + assert_eq!(c[0].what, "OR_running"); + assert!(c[0].negate); + } + + #[test] + fn runtime_skips_max_age() { + let c = parse_criteria("exit_status=0,max_age=8h", true, "f").unwrap(); + assert_eq!(c.len(), 1); + assert_eq!(c[0].what, "exit_status"); + } + + #[test] + fn nonruntime_keeps_max_age_only() { + let c = parse_criteria("exit_status=0,max_age=8h", false, "f").unwrap(); + assert_eq!(c.len(), 1); + assert_eq!(c[0].what, "max_age"); + } + + #[test] + fn bad_single_token_errors() { + assert!(parse_criteria("frobnicate", true, "f").is_err()); + } +} + +#[cfg(test)] +mod ini_tests { + use super::*; + + fn sec(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn percent_escape() { + let s = sec(&[]); + assert_eq!(interpolate("100%%", &s, &s, 0, "f").unwrap(), "100%"); + } + + #[test] + fn key_reference() { + let s = sec(&[("base", "exit_status=0")]); + assert_eq!( + interpolate("%(base)s,max_age=8h", &s, &s, 0, "f").unwrap(), + "exit_status=0,max_age=8h" + ); + } + + #[test] + fn bare_percent_errors() { + let s = sec(&[]); + assert!(interpolate("100%done", &s, &s, 0, "f").is_err()); + } + + #[test] + fn unknown_key_errors() { + let s = sec(&[]); + assert!(interpolate("%(nope)s", &s, &s, 0, "f").is_err()); + } + + #[test] + fn load_strings_with_defaults() { + let p = std::env::temp_dir().join(format!("chk_{}.ini", std::process::id())); + std::fs::write(&p, "[check]\nok = exit_status=0, max_age=8h\n").unwrap(); + let (ok, warn) = load_check_strings(p.to_str().unwrap()).unwrap(); + assert_eq!(ok, "exit_status=0, max_age=8h"); + assert_eq!(warn, CHECK_DEFAULT_WARNING); // default applied + std::fs::remove_file(&p).ok(); + } + + #[test] + fn default_section_key_used_as_interpolation_target() { + let p = std::env::temp_dir().join(format!("chk_def_{}.ini", std::process::id())); + std::fs::write( + &p, + "[DEFAULT]\nbase = exit_status=0\n[check]\nok = %(base)s, max_age=8h\n", + ) + .unwrap(); + let (ok, _warn) = load_check_strings(p.to_str().unwrap()).unwrap(); + assert_eq!(ok, "exit_status=0, max_age=8h"); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn default_section_supplies_warning() { + let p = std::env::temp_dir().join(format!("chk_defw_{}.ini", std::process::id())); + std::fs::write( + &p, + "[DEFAULT]\nwarning = exit_status=0, max_age=99h\n[check]\nok = exit_status=0\n", + ) + .unwrap(); + let (_ok, warn) = load_check_strings(p.to_str().unwrap()).unwrap(); + assert_eq!(warn, "exit_status=0, max_age=99h"); // [DEFAULT] warning overrides programmatic default + std::fs::remove_file(&p).ok(); + } + + #[test] + fn self_referential_interpolation_errors() { + let p = std::env::temp_dir().join(format!("chk_rec_{}.ini", std::process::id())); + std::fs::write(&p, "[check]\nok = %(ok)s\nwarning = exit_status=0\n").unwrap(); + let result = load_check_strings(p.to_str().unwrap()); + assert!(result.is_err()); // depth guard yields CheckLoadError, no panic/overflow + std::fs::remove_file(&p).ok(); + } +} diff --git a/src/checkstatus.rs b/src/checkstatus.rs new file mode 100644 index 0000000..2a21add --- /dev/null +++ b/src/checkstatus.rs @@ -0,0 +1,145 @@ +use crate::check::Check; +use crate::error::ScriptHerderError; +use crate::job::Job; +use crate::jobs_list::JobsList; +use crate::util::status_summary; +use std::collections::HashMap; + +/// Aggregated status of job invocations for --mode check (../src/scriptherder.py:805-963). +pub struct CheckStatus { + checks_ok: Vec, + checks_warning: Vec, + checks_unknown: Vec, + checks_critical: Vec, + checks: HashMap, + runtime_mode: bool, + checkdir: String, + last_num_checked: usize, +} + +impl CheckStatus { + pub fn new( + runtime_mode: bool, + checkdir: String, + checks: HashMap, + ) -> CheckStatus { + CheckStatus { + checks_ok: vec![], + checks_warning: vec![], + checks_unknown: vec![], + checks_critical: vec![], + checks, + runtime_mode, + checkdir, + last_num_checked: 0, + } + } + + /// Load and cache the evaluation criteria for this job name. + pub fn get_check(&mut self, name: &str) -> Result<&Check, ScriptHerderError> { + if !self.checks.contains_key(name) { + let path = std::path::Path::new(&self.checkdir).join(format!("{name}.ini")); + let p = path.to_string_lossy().to_string(); + let check = Check::from_file(&p, self.runtime_mode) + .map_err(|_| ScriptHerderError::check_load("Failed loading check", p))?; + self.checks.insert(name.to_string(), check); + } + Ok(self.checks.get(name).unwrap()) + } + + pub fn check_jobs(&mut self, jobs: JobsList) { + self.checks_ok.clear(); + self.checks_warning.clear(); + self.checks_unknown.clear(); + self.checks_critical.clear(); + + let groups = jobs.by_name_ordered(); + self.last_num_checked = groups.len(); + let mut all_jobs: Vec> = jobs.jobs.into_iter().map(Some).collect(); + + for (name, mut indices) in groups { + // Load+cache and clone the check so we can mutate jobs freely. + let check = match self.get_check(&name) { + Ok(c) => c.clone(), + Err(_) => { + let i = *indices.last().unwrap(); + let mut job = all_jobs[i].take().unwrap(); + job.set_check_status("UNKNOWN").unwrap(); + job.set_check_reason("Failed to load check".into()); + self.checks_unknown.push(job); + continue; + } + }; + let oldest = indices[0]; + indices.reverse(); // most recent first + let mut matched = false; + for i in indices { + let job = all_jobs[i].as_mut().unwrap(); + job.check(&check); + if job.is_ok() { + self.checks_ok.push(all_jobs[i].take().unwrap()); + matched = true; + break; + } else if job.is_warning() { + self.checks_warning.push(all_jobs[i].take().unwrap()); + matched = true; + break; + } else if job.is_critical() { + self.checks_critical.push(all_jobs[i].take().unwrap()); + matched = true; + break; + } + } + if !matched { + self.checks_critical.push(all_jobs[oldest].take().unwrap()); + } + } + } + + pub fn num_jobs(&self) -> usize { + self.last_num_checked + } + + pub fn aggregate_status(&self) -> (String, Option) { + if self.num_jobs() == 1 { + if let Some(j) = self.checks_ok.last() { + return ("OK".into(), j.check_reason().map(String::from)); + } + if let Some(j) = self.checks_warning.last() { + return ("WARNING".into(), j.check_reason().map(String::from)); + } + if let Some(j) = self.checks_critical.last() { + return ("CRITICAL".into(), j.check_reason().map(String::from)); + } + if let Some(j) = self.checks_unknown.last() { + return ("UNKNOWN".into(), j.check_reason().map(String::from)); + } + return ("FAIL".into(), Some("No jobs found?".into())); + } + if !self.checks_critical.is_empty() { + return ( + "CRITICAL".into(), + Some(status_summary(self.num_jobs(), &self.checks_critical)), + ); + } + if !self.checks_warning.is_empty() { + return ( + "WARNING".into(), + Some(status_summary(self.num_jobs(), &self.checks_warning)), + ); + } + if !self.checks_unknown.is_empty() { + return ( + "UNKNOWN".into(), + Some(status_summary(self.num_jobs(), &self.checks_unknown)), + ); + } + if !self.checks_ok.is_empty() { + return ( + "OK".into(), + Some(status_summary(self.num_jobs(), &self.checks_ok)), + ); + } + ("UNKNOWN".into(), Some("No jobs found?".into())) + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..22e0f58 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,161 @@ +use clap::{Parser, Subcommand}; + +pub const DEFAULT_DATADIR: &str = "/var/cache/scriptherder"; +pub const DEFAULT_CHECKDIR: &str = "/etc/scriptherder/check"; +pub const DEFAULT_UMASK: &str = "077"; + +#[derive(Parser)] +#[command(name = "scriptherder", about = "Script herder script")] +pub struct RawArgs { + #[arg(long, default_value_t = false)] + pub debug: bool, + #[arg(short = 'd', long, default_value = DEFAULT_DATADIR)] + pub datadir: String, + #[arg(long, default_value = DEFAULT_CHECKDIR)] + pub checkdir: String, + #[command(subcommand)] + pub mode: RawMode, +} + +#[derive(Subcommand)] +pub enum RawMode { + Wrap { + #[arg(short = 'N', long, required = true)] + name: String, + #[arg(long, default_value = DEFAULT_UMASK)] + umask: String, + #[arg(long, default_value_t = false)] + syslog: bool, + #[arg(long = "random-sleep", default_value_t = 0)] + random_sleep: u64, + #[arg(required = true, num_args = 1.., trailing_var_arg = true)] + cmd: Vec, + }, + Ls { + names: Vec, + }, + Check { + names: Vec, + }, + Lastlog { + names: Vec, + }, + Lastfaillog { + names: Vec, + }, +} + +/// Replicate Python argv preprocessing: drop a leading `--mode`, default empty to `ls`. +pub fn preprocess(mut args: Vec) -> Vec { + if args.first().map(|s| s == "--mode").unwrap_or(false) { + args.remove(0); + } + if args.is_empty() { + args.push("ls".to_string()); + } + args +} + +pub fn parse() -> RawArgs { + let argv: Vec = std::env::args().skip(1).collect(); + let processed = preprocess(argv); + let mut full = vec!["scriptherder".to_string()]; + full.extend(processed); + let args = RawArgs::parse_from(full); + if let RawMode::Wrap { umask, .. } = &args.mode { + if umask.len() != 3 { + eprintln!("error: Umask must be 3 digits (e.g. the default '{DEFAULT_UMASK}')"); + std::process::exit(2); + } + } + args +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preprocess_strips_mode_and_defaults_ls() { + assert_eq!( + preprocess(vec!["--mode".into(), "ls".into()]), + vec!["ls".to_string()] + ); + assert_eq!(preprocess(vec![]), vec!["ls".to_string()]); + assert_eq!( + preprocess(vec!["check".into(), "foo".into()]), + vec!["check".to_string(), "foo".to_string()] + ); + } + + #[test] + fn wrap_captures_trailing_hyphen_args() { + // Verify wrap with `--` separator captures cmd including -v style flags + let args = RawArgs::parse_from([ + "scriptherder", + "wrap", + "-N", + "myscript", + "--", + "/bin/foo", + "-v", + ]); + match args.mode { + RawMode::Wrap { name, cmd, .. } => { + assert_eq!(name, "myscript"); + assert_eq!(cmd, vec!["/bin/foo", "-v"]); + } + _ => panic!("expected Wrap"), + } + } + + #[test] + fn global_flags_before_subcommand() { + let args = + RawArgs::parse_from(["scriptherder", "--debug", "-d", "/tmp/data", "ls", "job1"]); + assert!(args.debug); + assert_eq!(args.datadir, "/tmp/data"); + match args.mode { + RawMode::Ls { names } => assert_eq!(names, vec!["job1"]), + _ => panic!("expected Ls"), + } + } + + #[test] + fn wrap_defaults() { + let args = RawArgs::parse_from(["scriptherder", "wrap", "-N", "test", "--", "/bin/true"]); + match args.mode { + RawMode::Wrap { + umask, + syslog, + random_sleep, + cmd, + .. + } => { + assert_eq!(umask, "077"); + assert!(!syslog); + assert_eq!(random_sleep, 0); + assert_eq!(cmd, vec!["/bin/true"]); + } + _ => panic!("expected Wrap"), + } + } + + #[test] + fn preprocess_strips_mode_keeps_value_and_rest() { + // --mode wrap foo → [wrap, foo] + assert_eq!( + preprocess(vec!["--mode".into(), "wrap".into(), "foo".into()]), + vec!["wrap".to_string(), "foo".to_string()] + ); + } + + #[test] + fn check_with_names() { + let args = RawArgs::parse_from(["scriptherder", "check", "jobA", "jobB"]); + match args.mode { + RawMode::Check { names } => assert_eq!(names, vec!["jobA", "jobB"]), + _ => panic!("expected Check"), + } + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..67c8de5 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,50 @@ +use thiserror::Error; + +/// Errors raised while loading job or check files. Mirrors the Python +/// ScriptHerderError hierarchy (reason + filename). +#[derive(Debug, Error)] +pub enum ScriptHerderError { + #[error("{reason} (file {filename})")] + JobLoad { reason: String, filename: String }, + #[error("{reason} (file {filename})")] + CheckLoad { reason: String, filename: String }, +} + +impl ScriptHerderError { + pub fn job_load(reason: impl Into, filename: impl Into) -> Self { + ScriptHerderError::JobLoad { + reason: reason.into(), + filename: filename.into(), + } + } + pub fn check_load(reason: impl Into, filename: impl Into) -> Self { + ScriptHerderError::CheckLoad { + reason: reason.into(), + filename: filename.into(), + } + } + pub fn filename(&self) -> &str { + match self { + ScriptHerderError::JobLoad { filename, .. } => filename, + ScriptHerderError::CheckLoad { filename, .. } => filename, + } + } + pub fn reason(&self) -> &str { + match self { + ScriptHerderError::JobLoad { reason, .. } => reason, + ScriptHerderError::CheckLoad { reason, .. } => reason, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn carries_reason_and_filename() { + let e = ScriptHerderError::check_load("Failed reading file", "/x.ini"); + assert_eq!(e.reason(), "Failed reading file"); + assert_eq!(e.filename(), "/x.ini"); + } +} diff --git a/src/job.rs b/src/job.rs new file mode 100644 index 0000000..3dffe8c --- /dev/null +++ b/src/job.rs @@ -0,0 +1,457 @@ +use crate::error::ScriptHerderError; +use crate::util::time_to_str; +use serde::{Deserialize, Serialize}; +use std::io::Read; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn now_secs() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs_f64() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobData { + pub version: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default)] + pub cmd: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_time: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_time: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_filename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub check_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub check_reason: Option, +} + +pub const EXIT_STATUS_NAMES: [&str; 4] = ["OK", "WARNING", "CRITICAL", "UNKNOWN"]; + +pub struct Job { + pub data: JobData, + output: Option>, +} + +impl Job { + pub fn new(name: &str, cmd: Vec) -> Result { + let mut data = JobData { + version: 2, + name: if name.is_empty() { + None + } else { + Some(name.to_string()) + }, + cmd: cmd.clone(), + start_time: None, + end_time: None, + exit_status: None, + pid: None, + filename: None, + output: None, + output_filename: None, + output_size: None, + check_status: None, + check_reason: None, + }; + if data.name.is_none() && !cmd.is_empty() { + // basename of cmd[0] + let base = std::path::Path::new(&cmd[0]) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(&cmd[0]) + .to_string(); + data.name = Some(base); + } + Ok(Job { data, output: None }) + } + + pub fn from_data(data: JobData) -> Result { + if data.version != 1 && data.version != 2 { + return Err(ScriptHerderError::job_load( + format!("Unknown version: {}", data.version), + data.filename.clone().unwrap_or_default(), + )); + } + Ok(Job { data, output: None }) + } + + pub fn from_file(filename: &str) -> Result { + let mut f = std::fs::File::open(filename).map_err(|e| { + ScriptHerderError::job_load(format!("Error ({e}) loading job output"), filename) + })?; + let mut buf = String::new(); + f.read_to_string(&mut buf).map_err(|e| { + ScriptHerderError::job_load(format!("Error ({e}) loading job output"), filename) + })?; + let mut data: JobData = serde_json::from_str(&buf) + .map_err(|_| ScriptHerderError::job_load("JSON parsing failed", filename))?; + data.filename = Some(filename.to_string()); + Job::from_data(data) + } + + pub fn name(&self) -> String { + match &self.data.name { + Some(n) => n.clone(), + None => self.cmd().to_string(), + } + } + + pub fn cmd(&self) -> &str { + self.data.cmd.first().map(|s| s.as_str()).unwrap_or("") + } + + pub fn args(&self) -> Vec { + self.data.cmd.iter().skip(1).cloned().collect() + } + + pub fn start_time(&self) -> Option { + self.data.start_time + } + + pub fn end_time(&self) -> Option { + self.data.end_time + } + + pub fn exit_status(&self) -> Option { + self.data.exit_status + } + + pub fn pid(&self) -> Option { + self.data.pid + } + + pub fn filename(&self) -> Option<&str> { + self.data.filename.as_deref() + } + + pub fn output_filename(&self) -> Option<&str> { + self.data.output_filename.as_deref() + } + + pub fn check_status(&self) -> Option<&str> { + self.data.check_status.as_deref() + } + + pub fn check_reason(&self) -> Option<&str> { + self.data.check_reason.as_deref() + } + + /// A job is considered "run" (has results) when both start and end times are set. + pub fn is_running(&self) -> bool { + self.data.start_time.is_some() && self.data.end_time.is_some() + } + + pub fn is_ok(&self) -> bool { + self.check_status() == Some("OK") + } + + pub fn is_warning(&self) -> bool { + self.check_status() == Some("WARNING") + } + + pub fn is_critical(&self) -> bool { + self.check_status() == Some("CRITICAL") + } + + pub fn age(&self) -> String { + match self.data.start_time { + None => "N/A".to_string(), + Some(st) => time_to_str(now_secs() - st), + } + } + + pub fn duration_str(&self) -> String { + match (self.data.end_time, self.data.start_time) { + (Some(e), Some(s)) => time_to_str(e - s), + _ => "NaN".to_string(), + } + } + + pub fn status_summary(&self) -> String { + if !self.is_running() { + return format!("{}[not_running]", self.name()); + } + let age = time_to_str(now_secs() - self.data.start_time.unwrap()); + format!( + "{}[exit={},age={}]", + self.name(), + self.exit_status() + .map(|e| e.to_string()) + .unwrap_or_else(|| "None".to_string()), + age + ) + } + + /// Python `__str__` + pub fn display(&self) -> String { + self.status_summary() + } + + pub fn set_check_status(&mut self, value: &str) -> Result<(), ScriptHerderError> { + if !EXIT_STATUS_NAMES.contains(&value) { + return Err(ScriptHerderError::job_load( + format!("Unknown check_status {value:?}"), + "", + )); + } + self.data.check_status = Some(value.to_string()); + Ok(()) + } + + pub fn set_check_reason(&mut self, value: String) { + self.data.check_reason = Some(value); + } + + /// Determine OK/WARNING/CRITICAL status from a Check, storing status + reason. + /// Mirrors ../src/scriptherder.py:401-420. + pub fn check(&mut self, check: &crate::check::Check) { + let (status, msg) = check.job_is_ok(self); + if status { + self.set_check_status("OK").unwrap(); + self.set_check_reason(msg.join(", ")); + } else { + let (wstatus, warn_msg) = check.job_is_warning(self); + let mut merged = msg.clone(); + for m in warn_msg { + if !merged.contains(&m) { + merged.push(m); + } + } + self.set_check_status(if wstatus { "WARNING" } else { "CRITICAL" }) + .unwrap(); + self.set_check_reason(merged.join(", ")); + } + } + + /// Lazy-loads output: first from in-memory buffer, then from output_filename, + /// then from the inline `data.output` string field. + pub fn output(&mut self) -> Option> { + if let Some(o) = &self.output { + return Some(o.clone()); + } + if self.data.output.is_none() { + if let Some(fname) = self.data.output_filename.clone() { + if let Ok(bytes) = std::fs::read(&fname) { + return Some(bytes); + } + } + } + self.data.output.as_ref().map(|s| s.as_bytes().to_vec()) + } + + /// Run the command, capturing stdout and stderr. + /// Python uses `stderr=subprocess.STDOUT` which interleaves in real time; + /// Rust's `Output` captures them separately — we concatenate stdout+stderr + /// as the closest faithful approximation. + pub fn run(&mut self) { + use std::process::{Command, Stdio}; + self.data.start_time = Some(now_secs()); + // Spawn (not output()) so the child pid can be recorded, mirroring Python `proc.pid`. + let child = Command::new(&self.data.cmd[0]) + .args(&self.data.cmd[1..]) + .current_dir("/") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) // merged below + .spawn(); + match child { + Ok(c) => { + self.data.pid = Some(c.id() as i32); + let out = c.wait_with_output(); + self.data.end_time = Some(now_secs()); + match out { + Ok(out) => { + // Python merges stderr into stdout (STDOUT). Concatenate. + let mut merged = out.stdout; + merged.extend_from_slice(&out.stderr); + self.data.exit_status = Some(out.status.code().unwrap_or(-1)); + self.output = Some(merged); + } + Err(_) => { + self.data.exit_status = Some(-1); + self.output = Some(Vec::new()); + } + } + } + Err(_) => { + self.data.end_time = Some(now_secs()); + self.data.exit_status = Some(-1); + self.output = Some(Vec::new()); + } + } + } + + /// Set start/end times directly; intended for test support only. + pub fn set_times_for_test(&mut self, start: f64, end: f64) { + self.data.start_time = Some(start); + self.data.end_time = Some(end); + } + + pub fn save_to_file( + &mut self, + datadir: &str, + filename: Option<&str>, + umask_octal: &str, + ) -> std::io::Result<()> { + use chrono::{Local, TimeZone}; + use std::io::Write; + + let fname = match filename { + Some(f) => f.to_string(), + None => { + // sanitize name: non-alphanumeric -> '_' + let sanitized: String = self + .name() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '_' }) + .collect(); + let st = self.data.start_time.expect("start_time set before save"); + let secs = st.trunc() as i64; + let micros = ((st.fract()) * 1_000_000.0).round() as u32; + let dt = Local.timestamp_opt(secs, 0).single().expect("valid ts"); + // Match Python `{:03}`.format(_ts.microsecond): full microseconds, min 3-digit pad. + let time_str = format!("{}.{:03}", dt.format("%Y%m%dT%H%M%S"), micros); + format!( + "{sanitized}__ts-{time_str}_pid-{}", + self.data.pid.map(|p| p.to_string()).unwrap_or_default() + ) + } + }; + let full = std::path::Path::new(datadir).join(&fname); + let full = full.to_str().unwrap().to_string(); + + // umask from 3-digit octal string, e.g. "077" + let umask_val = u32::from_str_radix(umask_octal, 8).unwrap_or(0o077); + let old_umask = unsafe { libc::umask(umask_val as libc::mode_t) }; + + let output_fn = format!("{full}_output"); + if self.output.is_some() { + self.data.output_filename = Some(format!("{output_fn}.data")); + self.data.output_size = self.output.as_ref().map(|o| o.len()); + } + + // write metadata json (indent 4, sorted keys) atomically + let value = serde_json::to_value(&self.data).unwrap(); + let sorted = sort_json_keys(value); + let mut buf = Vec::new(); + let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter); + use serde::Serialize as _; + sorted.serialize(&mut ser).unwrap(); + let json = String::from_utf8(buf).unwrap(); + { + let mut f = std::fs::File::create(format!("{full}.tmp"))?; + f.write_all(json.as_bytes())?; + f.write_all(b"\n")?; + } + std::fs::rename(format!("{full}.tmp"), format!("{full}.json"))?; + self.data.filename = Some(full.clone()); + + if let Some(out) = self.output.take() { + let out_target = self.data.output_filename.clone().unwrap(); + { + let mut fd = std::fs::File::create(format!("{out_target}.tmp"))?; + fd.write_all(&out)?; + } + std::fs::rename(format!("{out_target}.tmp"), &out_target)?; + } + + unsafe { + libc::umask(old_umask); + } + Ok(()) + } +} + +/// Recursively reorder JSON object keys alphabetically (Python sort_keys=True). +fn sort_json_keys(value: serde_json::Value) -> serde_json::Value { + use serde_json::Value; + match value { + Value::Object(map) => { + let mut sorted = serde_json::Map::new(); + let mut keys: Vec<_> = map.keys().cloned().collect(); + keys.sort(); + for k in keys { + sorted.insert(k.clone(), sort_json_keys(map[&k].clone())); + } + Value::Object(sorted) + } + Value::Array(arr) => Value::Array(arr.into_iter().map(sort_json_keys).collect()), + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn save_and_reload_roundtrip() { + let dir = std::env::temp_dir().join(format!("sh_test_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut job = Job::new("rt job", vec!["/bin/echo".into(), "hi".into()]).unwrap(); + job.run(); + job.save_to_file(dir.to_str().unwrap(), None, "077") + .unwrap(); + let saved = job.filename().unwrap().to_string(); + let reloaded = Job::from_file(&format!("{saved}.json")).unwrap(); + assert_eq!(reloaded.name(), "rt job"); + assert_eq!(reloaded.exit_status(), Some(0)); + // output stored in a sibling .data file + let out_fn = reloaded.output_filename().unwrap(); + assert!(std::path::Path::new(out_fn).exists()); + + // formatting fidelity: 4-space indent and sorted keys (Python indent=4, sort_keys=True) + let text = std::fs::read_to_string(format!("{saved}.json")).unwrap(); + assert!( + text.lines() + .any(|l| l.starts_with(" \"") && !l.starts_with(" ")), + "expected a line with exactly 4-space indent before a key" + ); + let pos_cmd = text.find("\"cmd\"").unwrap(); + let pos_name = text.find("\"name\"").unwrap(); + let pos_version = text.find("\"version\"").unwrap(); + assert!( + pos_cmd < pos_name && pos_name < pos_version, + "keys must be sorted: cmd < name < version" + ); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn run_echo_captures_output_and_times() { + let mut job = Job::new("echo_test", vec!["/bin/echo".into(), "test".into()]).unwrap(); + job.run(); + assert!(job.is_running()); + assert_eq!(job.output().unwrap(), b"test\n"); + assert!(job.start_time().unwrap() <= job.end_time().unwrap()); + assert!(job.pid().is_some()); + } + + #[test] + fn name_defaults_to_basename() { + let job = Job::new("", vec!["/bin/echo".into()]).unwrap(); + assert_eq!(job.name(), "echo"); + } + + #[test] + fn rejects_unknown_version() { + let data: JobData = serde_json::from_str(r#"{"version":9}"#).unwrap(); + assert!(Job::from_data(data).is_err()); + } +} diff --git a/src/jobs_list.rs b/src/jobs_list.rs new file mode 100644 index 0000000..462edf2 --- /dev/null +++ b/src/jobs_list.rs @@ -0,0 +1,131 @@ +use crate::error::ScriptHerderError; +use crate::job::Job; +use std::collections::HashMap; + +pub struct JobsList { + pub jobs: Vec, +} + +impl JobsList { + pub fn from_jobs(mut jobs: Vec, load_not_running: bool) -> JobsList { + jobs.sort_by(|a, b| { + let sa = a.start_time().unwrap_or(0.0); + let sb = b.start_time().unwrap_or(0.0); + sa.partial_cmp(&sb).unwrap_or(std::cmp::Ordering::Equal) + }); + let _ = load_not_running; // only meaningful in from_dir + JobsList { jobs } + } + + pub fn from_dir( + datadir: &str, + checkdir: &str, + names: &[String], + load_not_running: bool, + ) -> Result { + let mut jobs: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(datadir) { + for e in entries.flatten() { + let path = e.path(); + if !path.is_file() { + continue; + } + let fname = path.to_string_lossy().to_string(); + if !fname.ends_with(".json") { + continue; + } + match Job::from_file(&fname) { + Ok(job) => { + if !names.is_empty() && names != ["ALL"] && !names.contains(&job.name()) { + continue; + } + jobs.push(job); + } + Err(exc) => { + log::warn!( + "Failed loading job file {:?} ({})", + exc.filename(), + exc.reason() + ); + } + } + } + } + let mut list = JobsList::from_jobs(jobs, load_not_running); + if load_not_running { + list.load_not_running(checkdir, names); + } + Ok(list) + } + + fn load_not_running(&mut self, checkdir: &str, names: &[String]) { + let present: std::collections::HashSet = + self.jobs.iter().map(|j| j.name()).collect(); + if let Ok(entries) = std::fs::read_dir(checkdir) { + for e in entries.flatten() { + let path = e.path(); + if !path.is_file() { + continue; + } + let fname = path.file_name().unwrap().to_string_lossy().to_string(); + if !fname.ends_with(".ini") { + continue; + } + let name = fname[..fname.len() - 4].to_string(); + if !names.is_empty() && names != ["ALL"] && !names.contains(&name) { + continue; + } + if !present.contains(&name) { + if let Ok(job) = Job::new(&name, vec![]) { + self.jobs.push(job); + } + } + } + } + } + + /// Group job indices by name, preserving first-seen insertion order (matches Python dict order). + pub fn by_name_ordered(&self) -> Vec<(String, Vec)> { + let mut idx: HashMap = HashMap::new(); + let mut groups: Vec<(String, Vec)> = Vec::new(); + for (i, job) in self.jobs.iter().enumerate() { + let name = job.name(); + match idx.get(&name) { + Some(&g) => groups[g].1.push(i), + None => { + idx.insert(name.clone(), groups.len()); + groups.push((name, vec![i])); + } + } + } + groups + } + + pub fn last_of_each(&self) -> Vec { + self.by_name_ordered() + .into_iter() + .map(|(_, v)| *v.last().unwrap()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::job::Job; + + #[test] + fn groups_and_sorts_by_start_time() { + let mut j1 = Job::new("a", vec!["/bin/true".into()]).unwrap(); + let mut j2 = Job::new("a", vec!["/bin/true".into()]).unwrap(); + j1.set_times_for_test(100.0, 101.0); + j2.set_times_for_test(200.0, 201.0); + let list = JobsList::from_jobs(vec![j2, j1], false); // out of order + let grouped = list.by_name_ordered(); + assert_eq!(grouped.len(), 1); + assert_eq!(grouped[0].0, "a"); + // sorted oldest-first: index 0 should be the start_time=100 job + let last = list.last_of_each(); + assert_eq!(list.jobs[last[0]].start_time(), Some(200.0)); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8a194b3 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,8 @@ +pub mod check; +pub mod checkstatus; +pub mod cli; +pub mod error; +pub mod job; +pub mod jobs_list; +pub mod table; +pub mod util; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..485a619 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,107 @@ +use scriptherder::cli::{self, RawArgs, RawMode}; +use std::io::{IsTerminal, Write}; + +mod modes; + +/// Logger that writes to stderr and (for `wrap --syslog`) also to syslog. +/// Mirrors ../src/scriptherder.py:1361-1380. +struct ShLogger { + stderr_level: log::LevelFilter, + syslog: Option>>, +} + +impl log::Log for ShLogger { + fn enabled(&self, metadata: &log::Metadata) -> bool { + metadata.level() <= self.stderr_level || self.syslog.is_some() + } + + fn log(&self, record: &log::Record) { + // stderr handler honours its own level. + if record.level() <= self.stderr_level { + let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S,%3f"); + let _ = writeln!( + std::io::stderr(), + "{}: MainThread {} {}", + now, + record.level(), + record.args() + ); + } + // syslog handler logs at INFO and above. + if let Some(sl) = &self.syslog { + if record.level() <= log::Level::Info { + if let Ok(mut logger) = sl.lock() { + let msg = format!("{} {}", record.level(), record.args()); + let _ = match record.level() { + log::Level::Error => logger.err(msg), + log::Level::Warn => logger.warning(msg), + _ => logger.info(msg), + }; + } + } + } + } + + fn flush(&self) { + let _ = std::io::stderr().flush(); + } +} + +/// Build and install the global logger honouring `--debug`, TTY state and +/// `wrap --syslog` (../src/scriptherder.py:1361-1380). +fn init_logging(args: &RawArgs) { + // --debug → DEBUG, else INFO; drop to ERROR when stderr is not a TTY and not debug. + let stderr_level = if args.debug { + log::LevelFilter::Debug + } else if !std::io::stderr().is_terminal() { + log::LevelFilter::Error + } else { + log::LevelFilter::Info + }; + + let want_syslog = matches!(&args.mode, RawMode::Wrap { syslog: true, .. }); + let syslog = if want_syslog { + let name = match &args.mode { + RawMode::Wrap { name, .. } => name.clone(), + _ => "scriptherder".to_string(), + }; + // SysLogHandler("/dev/log") with format "{name}: {LEVEL} {msg}". + let formatter = syslog::Formatter3164 { + facility: syslog::Facility::LOG_USER, + hostname: None, + process: name, + pid: std::process::id(), + }; + syslog::unix(formatter).ok().map(std::sync::Mutex::new) + } else { + None + }; + + // Global max level must allow whatever any handler wants. + let max = if syslog.is_some() { + std::cmp::max(stderr_level, log::LevelFilter::Info) + } else { + stderr_level + }; + + let logger = ShLogger { + stderr_level, + syslog, + }; + if log::set_boxed_logger(Box::new(logger)).is_ok() { + log::set_max_level(max); + } +} + +fn main() { + let args = cli::parse(); + init_logging(&args); + let code = match &args.mode { + RawMode::Wrap { .. } => modes::wrap(&args), + RawMode::Ls { names } => modes::ls(&args, names), + RawMode::Check { names } => modes::run_check(&args, names), + RawMode::Lastlog { names } => modes::lastlog(&args, names, false), + RawMode::Lastfaillog { names } => modes::lastlog(&args, names, true), + }; + std::process::exit(code); +} diff --git a/src/modes.rs b/src/modes.rs new file mode 100644 index 0000000..dbcbaf7 --- /dev/null +++ b/src/modes.rs @@ -0,0 +1,262 @@ +//! Operating modes (wrap/ls/check/lastlog), mirroring +//! `../src/scriptherder.py:1123-1280`. Each returns a process exit code. + +use chrono::{Local, TimeZone}; +use scriptherder::checkstatus::CheckStatus; +use scriptherder::cli::{RawArgs, RawMode}; +use scriptherder::jobs_list::JobsList; +use scriptherder::table::{Align, ColumnMeta, DataTable}; +use std::collections::HashMap; +use std::io::IsTerminal; + +/// Map a Nagios status level to its exit code. Unknown/FAIL → 3 (defensive). +pub fn level_to_code(level: &str) -> i32 { + match level { + "OK" => 0, + "WARNING" => 1, + "CRITICAL" => 2, + _ => 3, + } +} + +/// `mode_wrap` (../src/scriptherder.py:1123): run a command and persist its state. +pub fn wrap(args: &RawArgs) -> i32 { + let (name, umask, random_sleep, cmd) = match &args.mode { + RawMode::Wrap { + name, + umask, + random_sleep, + cmd, + .. + } => (name.clone(), umask.clone(), *random_sleep, cmd.clone()), + _ => unreachable!("wrap called with non-wrap mode"), + }; + + let mut job = match scriptherder::job::Job::new(&name, cmd.clone()) { + Ok(j) => j, + Err(e) => { + log::error!("Failed creating job: {} ({})", e.reason(), e.filename()); + return 1; + } + }; + + if random_sleep > 0 { + let seconds = rand::random::() * random_sleep as f64; + log::debug!("Sleeping for {seconds:.2} seconds"); + std::thread::sleep(std::time::Duration::from_secs_f64(seconds)); + } + + log::debug!("Invoking '{}'", cmd.join(" ")); + job.run(); + log::debug!("Finished, exit status {:?}", job.exit_status()); + + // Record what the job's status evaluates to at the time of execution. + let mut checkstatus = CheckStatus::new(true, args.checkdir.clone(), HashMap::new()); + let job_name = job.name(); + let check = checkstatus.get_check(&job_name).ok().cloned(); + if let Some(check) = check { + job.check(&check); + let msg = format!( + "Job {job_name:?} check status is {} ({})", + job.check_status().unwrap_or("UNKNOWN"), + job.check_reason().unwrap_or("") + ); + if job.is_ok() { + log::info!("{msg}"); + } else { + log::warn!("{msg}"); + } + } + + if let Err(e) = job.save_to_file(&args.datadir, None, &umask) { + log::error!("Failed saving job to file: {e}"); + return 1; + } + 0 +} + +/// `mode_ls` (../src/scriptherder.py:1153): list saved job states in a table. +pub fn ls(args: &RawArgs, names: &[String]) -> i32 { + let jobs = match JobsList::from_dir(&args.datadir, &args.checkdir, names, true) { + Ok(j) => j, + Err(e) => { + log::error!("Failed loading jobs: {} ({})", e.reason(), e.filename()); + return 1; + } + }; + let last_of_each = jobs.last_of_each(); + + // Which indices to display. + let chosen: Vec = if names.is_empty() { + println!( + "\n=== Showing the last execution of each job, use 'ls ALL' to see all executions\n" + ); + last_of_each.clone() + } else { + (0..jobs.jobs.len()).collect() + }; + + let fields = vec![ + ColumnMeta::new("Start time", Align::Right), + ColumnMeta::new("Duration", Align::Left), + ColumnMeta::new("Age", Align::Left), + ColumnMeta::new("Status", Align::Left), + ColumnMeta::new("Criteria", Align::Left), + ColumnMeta::new("Name", Align::Left), + ColumnMeta::new("Filename", Align::Left), + ]; + let mut data = DataTable::new(fields); + + let is_tty = std::io::stdout().is_terminal(); + + for &i in &chosen { + let start = match jobs.jobs[i].start_time() { + Some(st) => { + let secs = st.trunc() as i64; + match Local.timestamp_opt(secs, 0).single() { + Some(dt) => dt.format("%Y-%m-%d %X").to_string(), + None => "***".to_string(), + } + } + None => "***".to_string(), + }; + data.push(&start); + data.push(&jobs.jobs[i].duration_str()); + data.push(&format!("{} ago", jobs.jobs[i].age())); + + let (level, msg): (String, String) = if last_of_each.contains(&i) { + // For the last instance of each job, evaluate full check-mode status. + let this = &jobs.jobs[i]; + // Re-construct a one-job list; loaded jobs already passed version checks. + let single = scriptherder::job::Job::from_data(this.data.clone()) + .expect("loaded job data round-trips"); + let mut cs = CheckStatus::new(false, args.checkdir.clone(), HashMap::new()); + cs.check_jobs(JobsList::from_jobs(vec![single], false)); + let (lvl, m) = cs.aggregate_status(); + (lvl, m.unwrap_or_default()) + } else { + let exit = jobs.jobs[i].exit_status(); + let level = if exit != Some(0) { "Non-zero" } else { "-" }; + ( + level.to_string(), + format!( + "exit={}, age={}", + exit.map(|e| e.to_string()) + .unwrap_or_else(|| "None".to_string()), + jobs.jobs[i].age() + ), + ) + }; + + // ANSI coloring matching ../src/scriptherder.py:1201-1216. + let (color1, color2, reset) = if level != "OK" && level != "-" && is_tty { + let bold = "\x1b[;1m".to_string(); + let c1 = if level == "CRITICAL" { + "\x1b[1;31m".to_string() + } else { + bold.clone() + }; + (c1, bold, "\x1b[0;0m".to_string()) + } else { + (String::new(), String::new(), String::new()) + }; + + data.push(&format!("{color1}{level}{reset}")); + data.push(&format!("{color2}{msg}{reset}")); + data.push(&jobs.jobs[i].name()); + data.push(jobs.jobs[i].filename().unwrap_or("")); + data.new_line(); + } + + print!("{}", data.render()); + 0 +} + +/// `mode_check` (../src/scriptherder.py:1222): Nagios-style aggregate status. +pub fn run_check(args: &RawArgs, names: &[String]) -> i32 { + let jobs = match JobsList::from_dir(&args.datadir, &args.checkdir, names, true) { + Ok(j) => j, + Err(e) => { + println!( + "UNKNOWN: Failed loading check from file '{}' ({})", + e.filename(), + e.reason() + ); + return 3; + } + }; + let mut status = CheckStatus::new(false, args.checkdir.clone(), HashMap::new()); + status.check_jobs(jobs); + let (level, msg) = status.aggregate_status(); + println!("{}: {}", level, msg.unwrap_or_default()); + level_to_code(&level) +} + +/// `mode_lastlog` (../src/scriptherder.py:1244): print last (or last-failed) output. +pub fn lastlog(args: &RawArgs, names: &[String], fail_status: bool) -> i32 { + let jobs = match JobsList::from_dir(&args.datadir, &args.checkdir, names, true) { + Ok(j) => j, + Err(e) => { + log::error!("Failed loading jobs: {} ({})", e.reason(), e.filename()); + return 1; + } + }; + + if jobs.jobs.is_empty() { + // Python returns None here; `__main__` then exits 1. + println!("No jobs found"); + return 1; + } + + let last_of_each = jobs.last_of_each(); + let view: Vec = last_of_each + .iter() + .copied() + .filter(|&i| { + let job = &jobs.jobs[i]; + match job.output_filename() { + Some(fname) if std::path::Path::new(fname).is_file() => { + if fail_status { + job.exit_status() != Some(0) + } else { + true + } + } + _ => false, + } + }) + .collect(); + + if !view.is_empty() { + for &i in &view { + let display = jobs.jobs[i].display(); + let fname = match jobs.jobs[i].output_filename() { + Some(f) => f.to_string(), + None => continue, + }; + match std::fs::read(&fname) { + Ok(bytes) => { + println!("=== Script output of {display:?}"); + use std::io::Write; + let _ = std::io::stdout().write_all(&bytes); + println!("=== End of script output\n"); + } + Err(e) => log::warn!("Failed reading {fname}: {e}"), + } + } + } else { + // Names in insertion order, matching Python `by_name.keys()`. + let names: Vec = jobs.by_name_ordered().into_iter().map(|(n, _)| n).collect(); + println!( + "No script output found for {} with fail_status={}", + names.join(", "), + if fail_status { "True" } else { "False" } + ); + } + // Python: `sys.exit(int(not bool(view_jobs)))` → 0 if output shown, else 1. + if view.is_empty() { + 1 + } else { + 0 + } +} diff --git a/src/table.rs b/src/table.rs new file mode 100644 index 0000000..11b4895 --- /dev/null +++ b/src/table.rs @@ -0,0 +1,132 @@ +use regex::Regex; + +#[derive(Clone, Copy, PartialEq)] +pub enum Align { + Left, + Right, +} + +pub struct ColumnMeta { + pub name: String, + pub width: usize, + pub align: Align, +} + +impl ColumnMeta { + pub fn new(name: &str, align: Align) -> ColumnMeta { + let mut m = ColumnMeta { + name: name.to_string(), + width: 0, + align, + }; + m.update_width(name.chars().count()); + m + } + + pub fn update_width(&mut self, value: usize) { + if value > self.width { + self.width = value; + } + } + + /// Format `value` (with known `print_width`) padded to column width. + pub fn format(&self, value: &str, print_width: usize) -> String { + let pad = " ".repeat(self.width.saturating_sub(print_width)); + if self.align == Align::Right { + format!("{pad}{value}") + } else { + format!("{value}{pad}") + } + } +} + +pub struct DataTable { + rows: Vec>, + curr: Vec<(String, usize)>, + meta: Vec, + ansi: Regex, +} + +impl DataTable { + pub fn new(meta: Vec) -> DataTable { + DataTable { + rows: vec![], + curr: vec![], + meta, + ansi: Regex::new(r"\x1b\[[;\d]*[A-Za-z]").unwrap(), + } + } + + pub fn push(&mut self, value: &str) { + let print_width = self.ansi.replace_all(value, "").chars().count(); + self.curr.push((value.to_string(), print_width)); + if self.meta.len() >= self.curr.len() { + let idx = self.curr.len() - 1; + self.meta[idx].update_width(print_width); + } + } + + pub fn new_line(&mut self) { + self.rows.push(std::mem::take(&mut self.curr)); + } + + /// Render the table as a string (equivalent to Python `__str__`). + pub fn render(&self) -> String { + let mut res: Vec = Vec::new(); + + // Header row: field names + let mut header = String::new(); + for h in &self.meta { + let w = h.name.chars().count(); + header.push_str(&h.format(&h.name, w)); + header.push_str(" "); + } + res.push(header.trim_end().to_string()); + + // Data rows + for row in &self.rows { + let mut line = String::new(); + for (idx, (value, pw)) in row.iter().enumerate() { + if self.meta.len() > idx { + line.push_str(&self.meta[idx].format(value, *pw)); + } else { + line.push_str(value); + } + line.push_str(" "); + } + res.push(line.trim_end().to_string()); + } + + res.join("\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aligns_columns_and_strips_ansi_for_width() { + let meta = vec![ + ColumnMeta::new("Name", Align::Left), + ColumnMeta::new("N", Align::Right), + ]; + let mut t = DataTable::new(meta); + t.push("alpha"); + t.push("1"); + t.new_line(); + t.push("\u{1b}[1mb\u{1b}[0m"); + t.push("22"); + t.new_line(); + let out = t.render(); + let lines: Vec<&str> = out.lines().collect(); + // header: "Name" left-aligned to width 5 (from "alpha"), "N" right-aligned to width 2 (from "22") + // "Name " + " " + " N" -> rstripped = "Name N" + assert_eq!(lines[0], "Name N"); + // row 1: "alpha" (width 5) + " " + " 1" (right, width 2) -> "alpha 1" + assert!(lines[1].ends_with(" 1")); + // row 2: colored "b" (print width 1, padded to 5) + " " + "22" (right, width 2) + assert!(lines[2].starts_with("\u{1b}[1mb\u{1b}[0m")); + assert!(lines[2].ends_with("22")); + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..7fee4fc --- /dev/null +++ b/src/util.rs @@ -0,0 +1,71 @@ +use crate::job::Job; +use regex::Regex; + +/// Parse time strings like "8h"/"30m"/"10". Bare number = seconds. +/// Returns None if it does not match `^(\d+)([hmsd]*)$`. +pub fn parse_time_value(value: &str) -> Option { + let re = Regex::new(r"^(\d+)([hmsd]*)$").unwrap(); + let caps = re.captures(value)?; + let num: i64 = caps.get(1).unwrap().as_str().parse().ok()?; + match caps.get(2).map(|m| m.as_str()).unwrap_or("") { + "m" => Some(num * 60), + "h" => Some(num * 3600), + "d" => Some(num * 86400), + _ => Some(num), + } +} + +/// Format a number of seconds as a short human string (matches Python _time_to_str). +pub fn time_to_str(value: f64) -> String { + if value < 1.0 { + return format!("{}ms", (value * 1000.0) as i64); + } + if value < 60.0 { + return format!("{}s", value as i64); + } + if value < 3600.0 { + return format!("{}m", (value / 60.0) as i64); + } + if value < 86400.0 { + return format!("{}h", (value / 3600.0) as i64); + } + let days = (value / 86400.0) as i64; + format!("{}d{}h", days, ((value as i64 % 86400) / 3600)) +} + +/// Format the multi-job summary line (../src/scriptherder.py:1283-1295). +pub fn status_summary(num_jobs: usize, failed: &[Job]) -> String { + let plural = if num_jobs != 1 { "s" } else { "" }; + let mut summaries: Vec = failed.iter().map(|j| j.status_summary()).collect(); + summaries.sort(); + format!( + "{}/{} job{} in this state: {}", + failed.len(), + num_jobs, + plural, + summaries.join(", ") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_time_suffixes() { + assert_eq!(parse_time_value("10"), Some(10)); + assert_eq!(parse_time_value("1m"), Some(60)); + assert_eq!(parse_time_value("8h"), Some(28800)); + assert_eq!(parse_time_value("2d"), Some(172800)); + assert_eq!(parse_time_value("bad"), None); + } + + #[test] + fn format_buckets() { + assert_eq!(time_to_str(0.5), "500ms"); + assert_eq!(time_to_str(10.0), "10s"); + assert_eq!(time_to_str(19.0 * 60.0), "19m"); + assert_eq!(time_to_str(2.0 * 3600.0), "2h"); + assert_eq!(time_to_str(90000.0), "1d1h"); + } +} diff --git a/tests/checks.rs b/tests/checks.rs new file mode 100644 index 0000000..e4d779e --- /dev/null +++ b/tests/checks.rs @@ -0,0 +1,237 @@ +use scriptherder::check::Check; +use scriptherder::job::Job; + +// Helper mirroring test_checks.py::_run +fn run_check(cmd: &[&str], ok: &str, warn: &str, run: bool, runtime_mode_off: bool) -> Job { + let mut job = Job::new("unittest_job", cmd.iter().map(|s| s.to_string()).collect()).unwrap(); + if run { + job.run(); + } + let check = Check::new(ok, warn, "unit_testing", true).unwrap(); + job.check(&check); + if runtime_mode_off { + let check = Check::new(ok, warn, "unit_testing", false).unwrap(); + job.check(&check); + } + job +} + +#[test] +fn exit_status_ok() { + let job = run_check(&["/bin/echo", "test"], "exit_status=0", "", true, false); + assert!(job.is_ok()); +} + +#[test] +fn exit_status_critical() { + let job = run_check( + &["/usr/bin/true", "test"], + "exit_status=1", + "exit_status=2", + true, + false, + ); + assert!(!job.is_ok()); + assert!(!job.is_warning()); + assert_eq!(job.check_status(), Some("CRITICAL")); +} + +#[test] +fn file_exists_fail_reason() { + let job = run_check( + &["/usr/bin/false"], + "exit_status=0,OR_file_exists=/this_file_should_not_exist", + "", + true, + true, + ); + assert!(!job.is_ok()); + assert_eq!(job.check_status(), Some("CRITICAL")); + assert_eq!( + job.check_reason(), + Some("file_does_not_exist=/this_file_should_not_exist, stored_status=OK==False") + ); +} + +#[test] +fn output_contains_reason() { + let job = run_check( + &["/bin/echo", "STATUS_TESTING_OK"], + "exit_status=0,output_contains=TESTING", + "", + true, + false, + ); + assert!(job.is_ok()); + assert_eq!( + job.check_reason(), + Some("exit=0, output_contains=TESTING==True") + ); +} + +#[test] +fn output_not_contains_obsolete_reason() { + let job = run_check( + &["/bin/echo", "STATUS_TESTING_OK"], + "exit_status=0,output_not_contains=ERROR", + "", + true, + false, + ); + assert!(job.is_ok()); + assert_eq!( + job.check_reason(), + Some("exit=0, !output_contains=ERROR==True") + ); +} + +#[test] +fn output_matches_reason() { + let job = run_check( + &["/bin/echo", "STATUS_TESTING_OK"], + "exit_status=0,output_matches=.*TESTING.*", + "", + true, + false, + ); + assert!(job.is_ok()); + assert_eq!( + job.check_reason(), + Some("exit=0, output_matches=.*TESTING.*==True") + ); +} + +#[test] +fn exit_status_warning() { + let job = run_check( + &["/bin/echo", "test"], + "exit_status=1", + "exit_status=0", + true, + false, + ); + assert!(!job.is_ok()); + assert!(job.is_warning()); +} + +#[test] +fn exit_status_negated1() { + let job = run_check(&["/usr/bin/false"], "!exit_status=0", "", true, false); + assert!(job.is_ok()); + assert!(!job.is_warning()); +} + +#[test] +fn max_age() { + let job = run_check( + &["/bin/echo", "test"], + "exit_status=0, max_age=10s", + "exit_status=0, max_age=3h", + true, + true, + ); + assert!(job.is_ok()); + assert!(!job.is_warning()); +} + +#[test] +fn max_age_negated() { + let job = run_check( + &["/bin/echo", "test"], + "exit_status=0, !max_age=10s", + "exit_status=0, max_age=3h", + true, + true, + ); + assert!(!job.is_ok()); + assert!(job.is_warning()); +} + +#[test] +fn file_exists() { + // exit_status=1 so ok fails; warn has OR_file_exists=/etc/services which exists + let job = run_check( + &["/bin/echo", "test"], + "exit_status=1", + "exit_status=1,OR_file_exists=/etc/services", + true, + true, + ); + assert!(!job.is_ok()); + assert!(job.is_warning()); +} + +#[test] +fn file_exists_negated() { + // /usr/bin/false exits 1; ok = exit_status=0,!OR_file_exists= + // negated file_exists on a missing file → criterion passes + let job = run_check( + &["/usr/bin/false"], + "exit_status=0,!OR_file_exists=/this_could_be_a_FAIL_file", + "", + true, + true, + ); + assert!(job.is_ok()); +} + +#[test] +fn or_running() { + // /bin/echo exits 0; ok = exit_status=1,OR_running → OR_running is True while running + let job = run_check( + &["/bin/echo", "test"], + "exit_status=1,OR_running", + "exit_status=0", + true, + false, + ); + assert!(job.is_ok()); + assert!(!job.is_warning()); +} + +#[test] +fn or_running_negated() { + // run=false so job never ran; ok = exit_status=1,OR_running (OR_running False, exit_status=1 not run) + // warn = !OR_running → negated OR_running is True when not running + let job = run_check( + &["/bin/echo", "test"], + "exit_status=1,OR_running", + "!OR_running", + false, + false, + ); + assert!(!job.is_ok()); + assert!(job.is_warning()); +} + +#[test] +fn output_contains_negated() { + let job = run_check( + &["/bin/echo", "STATUS_TESTING_OK"], + "exit_status=0,!output_contains=ERROR", + "", + true, + false, + ); + assert!(job.is_ok()); + assert_eq!( + job.check_reason(), + Some("exit=0, !output_contains=ERROR==True") + ); +} + +#[test] +fn output_matches_negated() { + let job = run_check( + &["/bin/echo", "STATUS_TESTING_OK"], + "exit_status=0,!output_matches=.*ERROR.*", + "", + true, + false, + ); + assert!(job.is_ok()); + assert_eq!( + job.check_reason(), + Some("exit=0, !output_matches=.*ERROR.*==True") + ); +} diff --git a/tests/checkstatus.rs b/tests/checkstatus.rs new file mode 100644 index 0000000..615fc4a --- /dev/null +++ b/tests/checkstatus.rs @@ -0,0 +1,132 @@ +use scriptherder::check::Check; +use scriptherder::checkstatus::CheckStatus; +use scriptherder::job::Job; +use scriptherder::jobs_list::JobsList; +use std::collections::HashMap; + +fn move_back(job: &mut Job, seconds: f64) { + let s = job.start_time().unwrap(); + let e = job.end_time().unwrap(); + job.set_times_for_test(s - seconds, e - seconds); +} + +fn make_checkstatus(jobs: Vec, ok: &str, warn: &str) -> CheckStatus { + let mut checks = HashMap::new(); + checks.insert( + "test1".to_string(), + Check::new(ok, warn, "unit_testing", false).unwrap(), + ); + let mut cs = CheckStatus::new(false, String::new(), checks); + cs.check_jobs(JobsList::from_jobs(jobs, false)); + cs +} + +#[test] +fn two_delayed_jobs_critical() { + let ok = "exit_status=0, max_age=1m"; + let warn = "exit_status=0, max_age=5m"; + let mut j1 = Job::new("test1", vec!["/usr/bin/true".into()]).unwrap(); + let mut j2 = Job::new("test1", vec!["/usr/bin/true".into()]).unwrap(); + j1.run(); + j2.run(); + let rc = Check::new(ok, warn, "unit_testing", true).unwrap(); + j1.check(&rc); + j2.check(&rc); + move_back(&mut j1, 19.0 * 60.0); + move_back(&mut j2, 20.0 * 60.0); + let cs = make_checkstatus(vec![j1, j2], ok, warn); + assert_eq!(cs.num_jobs(), 1); + assert_eq!( + cs.aggregate_status(), + ( + "CRITICAL".to_string(), + Some("age=19m>1m, age=19m>5m".to_string()) + ) + ); +} + +#[test] +fn two_delayed_jobs_warning() { + let ok = "exit_status=0, max_age=1m"; + let warn = "exit_status=0, max_age=2h"; + let mut j1 = Job::new("test1", vec!["/usr/bin/true".into()]).unwrap(); + let mut j2 = Job::new("test1", vec!["/usr/bin/true".into()]).unwrap(); + j1.run(); + j2.run(); + let rc = Check::new(ok, warn, "unit_testing", true).unwrap(); + j1.check(&rc); + j2.check(&rc); + move_back(&mut j1, 2.0 * 60.0); + move_back(&mut j2, 4.0 * 60.0); + let cs = make_checkstatus(vec![j1, j2], ok, warn); + assert_eq!(cs.num_jobs(), 1); + assert_eq!( + cs.aggregate_status(), + ( + "WARNING".to_string(), + Some("age=2m>1m, max_age=2h, stored_status=OK==True".to_string()) + ) + ); +} + +#[test] +fn positive_then_negative_critical() { + let ok = "exit_status=0"; + let warn = "exit_status=0"; + let mut j1 = Job::new("test1", vec!["/usr/bin/true".into()]).unwrap(); + let mut j2 = Job::new("test1", vec!["/usr/bin/false".into()]).unwrap(); + j1.run(); + j2.run(); + let rc = Check::new(ok, warn, "unit_testing", true).unwrap(); + j1.check(&rc); + j2.check(&rc); + let cs = make_checkstatus(vec![j1, j2], ok, warn); + assert_eq!(cs.num_jobs(), 1); + assert_eq!( + cs.aggregate_status(), + ( + "CRITICAL".to_string(), + Some("stored_status=OK==False".to_string()) + ) + ); +} + +#[test] +fn job_failed_job() { + let ok = "exit_status=0,max_age=50m"; + let warn = "exit_status=0,max_age=1h"; + let mut j1 = Job::new("test1", vec!["/usr/bin/false".into()]).unwrap(); + j1.run(); + move_back(&mut j1, 10.0); + let rc = Check::new(ok, warn, "unit_testing", true).unwrap(); + j1.check(&rc); + let cs = make_checkstatus(vec![j1], ok, warn); + assert_eq!(cs.num_jobs(), 1); + assert_eq!( + cs.aggregate_status(), + ( + "CRITICAL".to_string(), + Some("stored_status=OK==False".to_string()) + ) + ); +} + +#[test] +fn misconfigured_criteria() { + let ok = "exit_status=0,max_age=50m"; + let warn = "exit_status=0,max_age=1"; + let mut j1 = Job::new("test1", vec!["/usr/bin/false".into()]).unwrap(); + j1.run(); + move_back(&mut j1, 10.0); + let rc = Check::new(ok, warn, "unit_testing", true).unwrap(); + j1.check(&rc); + let cs = make_checkstatus(vec![j1], ok, warn); + assert_eq!(cs.num_jobs(), 1); + assert_eq!( + cs.aggregate_status(), + ( + "CRITICAL".to_string(), + Some("stored_status=OK==False, age=10s>1s".to_string()) + ) + ); +} diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs new file mode 100644 index 0000000..96e3ee9 --- /dev/null +++ b/tests/cli_smoke.rs @@ -0,0 +1,79 @@ +use std::process::Command; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_scriptherder")) +} + +#[test] +fn ls_empty_datadir_runs() { + let dir = std::env::temp_dir().join(format!("sh_cli_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let out = bin() + .arg("-d") + .arg(&dir) + .arg("--checkdir") + .arg(&dir) + .arg("ls") + .output() + .unwrap(); + assert!(out.status.success()); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn wrap_then_check_roundtrip() { + let dir = std::env::temp_dir().join(format!("sh_cli2_{}", std::process::id())); + let checkdir = dir.join("check"); + std::fs::create_dir_all(&checkdir).unwrap(); + std::fs::write( + checkdir.join("smoke.ini"), + "[check]\nok = exit_status=0, max_age=8h\n", + ) + .unwrap(); + // wrap a successful command + let w = bin() + .arg("-d") + .arg(&dir) + .arg("--checkdir") + .arg(&checkdir) + .arg("wrap") + .arg("-N") + .arg("smoke") + .arg("--") + .arg("/bin/true") + .output() + .unwrap(); + assert!(w.status.success()); + // check should report OK (exit 0) + let c = bin() + .arg("-d") + .arg(&dir) + .arg("--checkdir") + .arg(&checkdir) + .arg("check") + .arg("smoke") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&c.stdout); + assert!(stdout.starts_with("OK:"), "got: {stdout}"); + assert_eq!(c.status.code(), Some(0)); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn lastlog_empty_datadir_no_jobs_exits_1() { + let dir = std::env::temp_dir().join(format!("sh_cli3_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let out = bin() + .arg("-d") + .arg(&dir) + .arg("--checkdir") + .arg(&dir) + .arg("lastlog") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(stdout.trim(), "No jobs found", "got: {stdout}"); + assert_eq!(out.status.code(), Some(1)); + std::fs::remove_dir_all(&dir).ok(); +}