init port

This commit is contained in:
Johan Lundberg 2026-06-25 16:59:46 +02:00
commit f825488351
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
17 changed files with 3513 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

979
Cargo.lock generated Normal file
View file

@ -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"

26
Cargo.toml Normal file
View file

@ -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"

535
src/check.rs Normal file
View file

@ -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<String, String>,
defaults: &BTreeMap<String, String>,
depth: u8,
filename: &str,
) -> Result<String, ScriptHerderError> {
if depth > 10 {
return Err(ScriptHerderError::check_load(
"Interpolation too deep",
filename,
));
}
let bytes: Vec<char> = 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<String>,
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<Vec<Criterion>, 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<Criterion>,
pub warning_criteria: Vec<Criterion>,
}
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<Check, ScriptHerderError> {
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<Check, ScriptHerderError> {
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<String>) {
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<String>) {
self.evaluate(&self.warning_criteria.clone(), job)
}
fn evaluate(&self, criteria: &[Criterion], job: &mut Job) -> (bool, Vec<String>) {
let mut ok_msgs: Vec<String> = Vec::new();
let mut fail_msgs: Vec<String> = 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::<i32>().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<String, String> = 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<String, String> = 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, &section, &defaults, 0, filename)?;
let warning = interpolate(&warn_raw, &section, &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<String, String> {
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();
}
}

145
src/checkstatus.rs Normal file
View file

@ -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<Job>,
checks_warning: Vec<Job>,
checks_unknown: Vec<Job>,
checks_critical: Vec<Job>,
checks: HashMap<String, Check>,
runtime_mode: bool,
checkdir: String,
last_num_checked: usize,
}
impl CheckStatus {
pub fn new(
runtime_mode: bool,
checkdir: String,
checks: HashMap<String, Check>,
) -> 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<Option<Job>> = 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<String>) {
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()))
}
}

161
src/cli.rs Normal file
View file

@ -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<String>,
},
Ls {
names: Vec<String>,
},
Check {
names: Vec<String>,
},
Lastlog {
names: Vec<String>,
},
Lastfaillog {
names: Vec<String>,
},
}
/// Replicate Python argv preprocessing: drop a leading `--mode`, default empty to `ls`.
pub fn preprocess(mut args: Vec<String>) -> Vec<String> {
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<String> = 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"),
}
}
}

50
src/error.rs Normal file
View file

@ -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<String>, filename: impl Into<String>) -> Self {
ScriptHerderError::JobLoad {
reason: reason.into(),
filename: filename.into(),
}
}
pub fn check_load(reason: impl Into<String>, filename: impl Into<String>) -> 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");
}
}

457
src/job.rs Normal file
View file

@ -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<String>,
#[serde(default)]
pub cmd: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_time: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_time: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_status: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pid: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_size: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub check_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub check_reason: Option<String>,
}
pub const EXIT_STATUS_NAMES: [&str; 4] = ["OK", "WARNING", "CRITICAL", "UNKNOWN"];
pub struct Job {
pub data: JobData,
output: Option<Vec<u8>>,
}
impl Job {
pub fn new(name: &str, cmd: Vec<String>) -> Result<Job, ScriptHerderError> {
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<Job, ScriptHerderError> {
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<Job, ScriptHerderError> {
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<String> {
self.data.cmd.iter().skip(1).cloned().collect()
}
pub fn start_time(&self) -> Option<f64> {
self.data.start_time
}
pub fn end_time(&self) -> Option<f64> {
self.data.end_time
}
pub fn exit_status(&self) -> Option<i32> {
self.data.exit_status
}
pub fn pid(&self) -> Option<i32> {
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<Vec<u8>> {
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());
}
}

131
src/jobs_list.rs Normal file
View file

@ -0,0 +1,131 @@
use crate::error::ScriptHerderError;
use crate::job::Job;
use std::collections::HashMap;
pub struct JobsList {
pub jobs: Vec<Job>,
}
impl JobsList {
pub fn from_jobs(mut jobs: Vec<Job>, 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<JobsList, ScriptHerderError> {
let mut jobs: Vec<Job> = 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<String> =
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<usize>)> {
let mut idx: HashMap<String, usize> = HashMap::new();
let mut groups: Vec<(String, Vec<usize>)> = 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<usize> {
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));
}
}

8
src/lib.rs Normal file
View file

@ -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;

107
src/main.rs Normal file
View file

@ -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<std::sync::Mutex<syslog::Logger<syslog::LoggerBackend, syslog::Formatter3164>>>,
}
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);
}

262
src/modes.rs Normal file
View file

@ -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::<f64>() * 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<usize> = 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<usize> = 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<String> = 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
}
}

132
src/table.rs Normal file
View file

@ -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<Vec<(String, usize)>>,
curr: Vec<(String, usize)>,
meta: Vec<ColumnMeta>,
ansi: Regex,
}
impl DataTable {
pub fn new(meta: Vec<ColumnMeta>) -> 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<String> = 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"));
}
}

71
src/util.rs Normal file
View file

@ -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<i64> {
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<String> = 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");
}
}

237
tests/checks.rs Normal file
View file

@ -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=<nonexistent>
// 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")
);
}

132
tests/checkstatus.rs Normal file
View file

@ -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<Job>, 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())
)
);
}

79
tests/cli_smoke.rs Normal file
View file

@ -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();
}