init port
This commit is contained in:
commit
f825488351
17 changed files with 3513 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
979
Cargo.lock
generated
Normal file
979
Cargo.lock
generated
Normal 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
26
Cargo.toml
Normal 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
535
src/check.rs
Normal 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, §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<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
145
src/checkstatus.rs
Normal 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
161
src/cli.rs
Normal 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
50
src/error.rs
Normal 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
457
src/job.rs
Normal 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
131
src/jobs_list.rs
Normal 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
8
src/lib.rs
Normal 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
107
src/main.rs
Normal 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
262
src/modes.rs
Normal 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
132
src/table.rs
Normal 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
71
src/util.rs
Normal 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
237
tests/checks.rs
Normal 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
132
tests/checkstatus.rs
Normal 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
79
tests/cli_smoke.rs
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue