From da756ff1a494566d64c8fc2082c0bea40dcb56ff Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Sun, 28 Jul 2024 10:12:11 -0500 Subject: [PATCH] Implement basic functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I've been working on this off-and-on for a few days without committing anything 🤨. With this commit, the basic functionality for writing configuration files from _etcd_ keys is in place. The daemon connects to _etcd_ on startup, reads all currently-populated keys, and then enters a watch loop. It gracefully handles being disconnected from _etcd_ and will reconnect with a backoff timer. In addition to writing file contents, the daemon can set file permissions and run commands after changing files. --- .gitignore | 1 + Cargo.lock | 487 +++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 15 +- rustfmt.toml | 1 + src/backoff.rs | 48 +++++ src/config.rs | 112 +++++++++++ src/luci.rs | 193 +++++++++++++++++++ src/main.rs | 115 +++++------ src/model.rs | 74 +++++++ src/processor.rs | 286 ++++++++++++++++++++++++++++ 10 files changed, 1253 insertions(+), 79 deletions(-) create mode 100644 rustfmt.toml create mode 100644 src/backoff.rs create mode 100644 src/config.rs create mode 100644 src/luci.rs create mode 100644 src/model.rs create mode 100644 src/processor.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..4a3b37d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +config.toml diff --git a/Cargo.lock b/Cargo.lock index ee6ac24..3e78e1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,7 +51,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.72", ] [[package]] @@ -62,7 +62,7 @@ checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.72", ] [[package]] @@ -151,6 +151,24 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bytes" version = "1.6.1" @@ -169,6 +187,63 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive-adhoc" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283ac2881753c76c0892406705553f0d9ab30649f81e18964d3408f4501edb8" +dependencies = [ + "derive-adhoc-macros", + "heck 0.4.1", +] + +[[package]] +name = "derive-adhoc-macros" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c21b673a9b8c78c34908e6fcb42b922e11c4df2de5237f1c3f58d3285904a84b" +dependencies = [ + "heck 0.4.1", + "itertools 0.11.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "sha3", + "strum", + "syn 1.0.109", + "void", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "either" version = "1.13.0" @@ -213,6 +288,15 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +[[package]] +name = "file-mode" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773ea145485772b8d354624b32adbe20e776353d3e48c7b03ef44e3455e9815c" +dependencies = [ + "libc", +] + [[package]] name = "fixedbitset" version = "0.4.2" @@ -225,6 +309,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-channel" version = "0.3.30" @@ -264,6 +357,16 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.15" @@ -312,6 +415,12 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -324,6 +433,23 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hostname" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba" +dependencies = [ + "cfg-if", + "libc", + "windows", +] + [[package]] name = "http" version = "1.1.0" @@ -424,6 +550,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -444,6 +580,15 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -459,6 +604,15 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -471,6 +625,12 @@ version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +[[package]] +name = "libyml" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64804cc6a5042d4f05379909ba25b503ec04e2c082151d62122d5dcaa274b961" + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -487,10 +647,23 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" name = "luci" version = "0.1.0" dependencies = [ + "base64", + "blake2", "etcd-client", + "file-mode", + "hex", + "hostname", + "pwd-grp", + "sd-notify", + "serde", + "serde_yml", + "shlex", + "thiserror", "tokio", + "toml", "tracing", "tracing-subscriber", + "url", ] [[package]] @@ -578,6 +751,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.1" @@ -611,7 +790,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.72", ] [[package]] @@ -639,7 +818,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.72", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", ] [[package]] @@ -668,8 +857,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bb182580f71dd070f88d01ce3de9f4da5021db7115d2e1c3605a754153b77c1" dependencies = [ "bytes", - "heck", - "itertools", + "heck 0.5.0", + "itertools 0.13.0", "log", "multimap", "once_cell", @@ -678,7 +867,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn", + "syn 2.0.72", "tempfile", ] @@ -689,10 +878,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18bec9b0adc4eba778b33684b7ba3e7137789434769ee3ce3930463ef904cfca" dependencies = [ "anyhow", - "itertools", + "itertools 0.13.0", "proc-macro2", "quote", - "syn", + "syn 2.0.72", ] [[package]] @@ -704,6 +893,18 @@ dependencies = [ "prost", ] +[[package]] +name = "pwd-grp" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6955c41fd7e4283bdf6ff3e7218b7e3f8ef24c4236b31d22be050f4cfd5e2a2c" +dependencies = [ + "derive-adhoc", + "libc", + "paste", + "thiserror", +] + [[package]] name = "quote" version = "1.0.36" @@ -869,6 +1070,18 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "sd-notify" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4646d6f919800cd25c50edb49438a1381e2cd4833c027e75e8897981c50b8b5e" + [[package]] name = "serde" version = "1.0.204" @@ -886,7 +1099,54 @@ checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.72", +] + +[[package]] +name = "serde_json" +version = "1.0.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_yml" +version = "0.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e76bab63c3fd98d27c17f9cbce177f64a91f5e69ac04cafe04e1bb25d1dc3c" +dependencies = [ + "indexmap 2.2.6", + "itoa", + "libyml", + "log", + "memchr", + "ryu", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", ] [[package]] @@ -898,6 +1158,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -938,12 +1204,45 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.72", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.72" @@ -979,6 +1278,26 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "thiserror" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.72", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -989,6 +1308,21 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tinyvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.39.1" @@ -1014,7 +1348,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.72", ] [[package]] @@ -1052,6 +1386,51 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2caab0bf757388c6c0ae23b3293fdb463fee59434529014f85e3263b995c28" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.22.16", +] + +[[package]] +name = "toml_datetime" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.2.6", + "toml_datetime", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "278f3d518e152219c994ce877758516bca5e118eaed6996192a774fb9fbf0788" +dependencies = [ + "indexmap 2.2.6", + "serde", + "serde_spanned", + "toml_datetime", + "winnow 0.6.15", +] + [[package]] name = "tonic" version = "0.12.1" @@ -1094,7 +1473,7 @@ dependencies = [ "proc-macro2", "prost-build", "quote", - "syn", + "syn 2.0.72", ] [[package]] @@ -1148,7 +1527,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.72", ] [[package]] @@ -1196,24 +1575,69 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "valuable" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "want" version = "0.3.1" @@ -1251,6 +1675,25 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1324,6 +1767,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "557404e450152cd6795bb558bca69e43c585055f4606e3bcae5894fc6dac9ba0" +dependencies = [ + "memchr", +] + [[package]] name = "zeroize" version = "1.8.1" diff --git a/Cargo.toml b/Cargo.toml index f3d791b..8f6e229 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,20 @@ version = "0.1.0" edition = "2021" [dependencies] +base64 = "0.22.1" +blake2 = "0.10.6" etcd-client = { version = "0.14.0", features = ["tls"] } -tokio = { version = "1.39.1", default-features = false, features = ["rt", "macros", "rt-multi-thread", "signal"] } +file-mode = "0.1.2" +hex = "0.4.3" +hostname = "0.4.0" +pwd-grp = "0.1.1" +sd-notify = "0.4.2" +serde = { version = "1.0.204", features = ["derive"] } +serde_yml = "0.0.11" +shlex = "1.3.0" +thiserror = "1.0.63" +tokio = { version = "1.39.1", default-features = false, features = ["rt", "macros", "rt-multi-thread", "signal", "process"] } +toml = "0.8.15" tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +url = { version = "2.5.2", features = ["serde"] } diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..a1ffd27 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +max_width = 79 diff --git a/src/backoff.rs b/src/backoff.rs new file mode 100644 index 0000000..b3b8b60 --- /dev/null +++ b/src/backoff.rs @@ -0,0 +1,48 @@ +use std::iter::Iterator; +use std::time::Duration; + +use tracing::warn; + +pub struct Backoff { + duration: Duration, + max: Duration, + min: Duration, +} + +impl Default for Backoff { + fn default() -> Self { + let min = Duration::from_millis(250); + let max = Duration::from_secs(300); + let duration = min; + Self { duration, max, min } + } +} + +impl Iterator for Backoff { + type Item = Duration; + + fn next(&mut self) -> Option { + let duration = self.duration; + self.duration = std::cmp::min(self.duration * 2, self.max); + Some(duration) + } +} + +impl Backoff { + #[allow(dead_code)] + pub fn reset(&mut self) { + self.duration = self.min; + } + + pub async fn sleep(&mut self) { + warn!( + "Retrying in {}", + if self.duration.as_secs() < 1 { + format!("{} ms", self.duration.as_millis()) + } else { + format!("{} seconds", self.duration.as_secs()) + } + ); + tokio::time::sleep(self.next().unwrap()).await; + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..26b27dd --- /dev/null +++ b/src/config.rs @@ -0,0 +1,112 @@ +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use tracing::{debug, info}; +use url::Url; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Unable to read configuration file: {0}")] + Io(#[from] std::io::Error), + #[error("Failed to parse configuration file: {0}")] + Toml(#[from] toml::de::Error), +} + +#[derive(Debug, Deserialize)] +pub struct EtcdConfig { + #[serde(default = "default_etcd_hosts")] + pub hosts: Vec, + + pub client_cert: Option, + pub client_key: Option, + pub ca_cert: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Configuration { + #[serde(default = "default_namespace")] + pub namespace: String, + #[serde(default)] + pub path_prefix: PathBuf, + pub etcd: EtcdConfig, +} + +impl Default for EtcdConfig { + fn default() -> Self { + Self { + hosts: default_etcd_hosts(), + + client_cert: None, + client_key: None, + ca_cert: None, + } + } +} + +impl Default for Configuration { + fn default() -> Self { + Self { + namespace: default_namespace(), + path_prefix: Default::default(), + etcd: Default::default(), + } + } +} + +impl Configuration { + pub fn load(path: Option>) -> Result { + let path = path + .map(|p| p.as_ref().into()) + .unwrap_or_else(Self::default_path); + debug!("Loading configuration from {}", path.display()); + let config = match std::fs::read_to_string(&path) { + Ok(d) => toml::from_str(&d)?, + Err(e) if e.kind() == ErrorKind::NotFound => { + debug!("{}: {}", path.display(), e); + Default::default() + } + Err(e) => return Err(e.into()), + }; + match std::env::var("DUMP_CONFIG") { + Ok(s) if s == "1" => info!("{:?}", config), + Ok(_) | Err(_) => (), + }; + Ok(config) + } + + pub fn default_path() -> PathBuf { + match std::env::var_os(format!( + "{}_CONFIG", + env!("CARGO_PKG_NAME").to_uppercase() + )) { + Some(v) => PathBuf::from(v), + None => match std::env::var_os("XDG_CONFIG_HOME") { + Some(v) => { + let mut p = PathBuf::from(v); + p.push(env!("CARGO_PKG_NAME")); + p.push("config.toml"); + p + } + None => match std::env::var_os("HOME") { + Some(v) => { + let mut p = PathBuf::from(v); + p.push(".config"); + p.push(env!("CARGO_PKG_NAME")); + p.push("config.toml"); + p + } + None => PathBuf::from("config.toml"), + }, + }, + } + } +} + +fn default_namespace() -> String { + env!("CARGO_PKG_NAME").into() +} + +fn default_etcd_hosts() -> Vec { + vec![Url::parse("http://localhost:2379").unwrap()] +} diff --git a/src/luci.rs b/src/luci.rs new file mode 100644 index 0000000..8941d17 --- /dev/null +++ b/src/luci.rs @@ -0,0 +1,193 @@ +use etcd_client::{ + Certificate, Client, ConnectOptions, GetOptions, Identity, KeyValue, + TlsOptions, WatchOptions, +}; +use sd_notify::NotifyState; +use tokio::task::JoinHandle; +use tracing::{debug, error, info, trace, warn}; + +use crate::backoff::Backoff; +use crate::config::Configuration; +use crate::model::ContentError; +use crate::processor::{Processor, Sender}; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum WatchError { + #[error("Could not get system hostname: {0}")] + Hostname(std::io::Error), + #[error("Error communicating with etcd server: {0}")] + Etcd(#[from] etcd_client::Error), +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum ConnectError { + #[error("Invalid etcd client configuration: {0}")] + InvalidConfig(etcd_client::Error), + #[error("Failed to load client certificate/key: {0}")] + Certificate(std::io::Error), + #[error("Failed to load CA certificate: {0}")] + CaCert(std::io::Error), +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum HandleError { + #[error("Error communicating with etcd: {0}")] + Etcd(#[from] etcd_client::Error), + #[error("Invalid UTF-8 etcd key")] + Utf8(#[from] std::str::Utf8Error), + #[error("Could not parse YAML: {0}")] + Yaml(#[from] serde_yml::Error), + #[error("Error decoding content: {0}")] + Content(#[from] ContentError), +} + +pub(crate) struct Luci { + client: Option, + config: Configuration, + tx: Sender, + processor: JoinHandle<()>, +} + +impl Luci { + pub fn new(config: Configuration) -> Self { + let path_prefix = config.path_prefix.clone(); + let (tx, processor) = Processor::new(path_prefix).start(); + Self { + client: None, + config, + tx, + processor, + } + } + + pub async fn connect(&mut self) -> Result<&Client, ConnectError> { + if let Some(client) = self.client.as_mut() { + if let Err(e) = client.status().await { + error!("Lost connection to etcd server: {}", e); + self.client.take(); + } else { + return Ok(self.client.as_ref().unwrap()); + } + } + let addrs = &self.config.etcd.hosts; + let mut backoff = Backoff::default(); + let options = self.connect_options()?; + loop { + let mut client = Client::connect(addrs, options.clone()) + .await + .map_err(ConnectError::InvalidConfig)?; + match client.status().await { + Ok(s) => { + info!("Connected to etcd server version {}", s.version()) + } + Err(e) => { + error!("Failed to connect to etcd: {:?}", e); + backoff.sleep().await; + continue; + } + } + self.client = Some(client); + return Ok(self.client.as_ref().unwrap()); + } + } + + pub async fn stop(self) { + debug!("Notifying processor to stop"); + drop(self.tx); + if let Err(e) = self.processor.await { + error!("Error in processor task: {}", e); + } + debug!("Processor stopped"); + } + + pub async fn watch(&mut self) -> Result<(), WatchError> { + let key = format!( + "{}/{}/config", + self.config.namespace, + hostname::get() + .map_err(WatchError::Hostname)? + .to_string_lossy() + ); + let get_options = GetOptions::new().with_prefix(); + let watch_options = WatchOptions::new().with_prefix(); + if let Some(client) = self.client.as_mut() { + let mut watch_client = client.watch_client(); + debug!("Fetching initial data from key: {}", key); + notify_init(); + let response = client.get(&key[..], Some(get_options)).await?; + for kv in response.kvs() { + if let Err(e) = self.handle_kv(kv).await { + error!("Error handling response: {}", e); + } + } + debug!("Setting up watch for key: {}", key); + let (_, mut stream) = + watch_client.watch(&key[..], Some(watch_options)).await?; + info!("Watching keys under '{}'", key); + notify_ready(); + while let Some(message) = stream.message().await? { + trace!("Received message: {:?}", message); + for event in message.events() { + if let Some(kv) = event.kv() { + if let Err(e) = self.handle_kv(kv).await { + error!("Error handling message: {}", e); + } + } + } + } + error!("Lost connection to watch stream"); + } + Ok(()) + } + + fn connect_options(&self) -> Result, ConnectError> { + if self.config.etcd.client_cert.is_none() + && self.config.etcd.ca_cert.is_none() + { + return Ok(None); + } + let mut tls_options = TlsOptions::new(); + if let Some(c) = self.config.etcd.client_cert.as_ref() { + let key = if let Some(k) = self.config.etcd.client_key.as_ref() { + std::fs::read(k).map_err(ConnectError::Certificate)? + } else { + vec![] + }; + let cert = std::fs::read(c).map_err(ConnectError::Certificate)?; + tls_options = tls_options.identity(Identity::from_pem(cert, key)); + } + if let Some(c) = self.config.etcd.ca_cert.as_ref() { + tls_options = tls_options.ca_certificate(Certificate::from_pem( + std::fs::read(c).map_err(ConnectError::CaCert)?, + )); + } + Ok(Some(ConnectOptions::new().with_tls(tls_options))) + } + + async fn handle_kv(&mut self, kv: &KeyValue) -> Result<(), HandleError> { + let key = String::from_utf8_lossy(kv.key()); + debug!("Handling notification for key {}", key); + let value = serde_yml::from_slice(kv.value())?; + if self.tx.send((key.into(), value)).await.is_err() { + error!("Failed to send entry to processor"); + } + Ok(()) + } +} + +fn notify_ready() { + let state = &[ + NotifyState::Status("Watching for etcd key changes"), + NotifyState::Ready, + ]; + if let Err(e) = sd_notify::notify(true, state) { + warn!("Failed to send ready notification to service manager: {}", e); + } +} + +fn notify_init() { + let state = &[NotifyState::Status("Fetching initial configuration")]; + if let Err(e) = sd_notify::notify(false, state) { + warn!("Failed to send status notification to service manager: {}", e); + } +} diff --git a/src/main.rs b/src/main.rs index b26004f..d0e97ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,89 +1,74 @@ +mod backoff; +mod config; +mod luci; +mod model; +mod processor; + +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use etcd_client::Client; -use tokio::sync::Notify; use tokio::signal::unix::SignalKind; -use tracing::{debug, info, error, trace}; +use tokio::sync::Notify; +use tracing::{debug, error, info}; + +use config::Configuration; #[tokio::main] async fn main() { tracing_subscriber::fmt() + .without_time() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .with_writer(std::io::stderr) .init(); - let stop = Arc::new(Notify::new()); - let my_stop = stop.clone(); - let task = tokio::spawn(async move { - run(&stop).await - }); - if let Err(e) = wait_signal().await { - error!("Failed to set up signal handler: {}", e); - std::process::exit(1); + let mut args = std::env::args_os(); + let config_path = args.nth(1).map(PathBuf::from); + + let config = match Configuration::load(config_path.as_deref()) { + Ok(c) => c, + Err(e) => { + error!("Failed to load configuration: {}", e); + std::process::exit(1); + } }; - my_stop.notify_waiters(); - if let Err(e) = task.await { - error!("{}", e); - std::process::exit(1); - } + + let stop = Arc::new(Notify::new()); + let stopper = stop.clone(); + tokio::spawn(async move { + if let Err(e) = wait_signal().await { + error!("Failed to set up signal handler: {}", e); + } + stopper.notify_waiters(); + }); + run(config, &stop).await; } -async fn run(stop: &Notify) { +async fn run(config: Configuration, stop: &Notify) { + let mut luci = luci::Luci::new(config); loop { - let mut client = tokio::select! { - c = connect() => c, - _ = stop.notified() => return - }; - let (_, mut stream) = match client.watch("dustin", None).await { - Ok((w, s)) => (w, s), - Err(e) => { - error!("Error setting up watch: {}", e); - continue; - } - }; - info!("Watch stream established"); tokio::select! { - m = stream.message() => { - trace!("{:?}", m); - } + r = luci.connect() => if let Err(e) = r { + error!("Unable to connect to etcd server: {}", e); + break; + }, _ = stop.notified() => break, }; + tokio::select! { + r = luci.watch() => if let Err(e) = r { + error!("Error in watch stream: {}", e); + }, + _ = stop.notified() => break, + }; + if tokio::time::timeout(Duration::from_secs(1), stop.notified()) + .await + .is_ok() + { + break; + } } info!("Shutting down"); -} - -async fn connect() -> Client { - let mut duration = Duration::from_millis(250); - let addrs = [ - "http://127.0.0.1:2379", - ]; - loop { - let mut client = match Client::connect(&addrs, None).await { - Ok(c) => c, - Err(e) => { - error!("Failed to connect to etcd: {}", e); - tokio::time::sleep(duration).await; - if duration.as_secs() < 300 { - duration *= 2; - } - continue; - } - }; - // Client::connect() always succeeds? - match client.status().await { - Ok(s) => info!("Connected: {:?}", s), - Err(e) => { - error!("Failed to connect to etcd: {}", e); - tokio::time::sleep(duration).await; - if duration.as_secs() < 300 { - duration *= 2; - } - continue; - } - } - return client; - } + luci.stop().await; } async fn wait_signal() -> std::io::Result<()> { diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..108bc8e --- /dev/null +++ b/src/model.rs @@ -0,0 +1,74 @@ +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +#[derive(Debug, thiserror::Error)] +pub enum ContentError { + #[error("Base64 decoding error: {0}")] + Base64(#[from] base64::DecodeError), +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +enum ContentEncoding { + Raw, + Base64, +} + +#[derive(Debug, Default, Deserialize)] +pub struct Hooks { + #[serde(default)] + pub changed: Vec, +} + +#[derive(Debug, serde::Deserialize)] +pub struct ConfigEntry { + path: PathBuf, + owner: Option, + group: Option, + mode: Option, + #[serde(default)] + content_encoding: ContentEncoding, + content: String, + #[serde(default)] + hooks: Hooks, +} + +impl Default for ContentEncoding { + fn default() -> Self { + Self::Raw + } +} + +impl ConfigEntry { + pub fn path(&self) -> &Path { + self.path.as_ref() + } + + pub fn owner(&self) -> Option<&str> { + self.owner.as_deref() + } + + pub fn group(&self) -> Option<&str> { + self.group.as_deref() + } + + pub fn mode(&self) -> Option<&str> { + self.mode.as_deref() + } + + pub fn content(&self) -> Result, ContentError> { + match &self.content_encoding { + ContentEncoding::Raw => Ok(self.content.as_bytes().into()), + ContentEncoding::Base64 => Ok(base64_decode(&self.content)?), + } + } + + pub fn hooks(&self) -> &Hooks { + &self.hooks + } +} + +fn base64_decode(value: &str) -> Result, base64::DecodeError> { + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, value) +} diff --git a/src/processor.rs b/src/processor.rs new file mode 100644 index 0000000..b6cf6d1 --- /dev/null +++ b/src/processor.rs @@ -0,0 +1,286 @@ +use std::io::{Read, Seek, Write}; +use std::os::unix::fs::MetadataExt; +#[cfg(unix)] +use std::os::unix::process::ExitStatusExt; +use std::path::{Path, PathBuf}; + +use blake2::{Blake2b512, Digest}; +use file_mode::{Mode, ModePath}; +use shlex::Shlex; +use tokio::process::Command; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tracing::Instrument; +use tracing::{debug, error, info, trace, warn}; + +use crate::model::{ConfigEntry, ContentError}; + +pub type Message = (String, ConfigEntry); +pub type Sender = mpsc::Sender; +pub type Receiver = mpsc::Receiver; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum ProcessError { + #[error("Content error: {0}")] + Content(#[from] ContentError), + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), +} + +#[derive(Debug, thiserror::Error)] +pub enum SetPermissionsError { + #[error("{0}")] + Io(#[from] std::io::Error), + #[error("User not found: {0}")] + UserNotFound(String), + #[error("Group not found: {0}")] + GroupNotFound(String), + #[error("Bad mode string: {0}")] + BadMode(#[from] file_mode::ModeParseError), +} + +pub struct Processor { + path_prefix: PathBuf, +} + +impl Processor { + pub fn new(path_prefix: PathBuf) -> Self { + Self { path_prefix } + } + + pub fn start(self) -> (Sender, JoinHandle<()>) { + let (tx, rx) = mpsc::channel(512); + let handle = tokio::spawn(async move { self.run(rx).await }); + (tx, handle) + } + + async fn process(&self, entry: ConfigEntry) -> Result<(), ProcessError> { + let path = self + .path_prefix + .join(entry.path().strip_prefix("/").unwrap_or(entry.path())); + let changed = write_file(&path, &entry.content()?)?; + #[cfg(unix)] + { + if entry.owner().is_some() || entry.group().is_some() { + if let Err(e) = chown(&path, entry.owner(), entry.group()) { + error!( + "Failed to set ownership of {}: {}", + path.display(), + e + ); + } + } + if let Some(mode) = entry.mode() { + if let Err(e) = chmod(&path, mode) { + error!("Failed to set mode of {}: {}", path.display(), e); + } + } + } + if changed { + info!("File {} was changed", path.display()); + let hooks = entry.hooks(); + debug!("Processing hooks: {:?}", hooks); + for hook in hooks.changed.iter() { + if let Some(args) = parse_hook(hook, &path) { + run_hook(args).await; + } + } + } else { + debug!("File {} was NOT changed", path.display()); + } + Ok(()) + } + + async fn run(self, mut rx: Receiver) { + while let Some((key, entry)) = rx.recv().await { + let span = tracing::debug_span!("process", key = key); + if let Err(e) = self.process(entry).instrument(span).await { + error!("Error while processing config entry: {}", e) + } + } + debug!("Channel closed"); + } +} + +fn checksum(path: impl AsRef) -> std::io::Result> { + let mut f = std::fs::File::open(path)?; + let mut blake = Blake2b512::new(); + loop { + let mut buf = vec![0u8; 16384]; + let sz = f.read(&mut buf)?; + if sz == 0 { + break; + } + blake.update(&buf[..sz]); + } + Ok(blake.finalize().to_vec()) +} + +fn write_file( + dest: impl AsRef, + data: &[u8], +) -> Result { + if let Some(p) = dest.as_ref().parent() { + if !p.exists() { + info!("Creating directory {}", p.display()); + std::fs::create_dir_all(p)?; + } + } + if let Ok(orig_cksm) = checksum(&dest) { + trace!( + "Original checksum: {}: {}", + dest.as_ref().display(), + hex::encode(&orig_cksm) + ); + let mut blake = Blake2b512::new(); + blake.update(data); + let new_cksm = blake.finalize().to_vec(); + trace!( + "New checksum: {}: {}", + dest.as_ref().display(), + hex::encode(&new_cksm) + ); + if orig_cksm == new_cksm { + return Ok(false); + } + } + debug!("Writing output: {}", dest.as_ref().display()); + let mut f = std::fs::File::create(&dest)?; + f.write_all(data)?; + let size = f.stream_position()?; + debug!("Wrote output: {} ({} bytes)", dest.as_ref().display(), size); + Ok(true) +} + +#[cfg(unix)] +fn chown( + path: impl AsRef, + owner: Option<&str>, + group: Option<&str>, +) -> Result<(), SetPermissionsError> { + let uid = if let Some(owner) = owner { + debug!("Looking up UID for user {}", owner); + if let Some(pw) = pwd_grp::getpwnam(owner)? { + debug!("Found UID {} for user {}", pw.uid, owner); + Some(pw.uid) + } else { + return Err(SetPermissionsError::UserNotFound(owner.into())); + } + } else { + None + }; + let gid = if let Some(group) = group { + debug!("Looking up GID for group {}", group); + if let Some(gr) = pwd_grp::getgrnam(group)? { + debug!("Found GID {} for group {}", gr.gid, group); + Some(gr.gid) + } else { + return Err(SetPermissionsError::GroupNotFound(group.into())); + } + } else { + None + }; + let metadata = std::fs::metadata(&path)?; + if Some(metadata.uid()) != uid || Some(metadata.gid()) != gid { + debug!( + "Setting ownership of {} to {:?} / {:?}", + path.as_ref().display(), + uid, + gid + ); + std::os::unix::fs::chown(path, uid, gid)? + } + Ok(()) +} + +#[cfg(unix)] +fn chmod( + path: impl AsRef, + mode: &str, +) -> Result<(), SetPermissionsError> { + let current_mode = path.as_ref().mode()?; + let mut filemode = current_mode.clone(); + match u32::from_str_radix(mode, 8) { + Ok(m) => filemode.set(&Mode::from(m)), + Err(_) => filemode.set_str_umask(mode, 0o777)?, + } + if current_mode != filemode { + debug!( + "Changing mode of {} from {} to {}", + path.as_ref().display(), + current_mode, + filemode, + ); + let newmode = file_mode::Mode::from(filemode.set_mode_path(&path)?); + info!("Set mode of {} to {}", path.as_ref().display(), newmode); + } + Ok(()) +} + +fn parse_hook(command: &str, path: &Path) -> Option> { + let mut bad_path = false; + let args: Vec<_> = Shlex::new(command) + .map(|a| { + if a == "%s" { + if let Some(p) = path.as_os_str().to_str() { + p.into() + } else { + bad_path = true; + a + } + } else { + a + } + }) + .collect(); + if bad_path { + warn!("Cannot run hook: path is not valid UTF-8"); + return None; + } + if args.is_empty() { + warn!( + "Invalid hook for {} ({}): empty argument list", + path.display(), + command + ); + return None; + } + Some(args) +} + +async fn run_hook(args: Vec) { + match shlex::try_join(args.iter().map(String::as_str)) { + Ok(cmd) => info!("Running hook: {}", cmd), + Err(e) => { + error!("Invalid hook: {}", e); + return; + } + } + if let Err(e) = _run_hook(args).await { + error!("Error running hook: {}", e); + } +} + +async fn _run_hook(args: Vec) -> std::io::Result<()> { + let status = Command::new(&args[0]) + .args(&args[1..]) + .stdin(std::process::Stdio::null()) + .spawn()? + .wait() + .await?; + match status.code() { + Some(0) => info!("Hook completed successfully"), + Some(c) => error!("Hook exited with status {}", c), + #[cfg(unix)] + None => { + if let Some(signal) = status.signal() { + error!("Hook killed by signal {}", signal); + } else { + error!("Hook exited with unknown status"); + } + } + #[cfg(not(unix))] + None => error!("Hook exited with unknown status"), + } + Ok(()) +}