diff --git a/Cargo.lock b/Cargo.lock index b3a3dd6..531d315 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,11 +17,20 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -34,15 +43,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -53,7 +62,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -64,33 +73,49 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] [[package]] name = "arcticwolf" version = "0.1.0" dependencies = [ "anyhow", + "arc-swap", + "arcticwolf", "async-trait", "bytes", "clap", - "getrandom", + "futures-util", + "getrandom 0.3.4", "libc", + "nix", "serde", "serde_ignored", + "serde_json", "tempfile", - "thiserror", + "thiserror 1.0.69", "tokio", + "tokio-util", "toml", "tracing", "tracing-subscriber", + "vergen-gitcl", "xdr-codec", ] @@ -105,6 +130,12 @@ dependencies = [ "syn", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "backtrace" version = "0.3.76" @@ -122,9 +153,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "byteorder" @@ -134,9 +165,41 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122ec45a44b270afd1402f351b782c676b173e3c3fb28d86ff7ebfb4d86a4ee4" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] [[package]] name = "cfg-if" @@ -144,11 +207,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" -version = "4.5.57" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -156,9 +225,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.57" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -168,9 +237,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -180,15 +249,90 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] [[package]] name = "equivalent" @@ -203,7 +347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -217,9 +361,52 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[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-sink", + "futures-task", + "pin-project-lite", + "slab", +] [[package]] name = "getrandom" @@ -229,10 +416,23 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "gimli" version = "0.32.3" @@ -241,9 +441,18 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -251,14 +460,28 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -267,23 +490,35 @@ 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 = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.177" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "lock_api" @@ -296,15 +531,33 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] [[package]] name = "miniz_oxide" @@ -317,13 +570,26 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", - "windows-sys 0.61.2", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", ] [[package]] @@ -332,7 +598,22 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", ] [[package]] @@ -346,9 +627,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -381,24 +662,40 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "powerfmt" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -409,6 +706,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -418,31 +721,85 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rustc-demangle" -version = "0.1.26" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde" version = "1.0.228" @@ -483,6 +840,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -503,13 +873,20 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -518,12 +895,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys", ] [[package]] @@ -534,9 +911,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.111" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -545,15 +922,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -562,7 +939,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -576,6 +962,17 @@ dependencies = [ "syn", ] +[[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 = "thread_local" version = "1.1.9" @@ -585,11 +982,44 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" -version = "1.48.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -599,20 +1029,33 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -656,9 +1099,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -678,9 +1121,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -699,23 +1142,33 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "utf8parse" @@ -729,6 +1182,46 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "cargo_metadata", + "derive_builder", + "regex", + "rustc_version", + "rustversion", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -741,112 +1234,169 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", ] [[package]] -name = "windows-link" -version = "0.2.1" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] [[package]] -name = "windows-sys" -version = "0.60.2" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "windows-targets", + "leb128fmt", + "wasmparser", ] [[package]] -name = "windows-sys" -version = "0.61.2" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "windows-link", + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "windows-targets" -version = "0.53.5" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] [[package]] -name = "windows_i686_gnu" -version = "0.53.1" +name = "winnow" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] -name = "windows_i686_msvc" -version = "0.53.1" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] [[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" +name = "wit-bindgen-rust-macro" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] [[package]] -name = "winnow" -version = "0.7.14" +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ - "memchr", + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] -name = "wit-bindgen" -version = "0.46.0" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "xdr-codec" @@ -857,3 +1407,9 @@ dependencies = [ "byteorder", "error-chain", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 2c82e5d..d06cc39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,32 +2,96 @@ name = "arcticwolf" version = "0.1.0" edition = "2024" -rust-version = "1.86" +# Required by vergen-gitcl 9.1.0 (transitive: vergen 9.0+ uses 1.88 features). +rust-version = "1.88" [lib] name = "arcticwolf" path = "src/lib.rs" +# Both binaries are declared explicitly: `arcticwolf` is the NFS daemon and +# `arcticwolfctl` is the operator admin client (issue #25). +[[bin]] +name = "arcticwolf" +path = "src/main.rs" + +[[bin]] +name = "arcticwolfctl" +path = "src/bin/arcticwolfctl.rs" + [dependencies] tokio = { version = "1", features = ["full"] } anyhow = "1" bytes = "1.5" thiserror = "1.0" tracing = "0.1" -tracing-subscriber = "0.3" +# `env-filter` provides `EnvFilter`, the daemon's log filter (also driven by +# `RUST_LOG`). The `reload` module (used by the admin `log-level` commands +# to swap the filter at runtime) and `fmt`/`registry` all come from the +# default feature set, so only `env-filter` needs to be requested here. +tracing-subscriber = { version = "0.3", features = ["env-filter"] } async-trait = "0.1" libc = "0.2" serde = { version = "1", features = ["derive"] } serde_ignored = "0.1" +serde_json = "1" toml = "0.8" clap = { version = "4", features = ["derive"] } getrandom = "0.3" +tokio-util = { version = "0.7", features = ["codec"] } +# Needed for the `Sink`/`Stream` extension traits used on the admin +# `Framed` connection. `default-features = false` keeps the build to just +# the sink/stream helpers we use here. +futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } +# `nix` is unused by Phase 1 itself; pulled in now so Phase 6 (audit log) +# can read `SO_PEERCRED` without another Cargo.toml churn. The `socket` +# and `user` features cover both the peer credential lookup and the +# subsequent uid/username resolution. +nix = { version = "0.29", features = ["socket", "user"] } +# `arc-swap` backs the live export snapshot in +# `fsal::multi_export::MultiExportFilesystem`. The NFS read path needs to +# pick a backend for every request without ever blocking the (single) admin +# writer, and the admin mutation path needs to replace the map atomically +# without taking a global lock that would stall in-flight RPCs. `ArcSwap` +# gives both: readers get a lock-free `load()` returning an `Arc` snapshot +# they can clone out and drop before any `.await`, writers do a single +# `store(Arc::new(new_snapshot))`. +arc-swap = "1" # XDR serialization (runtime) xdr-codec = "0.4" +# Test-only dependency, activated by the `test-util` feature (see +# [features]). The `AdminContext::for_test` helper needs it to build a +# tempdir-backed export when the library is compiled with `test-util` so +# integration tests can link against it. Never compiled into release builds. +tempfile = { version = "3", optional = true } + +[features] +default = [] +# Enables test-only helpers (e.g. `AdminContext::for_test`) so integration +# tests in tests/ can reuse the same fixtures the in-crate unit tests use. +# Never enable in release builds. +test-util = ["dep:tempfile"] + [dev-dependencies] tempfile = "3" +# Re-link this crate with the `test-util` feature enabled so integration +# tests in tests/ can call the in-crate test helpers (AdminContext::for_test) +# without duplicating the fixtures. +arcticwolf = { path = ".", features = ["test-util"] } +# Enable tokio's `test-util` feature (virtual-time `start_paused`/`advance`) +# for tests only — it is NOT part of the `full` feature set used by the +# release binary, so it lives here to keep it out of production builds. The +# drain-timeout test runs on paused time to stay deterministic on slow CI. +tokio = { version = "1", features = ["test-util"] } [build-dependencies] -# No build dependencies - xdrgen is installed as CLI tool +# xdrgen itself is installed as a CLI tool (see build.rs), not a crate. +# `vergen-gitcl` generates the VERGEN_* compile-time env vars consumed by +# the admin `version` command: VERGEN_GIT_SHA (build commit), +# VERGEN_RUSTC_SEMVER (rustc version) and VERGEN_CARGO_DEBUG (build +# profile). The `gitcl` backend shells out to the `git` CLI; when the +# build runs without a `.git` directory (the container build copies only +# sources) vergen falls back gracefully and the handler reports "unknown". +vergen-gitcl = { version = "9", features = ["cargo", "rustc"] } diff --git a/Earthfile b/Earthfile index 8f92c28..844ce9b 100644 --- a/Earthfile +++ b/Earthfile @@ -22,6 +22,10 @@ common: # Copy source code COPY src ./src + # Copy Rust integration tests (cargo discovers tests/*.rs; the .py + # files in this directory are nfstest scripts and are ignored by cargo) + COPY tests ./tests + # Pre-fetch dependencies to speed up subsequent builds RUN cargo fetch @@ -43,6 +47,30 @@ lint: RUN cargo fmt -- --check RUN cargo clippy -- -D warnings +# earthly +lockfile +# Reconcile Cargo.lock with Cargo.toml — preserves existing pins when +# constraints still permit. If a top-level requirement in Cargo.toml +# is changed (e.g. `bytes = "1.5"` → `"1.6"`), that crate will resolve +# to a new version. +# Re-run after adding a dep or enabling a feature that pulls new transitives. +lockfile: + FROM rust:1.91 + WORKDIR /src + # Copy the existing Cargo.lock alongside Cargo.toml so cargo treats + # already-pinned entries as fixed and only resolves newly-required + # transitives. `cargo generate-lockfile` (the previous approach) + # re-resolves from scratch and bumps unrelated semver-compatible + # dependencies. The `Cargo.lock*` wildcard mirrors `+common` and + # handles a fresh checkout where no lockfile exists yet — `cargo + # fetch` then resolves from scratch (similar to `generate-lockfile` + # but via the fetch interface). + COPY Cargo.toml Cargo.lock* ./ + COPY build.rs ./ + COPY xdr ./xdr + COPY src ./src + RUN cargo fetch + SAVE ARTIFACT Cargo.lock AS LOCAL Cargo.lock + # earthly +image image: ARG IMAGE_REPO=freezevicente diff --git a/Makefile b/Makefile index f4558f2..f9e657b 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ TESTCASE ?= read,write # Default target .DEFAULT_GOAL := help -.PHONY: help build test lint start-test-env nfstest stop-test-env clean +.PHONY: help build test lint lockfile start-test-env nfstest stop-test-env clean # Show available targets and their descriptions help: @@ -24,6 +24,7 @@ help: @echo " build - Build release binary" @echo " test - Run unit tests" @echo " lint - Run clippy and rustfmt checks" + @echo " lockfile - Regenerate Cargo.lock from Cargo.toml" @echo " start-test-env - Build and start both server and client VM" @echo " nfstest - Run NFS tests (TESTCASE=read,write)" @echo " stop-test-env - Stop both server and VM" @@ -46,6 +47,10 @@ test: lint: $(EARTHLY) +lint +# Regenerate Cargo.lock from Cargo.toml +lockfile: + $(EARTHLY) +lockfile + # Build Docker image image: ${EARTHLY} +image --IMAGE_REPO=$(IMAGE_REPO) --IMAGE_TAG=$(IMAGE_TAG) diff --git a/arcticwolf.example.toml b/arcticwolf.example.toml index 232bb00..e2df5e2 100644 --- a/arcticwolf.example.toml +++ b/arcticwolf.example.toml @@ -40,3 +40,21 @@ path = "/srv/nfs/backup" [logging] # Log level: "error", "warn", "info", "debug", "trace" (default: "info") level = "info" + +# Graceful shutdown (optional; omit the section to accept the defaults). +# +# On SIGTERM/SIGINT the daemon stops accepting new connections and waits for +# in-flight requests to drain before exiting 0. A second signal during the +# drain forces an immediate exit (128 + signum). +[shutdown] +# Maximum seconds to wait for in-flight requests to drain before forcing +# exit. This is a CEILING, not a target: a server that drains in 5ms exits +# in 5ms. Hitting the ceiling logs a warning but still exits 0 (a slow +# drain is not a failure). Default: 30. +# +# Note: per-connection handlers do not yet check for shutdown between +# requests, so a connected-but-idle client (one holding a connection open +# without sending traffic) keeps its handler alive and will make the drain +# run all the way to this ceiling. Active clients drain promptly; only +# silent-idle ones hit the ceiling. Tracked as a follow-up to #25. +drain_timeout_seconds = 30 diff --git a/build.rs b/build.rs index 9049b76..efc6a7b 100644 --- a/build.rs +++ b/build.rs @@ -3,7 +3,41 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; -fn main() { +use vergen_gitcl::{CargoBuilder, Emitter, GitclBuilder, RustcBuilder}; + +fn main() -> Result<(), Box> { + // Emit the VERGEN_* env vars first so build/version metadata is + // available even if XDR codegen is short-circuited by a cached run. + emit_build_metadata()?; + generate_xdr_types(); + Ok(()) +} + +/// Emit the `VERGEN_*` compile-time env vars consumed by the admin +/// `version` command (`VERGEN_GIT_SHA`, `VERGEN_RUSTC_SEMVER`, +/// `VERGEN_CARGO_DEBUG`). +/// +/// The `gitcl` backend shells out to the `git` CLI. When the build runs +/// without a `.git` directory available — as in the container build, which +/// copies only the sources — vergen cannot resolve the commit; it falls +/// back to a placeholder rather than failing, and the handler reports the +/// field as "unknown". +fn emit_build_metadata() -> Result<(), Box> { + let cargo = CargoBuilder::all_cargo()?; + let rustc = RustcBuilder::all_rustc()?; + let gitcl = GitclBuilder::all_git()?; + Emitter::default() + .add_instructions(&cargo)? + .add_instructions(&rustc)? + .add_instructions(&gitcl)? + .emit()?; + Ok(()) +} + +/// Run `xdrgen` over the XDR v3 specs and write the generated Rust types +/// into `OUT_DIR`. Panics on failure — a broken codegen step must fail the +/// build loudly. +fn generate_xdr_types() { let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); let out_path = Path::new(&out_dir); diff --git a/src/admin/audit.rs b/src/admin/audit.rs new file mode 100644 index 0000000..bac70cd --- /dev/null +++ b/src/admin/audit.rs @@ -0,0 +1,702 @@ +//! Structured audit log for admin requests (issue #25, Phase 6). +//! +//! Every admin request the daemon accepts produces one line of JSON in the +//! audit log file. The wire schema, written one event per line (UTF-8, +//! `\n`-terminated, JSON Lines), is: +//! +//! ```json +//! { +//! "ts": "2026-06-02T01:33:00.123456Z", +//! "peer": { "uid": 1000, "gid": 1000, "pid": 12345 }, +//! "command": "exports-add", +//! "request": { ... wire request minus the "command" tag ... }, +//! "result": "ok", +//! "duration_ms": 12 +//! } +//! ``` +//! +//! For an erroring request, `result` is `"err"` and an additional `"error"` +//! field carries the human-readable message; `error` is omitted on success. +//! `peer` is omitted (not `null`) when the kernel-side `SO_PEERCRED` lookup +//! fails — e.g. on a non-Linux host where the syscall is unavailable. +//! +//! ## Synthetic `command` tags +//! +//! Most events carry the real wire command (`"status"`, `"exports-add"`, +//! …). Three angle-bracketed sentinels stand in when there is no usable +//! command to record, so operators can still grep for the failure mode: +//! +//! - `` — the frame bytes were not valid JSON at all; the +//! `request` field is `null`. +//! - `` — the frame was valid JSON but not an object, or had no +//! string `command` field; `request` carries whatever parsed. +//! - `` — a codec-level failure (oversize frame, EOF +//! mid-frame) before any JSON was decoded; `request` is `null` and the +//! connection is closed immediately afterward. +//! +//! A fourth sentinel, ``, exists only in the metrics +//! `by_command` map (see [`crate::metrics::AdminMetrics`]), *not* in the +//! audit log: it is the bucket a client-supplied tag falls into when it names +//! a command the daemon doesn't recognize. The audit log instead records the +//! raw tag verbatim, so the two surfaces differ here by design — the audit +//! preserves what was sent, the metrics map bounds itself. `` +//! is distinct from `` above: `` means "valid JSON, no +//! `command` field", whereas `` means "named a command we +//! don't have". +//! +//! Two writer implementations are provided. [`FileAuditWriter`] owns the +//! audit-log file and a dedicated writer task; calls to +//! [`AuditWriter::record`] hand the event off through an unbounded mpsc +//! channel so the admin request path never blocks on disk I/O. The task +//! line-flushes after every event (cheap line buffering — crash-safety +//! beyond the kernel page cache is out of scope for v1). [`NoopAuditWriter`] +//! is the inert variant used when `[audit] enabled = false`. +//! +//! Sensitive-field redaction in the `request` payload is deliberately a +//! follow-up — gated on the S3 backend per the Phase 5 decision — so v1 +//! logs every field verbatim. Operators who enable audit today should be +//! aware that future credential-bearing exports will need redaction +//! before they go live. + +use std::os::fd::AsFd; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use serde::Serialize; +use tokio::fs::File; +use tokio::io::{AsyncWriteExt, BufWriter}; +use tokio::net::UnixStream; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tracing::warn; + +/// Peer credentials captured from `SO_PEERCRED` on the admin connection. +/// +/// `uid`/`gid`/`pid` mirror the kernel's `struct ucred`. Recorded verbatim +/// in the audit line so operators can answer "who ran this command" and +/// "from which process" without consulting auxiliary logs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct PeerCreds { + /// Effective user id of the connected client process. + pub uid: u32, + /// Effective group id of the connected client process. + pub gid: u32, + /// Process id of the connected client. + pub pid: i32, +} + +/// Extract `SO_PEERCRED` from an accepted admin connection. +/// +/// Returns `None` (and logs a `warn`) on failure rather than propagating +/// an error: a missing peer triple must not fail the admin request. The +/// underlying lookup is Linux-only — on non-Linux test platforms (e.g. +/// developer macOS) the syscall is not implemented and we'd see the same +/// `None`, which is exactly the fallback the audit schema documents. +pub fn peer_creds(stream: &UnixStream) -> Option { + use nix::sys::socket::{getsockopt, sockopt::PeerCredentials}; + + let borrowed = stream.as_fd(); + match getsockopt(&borrowed, PeerCredentials) { + Ok(creds) => Some(PeerCreds { + uid: creds.uid(), + gid: creds.gid(), + // `UnixCredentials::pid` returns `pid_t` (i32 on Linux). We + // surface the kernel-supplied value directly so an `unshare(2)` + // peer's pid in its namespace is what shows up — the audit log + // is a record of what the kernel told us, not a normalized view. + pid: creds.pid(), + }), + Err(err) => { + warn!("admin: SO_PEERCRED lookup failed, omitting peer from audit: {err}"); + None + } + } +} + +/// One JSON-lines audit record. +/// +/// Built by the admin server immediately after dispatch and before the +/// response is encoded. Each field maps directly to a top-level key in +/// the wire schema documented at the top of this module. +#[derive(Debug, Clone, Serialize)] +#[non_exhaustive] +pub struct AuditEvent { + /// RFC 3339 / ISO 8601 UTC timestamp with microsecond precision and a + /// trailing `Z` (e.g. `2026-06-02T01:33:00.123456Z`). + pub ts: String, + /// Peer credentials captured from `SO_PEERCRED`. Omitted (the field is + /// dropped from the serialized JSON, not serialized as `null`) when + /// the kernel-side lookup failed. + #[serde(skip_serializing_if = "Option::is_none")] + pub peer: Option, + /// Wire command tag (e.g. `"status"`, `"exports-add"`, + /// `"log-level-set"`), or one of the synthetic sentinels + /// `""` / `""` / `""` documented in + /// the module-level "Synthetic `command` tags" section. + pub command: String, + /// The original request JSON with the `command` discriminator removed. + /// `null` for undecodable frames. + pub request: serde_json::Value, + /// `"ok"` if the handler returned a success response, `"err"` if it + /// returned an error (or the request never made it past decode). + pub result: &'static str, + /// Human-readable error message; present iff `result == "err"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Wall-clock duration in milliseconds from request decode to the + /// moment the audit event is built (i.e. just before response encode). + /// Best-effort — not a load-bearing metric. + pub duration_ms: u128, +} + +/// Type-erased audit destination. Implementations are expected to be +/// fire-and-forget: a `record` call must not block the admin request path +/// on disk I/O. Failures (the writer task panicked, the disk filled, the +/// file is gone) are surfaced through `tracing::warn!` rather than +/// propagated to the caller — losing an audit line is preferable to +/// stalling or failing an admin command on a side-channel. +#[async_trait] +pub trait AuditWriter: Send + Sync { + /// Hand `event` to the writer for asynchronous recording. + fn record(&self, event: AuditEvent); + + /// Flush every queued event to its destination and stop the writer. + /// + /// Called once from `main.rs` during graceful shutdown (issue #25 Phase + /// 8), *after* all four server accept loops have drained — so by the + /// time this runs no new events are being recorded. The returned future + /// completes only once everything queued has reached disk. + /// + /// Implementations must be idempotent: a second call is a no-op (the + /// daemon calls it exactly once, but tests and a future double-shutdown + /// path must not panic or hang). + async fn shutdown(&self); +} + +/// Inert audit writer. Used when `[audit] enabled = false` so the request +/// path can `context.audit.record(event)` unconditionally without an +/// `Option` round-trip at the call site. +pub struct NoopAuditWriter; + +#[async_trait] +impl AuditWriter for NoopAuditWriter { + fn record(&self, _event: AuditEvent) {} + + /// Nothing is buffered, so shutdown returns immediately. + async fn shutdown(&self) {} +} + +/// Audit writer backed by a JSON-lines file and a dedicated writer task. +/// +/// The choice of mpsc + task (vs. `Mutex>`) is deliberate: +/// the admin request path must never hold a lock across `.await` on disk +/// I/O, and message-passing makes the contention-free hot path explicit. +/// Backpressure is fine via the unbounded channel: admin traffic is +/// human-scale (operator commands), not RPC-scale. +/// +/// Both fields are wrapped in `Option` behind a `std::sync::Mutex` so +/// [`AuditWriter::shutdown`] can take ownership of the single sender and the +/// writer-task handle through a shared `&self` (the writer is shared as +/// `Arc`). Taking the sender closes the channel — which is +/// how the writer task learns to flush and exit — and taking the handle lets +/// shutdown `.await` the task to completion. The `Mutex` is uncontended on +/// the hot path: `record` only locks to read the sender, and admin traffic +/// is human-scale. +#[derive(Debug)] +pub struct FileAuditWriter { + /// The single channel sender. `Some` while the writer is live; `None` + /// after [`shutdown`](AuditWriter::shutdown) has taken and dropped it. + tx: Mutex>>, + /// Join handle for the spawned [`writer_task`]. `Some` until `shutdown` + /// takes it to `.await`; `None` afterwards (idempotency marker). + task: Mutex>>, +} + +impl FileAuditWriter { + /// Open the audit-log file and spawn its writer task. + /// + /// Fails if the parent directory of `path` does not exist (the daemon + /// refuses to start with audit misconfigured; `/var/log` is operator + /// territory and we don't auto-mkdir) or if the file itself cannot be + /// opened. The file is opened with `append(true).create(true)` and + /// `mode(0o640)` — never truncated, umask-respecting. + /// + /// Must be called inside a Tokio runtime: the writer task is spawned + /// via [`tokio::spawn`]. + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + && !parent.exists() + { + anyhow::bail!( + "audit log parent directory {} does not exist (refusing to start with [audit] enabled = true)", + parent.display(), + ); + } + + let std_file = std::fs::OpenOptions::new() + .append(true) + .create(true) + .mode(0o640) + .open(path) + .with_context(|| format!("failed to open audit log {}", path.display()))?; + let file = File::from_std(std_file); + + let (tx, rx) = mpsc::unbounded_channel::(); + let task = tokio::spawn(writer_task(file, rx)); + Ok(Self { + tx: Mutex::new(Some(tx)), + task: Mutex::new(Some(task)), + }) + } +} + +#[cfg(test)] +impl FileAuditWriter { + /// Test-only constructor that drops the receiver half immediately, so + /// the very first [`record`](AuditWriter::record) call hits the + /// channel-closed `Err` branch. Lets the fail-safe path (warn + return, + /// never panic) be covered directly instead of relying on writer-task + /// timing. Spawns no writer task — there is nothing to drain. + fn with_dropped_receiver() -> Self { + let (tx, rx) = mpsc::unbounded_channel::(); + drop(rx); + Self { + tx: Mutex::new(Some(tx)), + task: Mutex::new(None), + } + } +} + +#[async_trait] +impl AuditWriter for FileAuditWriter { + fn record(&self, event: AuditEvent) { + let guard = self.tx.lock().unwrap_or_else(|poison| poison.into_inner()); + // `guard` is `None` once `shutdown` has run: the sender is gone and + // the file is flushed, so a `record` arriving in that final teardown + // window is dropped silently. While the writer is live, the receiver + // lives in the spawned task; if that task has exited (panic, runtime + // shutdown, channel close on drop) the `send` errors and we drop the + // event — losing an audit record is strictly preferable to stalling + // or failing the admin request. + if let Some(tx) = guard.as_ref() + && let Err(err) = tx.send(event) + { + warn!("audit: dropping event, writer channel closed: {err}"); + } + } + + async fn shutdown(&self) { + // Drop the *sole* channel sender so the writer task's `recv()` + // returns `None`, letting it flush its `BufWriter` and exit. Because + // every `Arc` clone routes through this one `Mutex`, + // taking the Option here closes the channel no matter how many clones + // are still alive — we don't have to rely on every holder dropping + // its `Arc` first. Idempotent: after the first call the Option is + // `None` and the take is a cheap no-op. + { + let mut guard = self.tx.lock().unwrap_or_else(|poison| poison.into_inner()); + guard.take(); + } + // Take and await the writer task so all queued events have reached + // disk before we return. The second `shutdown` call finds `None`. + let handle = { + let mut guard = self + .task + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + guard.take() + }; + if let Some(handle) = handle + && let Err(err) = handle.await + { + warn!("audit: writer task did not exit cleanly during shutdown: {err}"); + } + } +} + +/// Drain `rx` into `file`, line-flushing after every event. Exits when the +/// channel is closed (last `Sender` dropped — today, that happens when +/// `Arc` is dropped at daemon shutdown). +async fn writer_task(file: File, mut rx: mpsc::UnboundedReceiver) { + let mut writer = BufWriter::new(file); + while let Some(event) = rx.recv().await { + let line = match serde_json::to_vec(&event) { + Ok(bytes) => bytes, + Err(err) => { + warn!("audit: serialize failed, dropping event: {err}"); + continue; + } + }; + if let Err(err) = writer.write_all(&line).await { + warn!("audit: write failed, dropping event: {err}"); + continue; + } + if let Err(err) = writer.write_all(b"\n").await { + warn!("audit: newline write failed: {err}"); + continue; + } + if let Err(err) = writer.flush().await { + warn!("audit: flush failed: {err}"); + } + } + // Channel closed: drain whatever's buffered before the task exits. + let _ = writer.flush().await; +} + +/// Current wall-clock time as an RFC 3339 string with microsecond +/// precision and a trailing `Z` (UTC). Implemented in terms of +/// [`SystemTime`] + Howard Hinnant's `civil_from_days` algorithm so the +/// audit module doesn't pull in a date/time crate just for one formatter. +pub fn rfc3339_micros_now() -> String { + format_rfc3339_micros(SystemTime::now()) +} + +fn format_rfc3339_micros(t: SystemTime) -> String { + let dur = t.duration_since(UNIX_EPOCH).unwrap_or_default(); + let secs = dur.as_secs() as i64; + let micros = dur.subsec_micros(); + let days = secs.div_euclid(86400); + let tod = secs.rem_euclid(86400) as u32; + let hour = tod / 3600; + let minute = (tod % 3600) / 60; + let second = tod % 60; + let (year, month, day) = civil_from_days(days); + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{micros:06}Z",) +} + +/// Howard Hinnant's `civil_from_days`: days-since-1970 → (year, month, day) +/// in the proleptic Gregorian calendar. Reference: +/// . +/// Trusted for the SystemTime range; not exhaustively tested here because +/// the wall-clock domain covers it implicitly. +fn civil_from_days(z: i64) -> (i32, u32, u32) { + let z = z + 719468; + let era = z.div_euclid(146097); + let doe = z.rem_euclid(146097) as u32; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y as i32, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::time::Duration; + + fn sample_event() -> AuditEvent { + AuditEvent { + ts: "2026-06-02T01:33:00.123456Z".to_string(), + peer: Some(PeerCreds { + uid: 1000, + gid: 1000, + pid: 4242, + }), + command: "status".to_string(), + request: json!({}), + result: "ok", + error: None, + duration_ms: 7, + } + } + + #[test] + fn noop_writer_is_silent() { + // The whole reason `NoopAuditWriter` exists is so the request path + // can `context.audit.record(event)` without branching on whether + // audit is enabled. Pin that it doesn't panic and that the trait + // call is in fact a no-op (no observable side effect at the type + // level, asserted via successful construction + record). + let w = NoopAuditWriter; + w.record(sample_event()); + } + + #[tokio::test] + async fn file_writer_round_trip_single_event() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("audit.jsonl"); + let writer = FileAuditWriter::open(&path).expect("open audit writer"); + writer.record(sample_event()); + // Drop the writer so its mpsc sender closes — the spawned task + // drains and exits, flushing the BufWriter. + drop(writer); + // Give the spawned task a moment to drain. We can't deterministically + // join it (it lives in tokio::spawn) so we poll the file with a + // short ceiling — line-buffered writes are fast. + let contents = wait_for_lines(&path, 1).await; + assert_eq!(contents.len(), 1); + let value: serde_json::Value = serde_json::from_str(&contents[0]).expect("valid JSON"); + assert_eq!(value["ts"], "2026-06-02T01:33:00.123456Z"); + assert_eq!(value["command"], "status"); + assert_eq!(value["peer"]["uid"], 1000); + assert_eq!(value["peer"]["gid"], 1000); + assert_eq!(value["peer"]["pid"], 4242); + assert_eq!(value["result"], "ok"); + assert_eq!(value["duration_ms"], 7); + assert!( + value.get("error").is_none(), + "ok result must not carry an error field", + ); + } + + #[tokio::test] + async fn file_writer_shutdown_flushes_all_events() { + // Phase 8: `shutdown().await` must guarantee every recorded event has + // reached disk by the time it returns. Unlike the drop-based tests + // (which poll the file because the writer task can't be joined), this + // path joins the task explicitly, so we can read the file *once* + // straight afterward with no polling and see all N lines. + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("audit.jsonl"); + let writer = FileAuditWriter::open(&path).expect("open audit writer"); + for i in 0..32 { + let mut event = sample_event(); + event.duration_ms = i; + writer.record(event); + } + writer.shutdown().await; + + let contents = std::fs::read_to_string(&path).expect("read audit file after shutdown"); + let lines: Vec<&str> = contents.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!( + lines.len(), + 32, + "shutdown must flush every recorded event before returning", + ); + for (i, line) in lines.iter().enumerate() { + let value: serde_json::Value = serde_json::from_str(line).expect("valid JSON"); + assert_eq!( + value["duration_ms"], i as u128 as i64, + "events must be in order" + ); + } + + // Idempotency: a second shutdown must not panic or hang, and a + // `record` after shutdown is a silent no-op (the file is unchanged). + writer.shutdown().await; + writer.record(sample_event()); + let after = std::fs::read_to_string(&path).expect("re-read audit file"); + assert_eq!( + after.lines().filter(|l| !l.is_empty()).count(), + 32, + "record after shutdown must be dropped silently", + ); + } + + #[tokio::test] + async fn noop_writer_shutdown_returns_immediately() { + // The inert writer buffers nothing, so shutdown is a no-op that must + // resolve without blocking. Bound it so a regression (e.g. someone + // making it await something) surfaces as a failure, not a hang. + let w = NoopAuditWriter; + tokio::time::timeout(Duration::from_secs(1), w.shutdown()) + .await + .expect("noop shutdown must return immediately"); + } + + #[tokio::test] + async fn file_writer_preserves_record_order() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("audit.jsonl"); + let writer = FileAuditWriter::open(&path).expect("open audit writer"); + for i in 0..16 { + let mut event = sample_event(); + event.duration_ms = i; + writer.record(event); + } + drop(writer); + let lines = wait_for_lines(&path, 16).await; + assert_eq!(lines.len(), 16); + for (i, line) in lines.iter().enumerate() { + let value: serde_json::Value = serde_json::from_str(line).expect("valid JSON"); + assert_eq!( + value["duration_ms"], i as u128 as i64, + "lines must be written in the order they were recorded" + ); + } + } + + #[tokio::test] + async fn file_writer_err_event_carries_error_field() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("audit.jsonl"); + let writer = FileAuditWriter::open(&path).expect("open audit writer"); + let mut event = sample_event(); + event.result = "err"; + event.error = Some("boom".to_string()); + writer.record(event); + drop(writer); + let lines = wait_for_lines(&path, 1).await; + let value: serde_json::Value = serde_json::from_str(&lines[0]).expect("valid JSON"); + assert_eq!(value["result"], "err"); + assert_eq!(value["error"], "boom"); + } + + #[tokio::test] + async fn file_writer_omits_peer_when_absent() { + // Non-Linux test runs or `SO_PEERCRED` failures mean we don't have + // peer creds for the connection. The schema is "omit, don't null". + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("audit.jsonl"); + let writer = FileAuditWriter::open(&path).expect("open audit writer"); + let mut event = sample_event(); + event.peer = None; + writer.record(event); + drop(writer); + let lines = wait_for_lines(&path, 1).await; + let value: serde_json::Value = serde_json::from_str(&lines[0]).expect("valid JSON"); + assert!( + value.get("peer").is_none(), + "peer must be omitted (not null) when SO_PEERCRED is unavailable", + ); + } + + #[test] + fn file_writer_open_fails_when_parent_dir_missing() { + // Validates the operator contract: enabling audit with a path under + // a directory that doesn't exist is a hard startup failure, not a + // silent fallback. (See also the `validate()` test for the + // enabled-without-path case.) + let _rt = tokio::runtime::Runtime::new().expect("runtime"); + let path = std::path::PathBuf::from("/does/not/exist/audit.jsonl"); + let err = FileAuditWriter::open(&path) + .expect_err("missing parent dir must fail") + .to_string(); + assert!( + err.contains("does not exist"), + "error should mention the missing parent dir; got: {err}", + ); + } + + #[tokio::test] + async fn file_audit_writer_sets_requested_mode_on_creation() { + // The audit file is created `0o640` (owner rw, group r) via + // `OpenOptions::mode(0o640)`. Pin it the same way + // `bind_admin_socket_sets_requested_mode` pins the socket mode, so a + // refactor that drops `.mode(0o640)` fails here loudly. umask is + // process-global and only *removes* bits; we force it to 0 across + // the open so the assertion is the exact configured mode rather than + // a umask-derived subset, then restore it. + // + // SAFETY: `umask` mutates process-global state. We save the prior + // value and restore it immediately after the open. `cargo test` may + // run other tests on parallel threads, but the other umask-touching + // tests (`bind_admin_socket_*`) likewise save+restore, so the only + // cross-test effect is a brief intermediate mask; this test's own + // assertion reads the mode of a file it alone created. + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("audit-mode.jsonl"); + let saved_umask = unsafe { libc::umask(0o000) }; + let writer = FileAuditWriter::open(&path).expect("open audit writer"); + let mode = std::fs::metadata(&path) + .expect("stat audit file") + .permissions() + .mode() + & 0o777; + unsafe { + libc::umask(saved_umask); + } + drop(writer); + assert_eq!( + mode, 0o640, + "audit file must be created with mode 0o640; got {mode:o}", + ); + } + + #[test] + fn record_after_receiver_drop_logs_and_does_not_panic() { + // Documented fail-safe behavior: if the receiver is gone, `record` + // logs a warning and returns. The caller is the admin request + // path; losing one audit line is strictly preferable to panicking + // out of the connection handler. `with_dropped_receiver` closes the + // channel up front so this directly covers the `Err` branch in + // `record` rather than racing the writer task's shutdown. + let writer = FileAuditWriter::with_dropped_receiver(); + writer.record(sample_event()); + // A second record over the same closed channel must also be inert. + writer.record(sample_event()); + } + + #[test] + fn rfc3339_micros_known_epoch_round_trip() { + // Pin a known timestamp against the manual formatter so a future + // refactor of `format_rfc3339_micros` can't silently break the + // operator-facing audit log shape (which downstream tooling parses + // by string). + let t = UNIX_EPOCH + Duration::from_micros(1_717_286_400_000_001); + // 2024-06-02T00:00:00.000001Z + let s = format_rfc3339_micros(t); + assert_eq!(s, "2024-06-02T00:00:00.000001Z"); + } + + #[test] + fn rfc3339_micros_now_has_correct_shape() { + // Don't pin the exact value (it's wall clock), but pin the shape: + // 4-digit year, `Z` suffix, microsecond precision. + let s = rfc3339_micros_now(); + assert_eq!(s.len(), 27, "expected YYYY-MM-DDTHH:MM:SS.uuuuuuZ; got {s}"); + assert!(s.ends_with('Z'), "must end with Z; got {s}"); + assert_eq!(s.chars().nth(4), Some('-')); + assert_eq!(s.chars().nth(10), Some('T')); + assert_eq!(s.chars().nth(19), Some('.')); + } + + /// On Linux, an accepted `UnixStream` carries the connecting process's + /// `SO_PEERCRED` triple. We pair up a listener + client in-process so + /// the credentials we read back must match this very test process. + #[cfg(target_os = "linux")] + #[tokio::test] + async fn peer_creds_round_trip_on_local_socket() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket_path = dir.path().join("peer.sock"); + let listener = tokio::net::UnixListener::bind(&socket_path).expect("bind"); + let connect = tokio::net::UnixStream::connect(&socket_path); + let accept = listener.accept(); + let (_client, accepted) = tokio::join!(connect, accept); + let (server_side, _) = accepted.expect("accept"); + let creds = peer_creds(&server_side).expect("peer creds must be available on Linux"); + assert_eq!(creds.uid, nix::unistd::geteuid().as_raw()); + assert_eq!(creds.gid, nix::unistd::getegid().as_raw()); + assert_eq!(creds.pid as u32, std::process::id()); + } + + /// Poll `path` until at least `min_lines` non-empty lines have been + /// flushed. The FileAuditWriter drain runs on a tokio::spawn task we + /// can't deterministically join, so the test crosses the boundary by + /// observing the file. Bounded by a 2s ceiling so a regression + /// (writer task wedged) shows up as a test failure, not a hang. + async fn wait_for_lines(path: &std::path::Path, min_lines: usize) -> Vec { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + loop { + if let Ok(contents) = tokio::fs::read_to_string(path).await { + let lines: Vec = contents + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(); + if lines.len() >= min_lines { + return lines; + } + } + if std::time::Instant::now() >= deadline { + panic!( + "audit writer never produced {min_lines} lines at {}", + path.display() + ); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } +} diff --git a/src/admin/client.rs b/src/admin/client.rs new file mode 100644 index 0000000..9b7f0c4 --- /dev/null +++ b/src/admin/client.rs @@ -0,0 +1,510 @@ +//! In-process admin client. +//! +//! Both the `arcticwolfctl` binary and the integration tests drive the +//! admin protocol through these functions, so the wire round trip is +//! exercised without forking a separate process. The functions speak the +//! Phase 1 length-prefixed JSON codec ([`super::protocol`]). + +use std::fmt::Write as _; +use std::path::Path; + +use anyhow::{Context, Result, anyhow, bail}; +use bytes::Bytes; +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value; +use tokio::net::UnixStream; + +use super::protocol::framed; +use super::request::AdminRequest; +use super::response::AdminResponse; +use crate::config::ExportConfig; +use crate::fsal::multi_export::ExportSelector; + +/// Connect to the admin socket, send one request, and return the decoded +/// response. The connection is closed when the returned value is produced. +pub async fn send_request(socket_path: &Path, request: &AdminRequest) -> Result { + let stream = UnixStream::connect(socket_path).await.with_context(|| { + format!( + "failed to connect to admin socket {}", + socket_path.display() + ) + })?; + let mut connection = framed(stream); + + let payload = serde_json::to_vec(request).context("serializing admin request")?; + connection + .send(Bytes::from(payload)) + .await + .context("sending admin request")?; + + let frame = connection + .next() + .await + .ok_or_else(|| anyhow!("admin connection closed before a response was received"))? + .context("reading admin response frame")?; + serde_json::from_slice(&frame).context("decoding admin response") +} + +/// Send `request` and unwrap the success payload, turning an +/// `AdminResponse::Err` into an `anyhow` error. +async fn fetch(socket_path: &Path, request: &AdminRequest) -> Result { + match send_request(socket_path, request).await? { + AdminResponse::Ok { data } => Ok(data), + AdminResponse::Err { error } => bail!("admin error: {error}"), + } +} + +/// Fetch the daemon `status` payload. +pub async fn fetch_status(socket_path: &Path) -> Result { + fetch(socket_path, &AdminRequest::Status).await +} + +/// Fetch the daemon `version` payload. +pub async fn fetch_version(socket_path: &Path) -> Result { + fetch(socket_path, &AdminRequest::Version).await +} + +/// Fetch the tracing filter directive currently in effect (`log-level get`). +pub async fn fetch_log_level(socket_path: &Path) -> Result { + fetch(socket_path, &AdminRequest::LogLevelGet).await +} + +/// Swap the daemon's live tracing filter (`log-level set `). +/// +/// The daemon rejects an unrecognized level with an `AdminResponse::Err`, +/// which surfaces here as an `anyhow` error. +pub async fn set_log_level(socket_path: &Path, level: &str) -> Result { + fetch( + socket_path, + &AdminRequest::LogLevelSet { + level: level.to_string(), + }, + ) + .await +} + +/// Fetch the live `exports list` payload (active exports + retired uids). +pub async fn fetch_exports_list(socket_path: &Path) -> Result { + fetch(socket_path, &AdminRequest::ExportsList).await +} + +/// `exports add` — install (or pre-flight) a new export. +pub async fn exports_add(socket_path: &Path, config: ExportConfig, dry_run: bool) -> Result { + fetch(socket_path, &AdminRequest::ExportsAdd { config, dry_run }).await +} + +/// `exports remove` — retire (or pre-flight) an export. +pub async fn exports_remove( + socket_path: &Path, + selector: ExportSelector, + dry_run: bool, +) -> Result { + fetch( + socket_path, + &AdminRequest::ExportsRemove { selector, dry_run }, + ) + .await +} + +/// `exports update` — mutate (or pre-flight) an export's `read_only`. +pub async fn exports_update( + socket_path: &Path, + selector: ExportSelector, + read_only: bool, + dry_run: bool, +) -> Result { + fetch( + socket_path, + &AdminRequest::ExportsUpdate { + selector, + read_only, + dry_run, + }, + ) + .await +} + +/// Fetch the daemon's startup `config show` payload. +pub async fn fetch_config_show(socket_path: &Path) -> Result { + fetch(socket_path, &AdminRequest::ConfigShow).await +} + +/// Fetch the daemon's operational `metrics` snapshot. +pub async fn fetch_metrics(socket_path: &Path) -> Result { + fetch(socket_path, &AdminRequest::Metrics).await +} + +/// Render a `status` payload either as pretty JSON or a human summary. +pub fn render_status(data: &Value, json: bool) -> Result { + if json { + return Ok(serde_json::to_string_pretty(data)?); + } + let mut out = String::new(); + writeln!(out, "Daemon version: {}", field(data, "daemon_version"))?; + writeln!(out, "Uptime: {}s", field(data, "uptime_seconds"))?; + writeln!(out, "Bind address: {}", field(data, "bind_address"))?; + writeln!(out, "NFS port: {}", field(data, "nfs_port"))?; + writeln!(out, "Mount port: {}", field(data, "mount_port"))?; + writeln!(out, "Portmap port: {}", field(data, "portmap_port"))?; + writeln!(out, "Log level: {}", field(data, "log_level"))?; + write!(out, "Exports: {}", field(data, "export_count"))?; + Ok(out) +} + +/// Render a daemon `version` payload either as pretty JSON or a human +/// summary. +pub fn render_version(data: &Value, json: bool) -> Result { + if json { + return Ok(serde_json::to_string_pretty(data)?); + } + let mut out = String::new(); + writeln!(out, "Daemon version: {}", field(data, "daemon_version"))?; + writeln!(out, "Build commit: {}", field(data, "build_commit"))?; + writeln!(out, "Rustc version: {}", field(data, "rustc_version"))?; + write!(out, "Build profile: {}", field(data, "build_profile"))?; + Ok(out) +} + +/// Render a `log-level get` payload either as pretty JSON or a human +/// summary. +pub fn render_log_level_get(data: &Value, json: bool) -> Result { + if json { + return Ok(serde_json::to_string_pretty(data)?); + } + Ok(format!("Current log level: {}", field(data, "level"))) +} + +/// Render a `log-level set` payload either as pretty JSON or a human +/// summary. +pub fn render_log_level_set(data: &Value, json: bool) -> Result { + if json { + return Ok(serde_json::to_string_pretty(data)?); + } + Ok(format!("Log level set to {}", field(data, "level"))) +} + +/// Render an `exports list` payload. +/// +/// `--json` dumps the daemon response verbatim (including `retired_uids`). +/// The default human form prints a column-aligned table of the active +/// exports only; retired uids are deliberately not shown because they're +/// rarely useful at a glance — `--json` covers the diagnostic case. +pub fn render_exports_list(data: &Value, json: bool) -> Result { + if json { + return Ok(serde_json::to_string_pretty(data)?); + } + let exports = data + .get("exports") + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow!("exports list response missing `exports` array"))?; + + // Column widths: header sets the floor, contents widen as needed. + let mut uid_w = "UID".len(); + let mut name_w = "NAME".len(); + let mut fsal_w = "FSAL".len(); + for e in exports { + uid_w = uid_w.max(e["uid"].to_string().len()); + name_w = name_w.max( + e["name"] + .as_str() + .map(|s| s.len()) + .unwrap_or_else(|| e["name"].to_string().len()), + ); + fsal_w = fsal_w.max( + e["fsal"] + .as_str() + .map(|s| s.len()) + .unwrap_or_else(|| e["fsal"].to_string().len()), + ); + } + + let mut out = String::new(); + writeln!( + out, + "{: (uid )` on success, or — for `--dry-run` — +/// `Dry-run: would add export (uid )`, symmetric with +/// `would remove` / `would update`. +pub fn render_exports_add(data: &Value, json: bool) -> Result { + if json { + return Ok(serde_json::to_string_pretty(data)?); + } + if data["dry_run"].as_bool().unwrap_or(false) { + let would = &data["would_add"]; + return Ok(format!( + "Dry-run: would add export {} (uid {})", + field(would, "name"), + field(would, "uid"), + )); + } + Ok(format!( + "Added export {} (uid {})", + field(data, "name"), + field(data, "uid") + )) +} + +/// Render an `exports remove` payload. +pub fn render_exports_remove(data: &Value, json: bool) -> Result { + if json { + return Ok(serde_json::to_string_pretty(data)?); + } + if data["dry_run"].as_bool().unwrap_or(false) { + let would = &data["would_remove"]; + return Ok(format!( + "Dry-run: would remove export {} (uid {})", + field(would, "name"), + field(would, "uid"), + )); + } + Ok(format!( + "Removed export {} (uid {}) — uid retired until daemon restart", + field(data, "name"), + field(data, "uid"), + )) +} + +/// Render an `exports update` payload. +pub fn render_exports_update(data: &Value, json: bool) -> Result { + if json { + return Ok(serde_json::to_string_pretty(data)?); + } + if data["dry_run"].as_bool().unwrap_or(false) { + let would = &data["would_update"]; + return Ok(format!( + "Dry-run: would update export {} (uid {}): read_only = {} -> {}", + field(would, "name"), + field(would, "uid"), + field(&would["from"], "read_only"), + field(&would["to"], "read_only"), + )); + } + Ok(format!( + "Updated export {} (uid {}): read_only = {}", + field(data, "name"), + field(data, "uid"), + field(data, "read_only"), + )) +} + +/// Render a `config show` payload. +/// +/// Pretty-prints the config as JSON regardless of `json` — the human form +/// IS the JSON form for v1 (per the issue plan, sensitive-field redaction +/// and a human-curated layout land later). The `--json` flag is accepted +/// to keep the flag surface consistent across commands. +pub fn render_config_show(data: &Value, _json: bool) -> Result { + Ok(serde_json::to_string_pretty(data)?) +} + +/// Render a `metrics` payload. +/// +/// Like `config show`, the human form IS the pretty JSON form for v1 — the +/// JSON snapshot is the surface (no table view). `--json` is accepted for +/// flag-surface consistency but does not change the output. +pub fn render_metrics(data: &Value, _json: bool) -> Result { + Ok(serde_json::to_string_pretty(data)?) +} + +/// Render one JSON field for human output. A JSON string is unwrapped so it +/// prints without the surrounding quotes that `Value`'s `Display` would add; +/// other value kinds fall back to their JSON form. +fn field(data: &Value, key: &str) -> String { + match data.get(key) { + Some(Value::String(s)) => s.clone(), + Some(other) => other.to_string(), + None => "unknown".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample_status() -> Value { + json!({ + "daemon_version": "0.1.0", + "uptime_seconds": 1234, + "bind_address": "0.0.0.0", + "nfs_port": 2049, + "mount_port": 20048, + "portmap_port": 111, + "log_level": "info", + "export_count": 2, + }) + } + + #[test] + fn render_status_json_round_trips() { + let rendered = render_status(&sample_status(), true).expect("render json"); + let parsed: Value = serde_json::from_str(&rendered).expect("json round trip"); + assert_eq!(parsed, sample_status()); + } + + #[test] + fn render_status_human_is_unquoted_and_labelled() { + let rendered = render_status(&sample_status(), false).expect("render human"); + assert!(rendered.contains("Daemon version: 0.1.0")); + assert!(rendered.contains("NFS port: 2049")); + assert!(rendered.contains("Exports: 2")); + // The human form must not leak JSON quoting around string values. + assert!(!rendered.contains("\"0.1.0\"")); + } + + #[test] + fn render_version_human_lists_build_fields() { + let data = json!({ + "daemon_version": "0.1.0", + "build_commit": "abc123", + "rustc_version": "1.91.0", + "build_profile": "release", + }); + let rendered = render_version(&data, false).expect("render human"); + assert!(rendered.contains("Build commit: abc123")); + assert!(rendered.contains("Build profile: release")); + } + + #[test] + fn field_falls_back_to_unknown_for_missing_key() { + assert_eq!(field(&json!({}), "nope"), "unknown"); + } + + #[test] + fn render_log_level_get_human_is_labelled_and_unquoted() { + let data = json!({ "level": "debug" }); + let rendered = render_log_level_get(&data, false).expect("render human"); + assert_eq!(rendered, "Current log level: debug"); + } + + #[test] + fn render_log_level_set_human_is_labelled() { + let data = json!({ "level": "trace" }); + let rendered = render_log_level_set(&data, false).expect("render human"); + assert_eq!(rendered, "Log level set to trace"); + } + + #[test] + fn render_log_level_json_round_trips() { + let data = json!({ "level": "info" }); + let rendered = render_log_level_get(&data, true).expect("render json"); + let parsed: Value = serde_json::from_str(&rendered).expect("json round trip"); + assert_eq!(parsed, data); + } + + // ----- `exports list` table-rendering tests. ------------------------ + // Cover the 0/1/many input rows so the dynamic column widths + // (`uid_w`/`name_w`/`fsal_w`) are exercised in each regime: header + // floor only (zero rows), header floor still dominates (one short + // row), and content widening (long uid/name/fsal beats header). + + #[test] + fn render_exports_list_empty_prints_header_only() { + let data = json!({ "exports": [], "retired_uids": [] }); + let rendered = render_exports_list(&data, false).expect("render"); + // No rows → output is just the header line with header-width pads. + assert_eq!(rendered.trim_end(), "UID NAME FSAL READ-ONLY"); + } + + #[test] + fn render_exports_list_one_row_aligned() { + let data = json!({ + "exports": [ + { "uid": 1, "name": "/data", "fsal": "local", "read_only": false }, + ], + "retired_uids": [], + }); + let rendered = render_exports_list(&data, false).expect("render"); + // `/data` is wider than the `NAME` header (4 chars vs 5), so the + // name column widens to 5; everything else is at the header floor. + let expected = "UID NAME FSAL READ-ONLY\n1 /data local false"; + assert_eq!(rendered.trim_end(), expected); + } + + #[test] + fn render_exports_list_many_rows_columns_align_to_widest() { + // Mix a short row and a long row so the dynamic-width logic + // actually has to widen each column past the header floor. + let data = json!({ + "exports": [ + { "uid": 1, "name": "/d", "fsal": "local", "read_only": false }, + { "uid": 999999, "name": "/very/long/export/name", "fsal": "local", "read_only": true }, + ], + "retired_uids": [], + }); + let rendered = render_exports_list(&data, false).expect("render"); + let lines: Vec<&str> = rendered.lines().collect(); + // Header + 2 rows. + assert_eq!(lines.len(), 3, "got: {rendered:?}"); + // Every line is the same length once the dynamic widths apply + // (READ-ONLY column is the trailing field so no padding follows + // it — but every line's READ-ONLY value must start at the same + // column offset). + let header = lines[0]; + let row1 = lines[1]; + let row2 = lines[2]; + let ro_col = header.find("READ-ONLY").expect("header has READ-ONLY"); + assert!( + row1.starts_with("1 "), + "uid column must left-pad to widest: {row1:?}" + ); + assert_eq!( + row1.get(ro_col..=ro_col), + Some("f"), + "row1 READ-ONLY must start at the header's READ-ONLY column: {row1:?}" + ); + assert_eq!( + row2.get(ro_col..=ro_col), + Some("t"), + "row2 READ-ONLY must start at the header's READ-ONLY column: {row2:?}" + ); + } +} diff --git a/src/admin/commands/config_show.rs b/src/admin/commands/config_show.rs new file mode 100644 index 0000000..66607c4 --- /dev/null +++ b/src/admin/commands/config_show.rs @@ -0,0 +1,46 @@ +//! `config show` command — dump the daemon's startup configuration. +//! +//! Returns the original `Config` loaded at boot, not the live mutated +//! export set (admin mutations affect [`crate::fsal::MultiExportFilesystem`] +//! state; `config-show` is intentionally the *startup* view so operators +//! can diff what they configured against what's now live). +//! +//! Phase 5 returns the config unredacted. Sensitive-field redaction is a +//! follow-up when non-local FSALs land (S3 access keys, Ceph secrets, +//! etc.) — see the v1 plan in issue #25. + +use crate::admin::{AdminContext, AdminResponse}; + +/// `config show` — return the startup config as a JSON value. +pub fn handle(context: &AdminContext) -> AdminResponse { + match serde_json::to_value(context.config.as_ref()) { + Ok(data) => AdminResponse::Ok { data }, + Err(err) => AdminResponse::error(format!("config show: failed to serialize config: {err}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn returns_startup_config_keyed_by_top_level_sections() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let data = match handle(&context) { + AdminResponse::Ok { data } => data, + AdminResponse::Err { error } => panic!("config-show must return Ok; got: {error}"), + }; + // Top-level shape mirrors `Config`'s struct: server / exports / + // logging / admin. We don't lock the exact values here (the test + // helper builds an ephemeral config); just pin the shape. + assert!(data.get("server").is_some(), "missing `server`: {data}"); + assert!(data.get("exports").is_some(), "missing `exports`: {data}"); + assert!(data.get("logging").is_some(), "missing `logging`: {data}"); + assert!(data.get("admin").is_some(), "missing `admin`: {data}"); + // The one seeded export must round-trip. + let exports = data["exports"].as_array().expect("exports array"); + assert_eq!(exports.len(), 1); + assert_eq!(exports[0]["name"], "/data"); + assert_eq!(exports[0]["uid"], 1); + } +} diff --git a/src/admin/commands/exports_add.rs b/src/admin/commands/exports_add.rs new file mode 100644 index 0000000..b13b2f6 --- /dev/null +++ b/src/admin/commands/exports_add.rs @@ -0,0 +1,111 @@ +//! `exports add` command — install a new export at runtime. +//! +//! Delegates the heavy lifting to [`MultiExportFilesystem::add_export`], +//! which handles input validation, duplicate detection (active + retired +//! uids), and atomic snapshot rebuild. `dry_run = true` re-uses +//! [`MultiExportFilesystem::dry_run_add`] for the same checks without +//! touching the live snapshot, so operators can pre-flight an `add` +//! before committing. + +use serde_json::json; + +use crate::admin::{AdminContext, AdminResponse}; +use crate::config::ExportConfig; + +/// `exports add ` — add or pre-flight an export. +pub fn handle(context: &AdminContext, config: &ExportConfig, dry_run: bool) -> AdminResponse { + if dry_run { + return match context.filesystem.dry_run_add(config) { + // Echo the requested name/uid back in a `would_add` block so + // the dry-run shape is symmetric with `would_remove` / + // `would_update`. The values come straight from the request + // — `dry_run_add` validated them as a precondition for `Ok`. + Ok(()) => AdminResponse::Ok { + data: json!({ + "dry_run": true, + "would_succeed": true, + "would_add": { "name": config.name, "uid": config.uid }, + }), + }, + Err(err) => AdminResponse::error(format!("exports add (dry-run): {err:#}")), + }; + } + + match context.filesystem.add_export(config) { + Ok(()) => AdminResponse::Ok { + data: json!({ + "name": config.name, + "uid": config.uid, + }), + }, + Err(err) => AdminResponse::error(format!("exports add: {err:#}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::BackendConfig; + use crate::fsal::ExportRegistry; + + fn cfg(name: &str, uid: u32, path: std::path::PathBuf, read_only: bool) -> ExportConfig { + ExportConfig { + name: name.to_string(), + uid, + read_only, + backend: BackendConfig::Local { path }, + } + } + + #[test] + fn dry_run_returns_would_succeed_without_mutating() { + let (context, tmp, _log_guard) = AdminContext::for_test(); + // The for_test snapshot has one export, uid 1, name "/data". + let new_dir = tempfile::tempdir().expect("tempdir"); + let new = cfg("/extra", 7, new_dir.path().to_path_buf(), false); + let resp = handle(&context, &new, /*dry_run=*/ true); + match resp { + AdminResponse::Ok { data } => { + assert_eq!(data["dry_run"], true); + assert_eq!(data["would_succeed"], true); + // Symmetric with would_remove/would_update. + assert_eq!(data["would_add"]["name"], "/extra"); + assert_eq!(data["would_add"]["uid"], 7); + } + AdminResponse::Err { error } => panic!("dry-run must succeed; got: {error}"), + } + // State unchanged: still exactly one export. + assert_eq!(context.filesystem.list_exports().len(), 1); + // Keep tmp alive. + drop(tmp); + } + + #[test] + fn dry_run_rejects_duplicate_uid() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let new_dir = tempfile::tempdir().expect("tempdir"); + // uid 1 already taken by the seeded /data export. + let dup = cfg("/other", 1, new_dir.path().to_path_buf(), false); + match handle(&context, &dup, /*dry_run=*/ true) { + AdminResponse::Err { error } => { + assert!(error.contains("already in use"), "got: {error}") + } + AdminResponse::Ok { .. } => panic!("duplicate uid must be rejected"), + } + } + + #[test] + fn real_add_mutates_snapshot() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let new_dir = tempfile::tempdir().expect("tempdir"); + let new = cfg("/extra", 8, new_dir.path().to_path_buf(), false); + match handle(&context, &new, /*dry_run=*/ false) { + AdminResponse::Ok { data } => { + assert_eq!(data["name"], "/extra"); + assert_eq!(data["uid"], 8); + } + AdminResponse::Err { error } => panic!("add must succeed; got: {error}"), + } + assert_eq!(context.filesystem.list_exports().len(), 2); + } +} diff --git a/src/admin/commands/exports_list.rs b/src/admin/commands/exports_list.rs new file mode 100644 index 0000000..a35b73e --- /dev/null +++ b/src/admin/commands/exports_list.rs @@ -0,0 +1,48 @@ +//! `exports list` command — snapshot the live export set. +//! +//! Returns every active export (uid, name, fsal, read_only) along with the +//! list of retired uids — uids that were live during this daemon's run and +//! have since been removed. The retirement set is useful for operators +//! debugging "I removed this and it didn't come back" or wondering why +//! `exports add --uid ` was rejected. + +use serde_json::json; + +use crate::admin::{AdminContext, AdminResponse}; +use crate::fsal::ExportRegistry; + +/// `exports list` — return the active exports + retired uids. +pub fn handle(context: &AdminContext) -> AdminResponse { + let exports = context.filesystem.list_exports(); + let retired = context.filesystem.retired_uids(); + AdminResponse::Ok { + data: json!({ + "exports": exports, + "retired_uids": retired, + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_returns_seeded_export_and_no_retired() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let data = match handle(&context) { + AdminResponse::Ok { data } => data, + AdminResponse::Err { error } => panic!("list must return Ok; got: {error}"), + }; + let exports = data["exports"].as_array().expect("exports is an array"); + assert_eq!(exports.len(), 1); + assert_eq!(exports[0]["name"], "/data"); + assert_eq!(exports[0]["uid"], 1); + assert_eq!(exports[0]["read_only"], false); + assert_eq!(exports[0]["fsal"], "local"); + let retired = data["retired_uids"] + .as_array() + .expect("retired_uids is an array"); + assert!(retired.is_empty()); + } +} diff --git a/src/admin/commands/exports_remove.rs b/src/admin/commands/exports_remove.rs new file mode 100644 index 0000000..711bd18 --- /dev/null +++ b/src/admin/commands/exports_remove.rs @@ -0,0 +1,88 @@ +//! `exports remove` command — retire an export. +//! +//! The uid moves into `retired_uids` so an old client handle can't be +//! silently rerouted to a freshly-added export with the same uid. + +use serde_json::json; + +use crate::admin::{AdminContext, AdminResponse}; +use crate::fsal::multi_export::ExportSelector; + +/// `exports remove ` — remove or pre-flight a removal. +pub fn handle(context: &AdminContext, selector: &ExportSelector, dry_run: bool) -> AdminResponse { + if dry_run { + return match context.filesystem.dry_run_remove(selector) { + Ok((uid, name)) => AdminResponse::Ok { + data: json!({ + "dry_run": true, + "would_succeed": true, + "would_remove": { "uid": uid, "name": name }, + }), + }, + Err(err) => AdminResponse::error(format!("exports remove (dry-run): {err:#}")), + }; + } + + // `remove_export` returns (uid, name) derived from the same locked + // snapshot that performs the swap, so there is no TOCTOU window + // between a prefetch and the real mutation. + match context.filesystem.remove_export(selector) { + Ok(removed) => AdminResponse::Ok { + data: json!({ + "uid": removed.uid, + "name": removed.name, + }), + }, + Err(err) => AdminResponse::error(format!("exports remove: {err:#}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fsal::ExportRegistry; + + #[test] + fn dry_run_returns_would_remove_without_mutating() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let sel = ExportSelector::Name("/data".to_string()); + match handle(&context, &sel, /*dry_run=*/ true) { + AdminResponse::Ok { data } => { + assert_eq!(data["dry_run"], true); + assert_eq!(data["would_remove"]["uid"], 1); + assert_eq!(data["would_remove"]["name"], "/data"); + } + AdminResponse::Err { error } => panic!("dry-run must succeed; got: {error}"), + } + // Still active. + assert_eq!(context.filesystem.list_exports().len(), 1); + assert!(context.filesystem.retired_uids().is_empty()); + } + + #[test] + fn real_remove_retires_uid() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let sel = ExportSelector::Uid(1); + match handle(&context, &sel, /*dry_run=*/ false) { + AdminResponse::Ok { data } => { + assert_eq!(data["uid"], 1); + assert_eq!(data["name"], "/data"); + } + AdminResponse::Err { error } => panic!("remove must succeed; got: {error}"), + } + assert_eq!(context.filesystem.list_exports().len(), 0); + assert_eq!(context.filesystem.retired_uids(), vec![1]); + } + + #[test] + fn remove_missing_export_errors() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let sel = ExportSelector::Name("/missing".to_string()); + match handle(&context, &sel, /*dry_run=*/ false) { + AdminResponse::Err { error } => { + assert!(error.contains("No export named"), "got: {error}"); + } + AdminResponse::Ok { .. } => panic!("missing export must error"), + } + } +} diff --git a/src/admin/commands/exports_update.rs b/src/admin/commands/exports_update.rs new file mode 100644 index 0000000..faaf9a0 --- /dev/null +++ b/src/admin/commands/exports_update.rs @@ -0,0 +1,102 @@ +//! `exports update` command — mutate a live export. +//! +//! v1 only mutates `read_only`. Future fields land as additional optional +//! members on the wire variant and additional arguments here. + +use serde_json::json; + +use crate::admin::{AdminContext, AdminResponse}; +use crate::fsal::multi_export::ExportSelector; + +/// `exports update --read-only ` — update or pre-flight. +pub fn handle( + context: &AdminContext, + selector: &ExportSelector, + new_read_only: bool, + dry_run: bool, +) -> AdminResponse { + if dry_run { + return match context.filesystem.dry_run_update(selector) { + Ok((uid, name, current_ro)) => AdminResponse::Ok { + data: json!({ + "dry_run": true, + "would_succeed": true, + "would_update": { + "uid": uid, + "name": name, + "from": { "read_only": current_ro }, + "to": { "read_only": new_read_only }, + }, + }), + }, + Err(err) => AdminResponse::error(format!("exports update (dry-run): {err:#}")), + }; + } + + // `update_export` returns the (uid, name, prev_ro, new_ro) tuple + // derived from the snapshot that was just swapped in. No second + // `load()`, no TOCTOU window between the prefetch and the mutation. + match context + .filesystem + .update_export(selector, Some(new_read_only)) + { + Ok(updated) => AdminResponse::Ok { + data: json!({ + "uid": updated.uid, + "name": updated.name, + "read_only": updated.new_read_only, + }), + }, + Err(err) => AdminResponse::error(format!("exports update: {err:#}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fsal::ExportRegistry; + + #[test] + fn dry_run_returns_from_to_diff_without_mutating() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let sel = ExportSelector::Name("/data".to_string()); + match handle(&context, &sel, /*new=*/ true, /*dry_run=*/ true) { + AdminResponse::Ok { data } => { + assert_eq!(data["dry_run"], true); + assert_eq!(data["would_update"]["uid"], 1); + assert_eq!(data["would_update"]["from"]["read_only"], false); + assert_eq!(data["would_update"]["to"]["read_only"], true); + } + AdminResponse::Err { error } => panic!("dry-run must succeed; got: {error}"), + } + // State unchanged. + let infos = context.filesystem.list_exports(); + assert!(!infos[0].read_only); + } + + #[test] + fn real_update_flips_read_only() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let sel = ExportSelector::Name("/data".to_string()); + match handle(&context, &sel, /*new=*/ true, /*dry_run=*/ false) { + AdminResponse::Ok { data } => { + assert_eq!(data["read_only"], true); + } + AdminResponse::Err { error } => panic!("update must succeed; got: {error}"), + } + let infos = context.filesystem.list_exports(); + assert!(infos[0].read_only); + } + + #[test] + fn update_missing_export_errors() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let sel = ExportSelector::Uid(999); + match handle(&context, &sel, true, false) { + AdminResponse::Err { error } => { + assert!(error.contains("No export with uid"), "got: {error}") + } + AdminResponse::Ok { .. } => panic!("missing export must error"), + } + } +} diff --git a/src/admin/commands/log_level.rs b/src/admin/commands/log_level.rs new file mode 100644 index 0000000..51a3dec --- /dev/null +++ b/src/admin/commands/log_level.rs @@ -0,0 +1,414 @@ +//! `log-level` commands — inspect and live-reload the tracing filter. +//! +//! `get` reports the `EnvFilter` directive currently in effect; `set` swaps +//! it through the [`reload::Handle`](tracing_subscriber::reload::Handle) on +//! [`AdminContext`], changing daemon verbosity without a restart. + +use std::str::FromStr; + +use serde_json::json; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::EnvFilter; + +use crate::admin::{AdminContext, AdminResponse}; + +/// `log-level get` — report the tracing filter directive in effect. +pub fn handle_get(context: &AdminContext) -> AdminResponse { + match context.log_reload.with_current(|filter| filter.to_string()) { + Ok(directive) => AdminResponse::Ok { + data: json!({ "level": directive }), + }, + // The handle holds a `Weak`; this only fails if the subscriber + // owning the filter has been dropped, which cannot happen for the + // process-global subscriber in a running daemon. + Err(err) => AdminResponse::error(format!( + "log-level get failed: reload subscriber gone: {err}" + )), + } +} + +/// Validate one `EnvFilter` directive against this command's stricter +/// contract: each comma-separated piece is either a bare level +/// (`info`/`debug`/...) or a `target=level` pair whose right-hand side is +/// a recognized level. Target names are intentionally not validated — +/// `EnvFilter` accepts arbitrary target strings, and a user who wrote +/// `=` clearly intended a directive, so we trust the target name even +/// if it doesn't match a real module path. +/// +/// This is intentionally stricter than `EnvFilter::try_new` (which would +/// accept a bare `tomato` as the target half of `tomato=trace`) and looser +/// than the previous `LevelFilter::from_str` pre-check (which rejected +/// valid per-module directives like `arcticwolf=debug`). +/// +/// **Scope**: intentionally restricted to the v1 directive surface +/// — bare `LevelFilter` and `target=level`. `EnvFilter`'s full grammar +/// (span-field directives like `target[field=value]=level`, range +/// expressions, etc.) is out of scope; a future +/// `LogFilterSet { directive: String }` admin command could bypass this +/// validator and call `EnvFilter::try_new` directly for advanced +/// operators. +fn validate_filter_directive(input: &str) -> Result<(), String> { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err("Filter directive cannot be empty".to_string()); + } + + for piece in trimmed.split(',') { + let piece = piece.trim(); + if piece.is_empty() { + return Err(format!("Empty directive piece in '{input}'")); + } + if let Some(eq_pos) = piece.find('=') { + let level_str = piece[eq_pos + 1..].trim(); + LevelFilter::from_str(level_str) + .map_err(|_| format!("Invalid level '{level_str}' in directive '{piece}'"))?; + } else { + LevelFilter::from_str(piece).map_err(|_| { + format!( + "Invalid log level '{piece}' (expected one of: error, warn, info, debug, trace, off, or a directive like 'target=level')" + ) + })?; + } + } + Ok(()) +} + +/// `log-level set ` — swap the live tracing filter. +/// +/// Accepts either a bare log level (`info`) or an `EnvFilter` directive of +/// comma-separated `target=level` pairs (`arcticwolf=debug,hyper=warn`). +/// Bare non-levels (`tomato`) and pieces whose right-hand side isn't a +/// recognized level (`tomato=banana`) are rejected and the active filter +/// is left untouched, per the issue #25 acceptance criteria. +/// +/// Parsing the input straight through `EnvFilter` cannot enforce this +/// contract: `EnvFilter` accepts `tomato` as a *target* directive +/// (`tomato=trace`), so it would not reject the very input the AC +/// requires us to reject. [`validate_filter_directive`] applies the +/// per-piece rule before the reload. +pub fn handle_set(context: &AdminContext, level: &str) -> AdminResponse { + if let Err(err) = validate_filter_directive(level) { + return AdminResponse::error(err); + } + + // Defense-in-depth: if `EnvFilter::try_new` rejects input that the + // strict validator accepted (e.g. whitespace around `=`, or an empty + // target like `=info`), preserve the previous filter and report the + // disagreement. The reload is never reached, so the existing state + // is intact. `to_string()` normalizes the directive (e.g. `DEBUG` -> + // `debug`) so the echoed value matches a later `get`. + let filter = match EnvFilter::try_new(level) { + Ok(filter) => filter, + Err(err) => { + return AdminResponse::error(format!( + "EnvFilter rejected directive '{level}' after validation: {err}" + )); + } + }; + let directive = filter.to_string(); + + match context.log_reload.reload(filter) { + Ok(()) => AdminResponse::Ok { + data: json!({ "level": directive }), + }, + Err(err) => AdminResponse::error(format!( + "log-level set failed: reload subscriber gone: {err}" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Pull the `level` string out of an `Ok` response, panicking with the + /// error message if the response is an `Err`. + fn ok_level(response: &AdminResponse) -> String { + match response { + AdminResponse::Ok { data } => data["level"] + .as_str() + .expect("`level` is a string") + .to_string(), + AdminResponse::Err { error } => panic!("expected Ok response, got err: {error}"), + } + } + + #[test] + fn set_then_get_round_trips() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + + let set = handle_set(&context, "debug"); + assert_eq!(ok_level(&set), "debug", "set must echo the new level"); + + let get = handle_get(&context); + assert_eq!( + ok_level(&get), + "debug", + "get must observe the level set by the prior set", + ); + } + + #[test] + fn set_invalid_level_errors_and_leaves_filter_unchanged() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + // The `for_test` context starts at `info`. + assert_eq!(ok_level(&handle_get(&context)), "info"); + + match handle_set(&context, "tomato") { + AdminResponse::Err { error } => assert!( + error.contains("tomato"), + "the error should name the rejected input; got: {error}", + ), + AdminResponse::Ok { .. } => panic!("`log-level set tomato` must be rejected"), + } + + // The rejected `set` must not have disturbed the active filter. + assert_eq!( + ok_level(&handle_get(&context)), + "info", + "a rejected set must leave the filter unchanged", + ); + } + + #[test] + fn set_normalizes_case_in_the_echoed_level() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let set = handle_set(&context, "WARN"); + assert_eq!( + ok_level(&set), + "warn", + "an uppercase level must round-trip as its normalized directive", + ); + } + + /// `error` text must contain the rejected input, panic otherwise. + fn expect_err_containing(response: AdminResponse, needle: &str) -> String { + match response { + AdminResponse::Err { error } => { + assert!( + error.contains(needle), + "error should contain {needle:?}; got: {error}", + ); + error + } + AdminResponse::Ok { .. } => panic!("expected Err, got Ok"), + } + } + + #[test] + fn validates_bare_level() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + assert_eq!(ok_level(&handle_set(&context, "info")), "info"); + } + + #[test] + fn validates_per_module_directive() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let set = handle_set(&context, "arcticwolf=debug"); + // The echoed directive comes from `EnvFilter::to_string()` after + // normalization; assert it round-trips through `get`. + let directive = ok_level(&set); + assert_eq!( + ok_level(&handle_get(&context)), + directive, + "get must observe the per-module directive set", + ); + } + + #[test] + fn validates_compound_directive() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let set = handle_set(&context, "arcticwolf::nfs::read=trace,hyper=warn"); + let directive = ok_level(&set); + assert_eq!(ok_level(&handle_get(&context)), directive); + } + + #[test] + fn rejects_bare_nonsense() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let before = ok_level(&handle_get(&context)); + expect_err_containing(handle_set(&context, "tomato"), "tomato"); + assert_eq!( + ok_level(&handle_get(&context)), + before, + "a rejected bare nonsense input must leave the filter unchanged", + ); + } + + #[test] + fn rejects_invalid_level_in_directive() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let before = ok_level(&handle_get(&context)); + expect_err_containing(handle_set(&context, "tomato=banana"), "banana"); + assert_eq!( + ok_level(&handle_get(&context)), + before, + "a rejected directive must leave the filter unchanged", + ); + } + + #[test] + fn accepts_target_with_valid_level() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + // The target name `tomato` is arbitrary — using `=` signals intent, + // so we don't try to second-guess what targets are "real". + let set = handle_set(&context, "tomato=debug"); + let directive = ok_level(&set); + assert_eq!(ok_level(&handle_get(&context)), directive); + } + + /// A compound directive must be rejected if *any* piece is invalid, + /// even when other pieces are well-formed. The error message must + /// name the offending piece so an operator can fix it without + /// guessing. + #[test] + fn rejects_compound_directive_with_one_invalid_piece() { + let err = validate_filter_directive("info,tomato") + .expect_err("compound directive with a bare nonsense piece must be rejected"); + assert!( + err.contains("tomato"), + "error '{err}' should name the bad piece 'tomato'", + ); + } + + #[test] + fn rejects_compound_directive_with_invalid_level_in_one_directive() { + let err = validate_filter_directive("arcticwolf=debug,hyper=banana") + .expect_err("compound directive with one bad level must be rejected"); + assert!( + err.contains("banana"), + "error '{err}' should name the bad level 'banana'", + ); + } + + /// Behavioral assertion for issue #25's acceptance criterion: a + /// successful `handle_set` must actually change which events the + /// `EnvFilter` admits, not just the directive string echoed back. + /// + /// We can't lean on `AdminContext::for_test` for this — its reload + /// handle is intentionally never attached to a live subscriber. Instead + /// we build a one-off `Registry` with the reload layer + an in-memory + /// capture layer, drive `set_default` for the test's scope, and + /// construct a minimal `AdminContext` over the same handle so the + /// real handler runs against the real subscriber. + #[test] + fn handle_set_actually_changes_event_filtering() { + use std::fmt::Write; + use std::sync::{Arc, Mutex}; + use std::time::Instant; + use tracing::{debug, warn}; + use tracing_subscriber::{Layer, Registry, layer::SubscriberExt, reload}; + + use crate::admin::context::{LogReloadHandle, ServerMetadata}; + use crate::config::{BackendConfig, Config, ExportConfig}; + use crate::fsal::MultiExportFilesystem; + + #[derive(Clone, Default)] + struct CaptureLayer(Arc>>); + + impl Layer for CaptureLayer + where + S: tracing::Subscriber, + { + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + struct Visit<'a>(&'a mut String); + impl<'a> tracing::field::Visit for Visit<'a> { + fn record_str(&mut self, _: &tracing::field::Field, value: &str) { + self.0.push_str(value); + } + fn record_debug( + &mut self, + _: &tracing::field::Field, + value: &dyn std::fmt::Debug, + ) { + let _ = write!(self.0, "{value:?}"); + } + } + let mut rendered = String::new(); + event.record(&mut Visit(&mut rendered)); + self.0 + .lock() + .expect("capture layer mutex not poisoned") + .push(rendered); + } + } + + // Subscriber with the reloadable EnvFilter wired into the same + // capture stack a test can read back from. + let capture = CaptureLayer::default(); + let (filter_layer, handle) = + reload::Layer::::new(EnvFilter::new("warn")); + let subscriber = Registry::default().with(filter_layer).with(capture.clone()); + let _subscriber_guard = tracing::subscriber::set_default(subscriber); + + // Minimal AdminContext that carries the live reload handle. We + // build it by hand (rather than using `for_test`) so the handler + // operates on the same subscriber the capture layer observes. + let handle: LogReloadHandle = handle; + let tmp = tempfile::tempdir().expect("tempdir for behavioral test"); + let config = Config { + exports: vec![ExportConfig { + name: "/data".to_string(), + uid: 1, + read_only: false, + backend: BackendConfig::Local { + path: tmp.path().to_path_buf(), + }, + }], + ..Config::default() + }; + let filesystem: Arc = Arc::new( + MultiExportFilesystem::build_from_config(&config.exports) + .expect("build test filesystem"), + ); + let metadata = Arc::new(ServerMetadata { + nfs_port: 2049, + mount_port: 20048, + portmap_port: 111, + }); + let context = AdminContext::shared( + Instant::now(), + metadata, + handle, + filesystem, + Arc::new(config), + Arc::new(crate::admin::NoopAuditWriter), + Arc::new(crate::metrics::Metrics::new()), + ); + + // Filter starts at `warn`: debug is suppressed, warn passes. + debug!("debug-pre-reload"); + warn!("warn-pre-reload"); + + // Reload through the public admin handler, exactly as a client would. + match handle_set(&context, "debug") { + AdminResponse::Ok { .. } => {} + AdminResponse::Err { error } => panic!("handle_set('debug') failed: {error}"), + } + + // Same debug event now passes the relaxed filter. + debug!("debug-post-reload"); + + let events = capture.0.lock().expect("capture mutex not poisoned"); + let combined = events.join("|"); + assert!( + !combined.contains("debug-pre-reload"), + "debug-pre-reload should be filtered by the initial `warn` directive; \ + captured events: {combined}", + ); + assert!( + combined.contains("warn-pre-reload"), + "warn-pre-reload should pass the initial `warn` directive; \ + captured events: {combined}", + ); + assert!( + combined.contains("debug-post-reload"), + "debug-post-reload should pass after `handle_set` swapped the filter to `debug`; \ + captured events: {combined}", + ); + } +} diff --git a/src/admin/commands/metrics.rs b/src/admin/commands/metrics.rs new file mode 100644 index 0000000..401a356 --- /dev/null +++ b/src/admin/commands/metrics.rs @@ -0,0 +1,75 @@ +//! `metrics` command — a JSON snapshot of operational counters (Phase 7). +//! +//! Returns the lock-free counter snapshot assembled by +//! [`crate::metrics::Metrics::snapshot_json`]: server-wide RPC totals, +//! per-NFS-op counts, per-export I/O counters, and per-admin-command +//! counts. `uptime_seconds` is derived from [`AdminContext::start_time`] +//! rather than duplicated in the registry; the per-export rows come from the +//! filesystem router. The snapshot is NOT atomic across counters — see the +//! [`crate::metrics`] module doc. + +use crate::admin::{AdminContext, AdminResponse}; + +/// `metrics` — snapshot the daemon's operational counters as JSON. +pub fn handle(context: &AdminContext) -> AdminResponse { + let uptime_seconds = context.start_time.elapsed().as_secs(); + let exports = context.filesystem.export_metrics(); + let data = context.metrics.snapshot_json(uptime_seconds, exports); + AdminResponse::Ok { data } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::admin::AdminRequest; + + #[test] + fn metrics_returns_ok_with_documented_shape() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let data = match handle(&context) { + AdminResponse::Ok { data } => data, + AdminResponse::Err { error } => panic!("metrics must return Ok; got: {error}"), + }; + // Top-level sections. + for section in ["server", "nfs_ops", "exports", "admin"] { + assert!(data.get(section).is_some(), "missing `{section}`: {data}"); + } + assert!(data["server"]["uptime_seconds"].is_u64()); + assert!(data["server"]["rpc_requests_total"].is_u64()); + assert!(data["nfs_ops"]["getattr"].is_u64()); + // The for_test context defines exactly one export (/data, uid 1). + let exports = data["exports"].as_array().expect("exports array"); + assert_eq!(exports.len(), 1); + assert_eq!(exports[0]["uid"], 1); + assert_eq!(exports[0]["name"], "/data"); + assert_eq!(exports[0]["requests"], 0); + assert!(data["admin"]["by_command"].is_object()); + } + + #[test] + fn metrics_reflects_admin_command_counts() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + // Simulate two accepted admin requests being recorded the way the + // server's dispatch path does. + context.metrics.admin.record("status"); + context.metrics.admin.record("metrics"); + let data = match handle(&context) { + AdminResponse::Ok { data } => data, + AdminResponse::Err { error } => panic!("metrics must return Ok; got: {error}"), + }; + assert_eq!(data["admin"]["commands_total"], 2); + assert_eq!(data["admin"]["by_command"]["status"], 1); + assert_eq!(data["admin"]["by_command"]["metrics"], 1); + } + + /// The `metrics` request tag is `"metrics"`, matching the audit/command + /// tag the server records for this command. + #[test] + fn metrics_request_tag_is_metrics() { + let tag = serde_json::to_value(AdminRequest::Metrics).unwrap()["command"] + .as_str() + .expect("command tag") + .to_string(); + assert_eq!(tag, "metrics"); + } +} diff --git a/src/admin/commands/mod.rs b/src/admin/commands/mod.rs new file mode 100644 index 0000000..d8bf94f --- /dev/null +++ b/src/admin/commands/mod.rs @@ -0,0 +1,17 @@ +//! Daemon-side admin command handlers. +//! +//! Each handler maps one [`AdminRequest`](super::AdminRequest) variant to an +//! [`AdminResponse`](super::AdminResponse). Phase 2 shipped the two +//! read-only commands; Phase 3 added log-level; Phase 5 adds the exports +//! and config-show commands. Later phases add their handlers alongside +//! these. + +pub mod config_show; +pub mod exports_add; +pub mod exports_list; +pub mod exports_remove; +pub mod exports_update; +pub mod log_level; +pub mod metrics; +pub mod status; +pub mod version; diff --git a/src/admin/commands/status.rs b/src/admin/commands/status.rs new file mode 100644 index 0000000..485c4e5 --- /dev/null +++ b/src/admin/commands/status.rs @@ -0,0 +1,67 @@ +//! `status` command — a daemon health snapshot. + +use serde_json::json; + +use crate::admin::{AdminContext, AdminResponse}; +use crate::fsal::ExportRegistry; + +/// Build the `status` response from the live daemon context. +/// +/// The reported ports come from [`AdminContext::server_metadata`], which is +/// resolved after `bind(2)` — so a configured port of `0` (dynamic +/// allocation) is reported as the concrete port in use. `log_level` is read +/// live from the tracing reload handle, so it reflects any `log-level set` +/// applied since startup rather than the configured value. +pub fn handle(context: &AdminContext) -> AdminResponse { + let log_level = context + .log_reload + .with_current(|filter| filter.to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + let data = json!({ + "daemon_version": env!("CARGO_PKG_VERSION"), + "uptime_seconds": context.start_time.elapsed().as_secs(), + "bind_address": context.config.server.bind_address, + "nfs_port": context.server_metadata.nfs_port, + "mount_port": context.server_metadata.mount_port, + "portmap_port": context.server_metadata.portmap_port, + "log_level": log_level, + "export_count": context.filesystem.list_exports().len(), + }); + AdminResponse::Ok { data } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_returns_ok_with_all_documented_fields() { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + let data = match handle(&context) { + AdminResponse::Ok { data } => data, + AdminResponse::Err { error } => panic!("status must return Ok; got err: {error}"), + }; + for field in [ + "daemon_version", + "uptime_seconds", + "bind_address", + "nfs_port", + "mount_port", + "portmap_port", + "log_level", + "export_count", + ] { + assert!( + data.get(field).is_some(), + "status response missing `{field}`: {data}", + ); + } + assert_eq!(data["daemon_version"], env!("CARGO_PKG_VERSION")); + assert!(data["uptime_seconds"].is_u64()); + assert_eq!(data["log_level"], "info"); + assert_eq!( + data["export_count"], 1, + "the for_test context defines exactly one export", + ); + } +} diff --git a/src/admin/commands/version.rs b/src/admin/commands/version.rs new file mode 100644 index 0000000..4ef7318 --- /dev/null +++ b/src/admin/commands/version.rs @@ -0,0 +1,84 @@ +//! `version` command — daemon build and version metadata. + +use serde_json::json; + +use crate::admin::AdminResponse; + +/// Build the `version` response. +/// +/// `build_commit` and `rustc_version` come from the `vergen`-generated +/// compile-time env vars (see `build.rs`). When the crate is built without a +/// `.git` directory available — as in the container build, which copies only +/// the sources — `vergen` cannot resolve the commit and `build_commit` +/// falls back to `"unknown"`. +pub fn handle() -> AdminResponse { + let data = json!({ + "daemon_version": env!("CARGO_PKG_VERSION"), + "build_commit": build_commit(), + "rustc_version": rustc_version(), + "build_profile": build_profile(), + }); + AdminResponse::Ok { data } +} + +/// Git commit the daemon was built from, or `"unknown"`. +fn build_commit() -> &'static str { + vergen_or_unknown(option_env!("VERGEN_GIT_SHA")) +} + +/// `rustc` version the daemon was built with, or `"unknown"`. +fn rustc_version() -> &'static str { + vergen_or_unknown(option_env!("VERGEN_RUSTC_SEMVER")) +} + +/// Cargo build profile (`"debug"` / `"release"`), or `"unknown"`. +fn build_profile() -> &'static str { + match option_env!("VERGEN_CARGO_DEBUG") { + Some("true") => "debug", + Some("false") => "release", + _ => "unknown", + } +} + +/// Normalize a `vergen` value: an unset var, an empty string, and vergen's +/// `VERGEN_IDEMPOTENT_OUTPUT` placeholder (emitted when the underlying tool +/// — e.g. `git` — was unavailable at build time) all collapse to +/// `"unknown"`. +fn vergen_or_unknown(value: Option<&'static str>) -> &'static str { + match value { + Some(v) if !v.is_empty() && v != "VERGEN_IDEMPOTENT_OUTPUT" => v, + _ => "unknown", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_returns_ok_with_all_documented_fields() { + let data = match handle() { + AdminResponse::Ok { data } => data, + AdminResponse::Err { error } => panic!("version must return Ok; got err: {error}"), + }; + for field in [ + "daemon_version", + "build_commit", + "rustc_version", + "build_profile", + ] { + assert!( + data[field].is_string(), + "version field `{field}` must be a string: {data}", + ); + } + assert_eq!(data["daemon_version"], env!("CARGO_PKG_VERSION")); + } + + #[test] + fn build_profile_is_one_of_the_known_values() { + // `cargo test` builds in the dev profile, so this resolves to + // "debug" here — but pin the full set so a vergen change is caught. + assert!(matches!(build_profile(), "debug" | "release" | "unknown")); + } +} diff --git a/src/admin/context.rs b/src/admin/context.rs new file mode 100644 index 0000000..9d7bdcd --- /dev/null +++ b/src/admin/context.rs @@ -0,0 +1,239 @@ +//! Shared state for admin request handlers. +//! +//! Handlers read the daemon start instant (for `uptime`), the resolved +//! listening ports, the live tracing reload handle (for `log-level`), the +//! filesystem backend (for `export_count`), and the original configuration. +//! Later phases extend this struct further (Phase 6 adds the audit-log +//! writer). +//! +//! `Clone` and `Default` are deliberately *not* derived: there is no +//! meaningful `Default` for the filesystem backend or the reload handle, +//! and the context is shared by reference rather than copied. Consumers +//! share it via `Arc` (see [`AdminContext::shared`]). + +use std::sync::Arc; +use std::time::Instant; + +use tracing_subscriber::{EnvFilter, Registry, reload}; + +use crate::admin::audit::AuditWriter; +#[cfg(any(test, feature = "test-util"))] +use crate::admin::audit::NoopAuditWriter; +use crate::config::Config; +use crate::fsal::MultiExportFilesystem; +use crate::metrics::Metrics; + +/// Live reload handle for the daemon's tracing `EnvFilter`. +/// +/// The type parameters mirror the layer stack assembled in `main.rs`: the +/// reloadable layer is the `EnvFilter` itself, layered onto a `Registry` +/// subscriber. `log-level get` reads the current directive through it and +/// `log-level set` swaps it, so a single handle is shared (cheaply — it is +/// a `Weak` internally) across every admin connection. +pub type LogReloadHandle = reload::Handle; + +/// Actual TCP ports the RPC services ended up listening on. +/// +/// Resolved *after* `bind(2)` so that a configured port of `0` ("ask the OS +/// for any free port") is reported as the concrete port the daemon is +/// actually reachable on, not the literal `0` from the config file. +#[derive(Debug, Clone, Copy)] +pub struct ServerMetadata { + /// Port the NFS (program 100003) service is listening on. + pub nfs_port: u16, + /// Port the MOUNT (program 100005) service is listening on. + pub mount_port: u16, + /// Port the PORTMAP (program 100000) service is listening on. + pub portmap_port: u16, +} + +/// Shared, read-only state handed to every admin command handler. +#[non_exhaustive] +pub struct AdminContext { + /// Daemon process start instant. Used to compute `uptime_seconds`. + pub start_time: Instant, + /// Actual listening ports, resolved after bind. + pub server_metadata: Arc, + /// Live reload handle for the tracing `EnvFilter`. `log-level get` + /// reads the directive currently in effect; `log-level set` swaps it, + /// retuning daemon verbosity without a restart. `status` also reads it + /// so the reported `log_level` always reflects the live filter. + pub log_reload: LogReloadHandle, + /// Filesystem backend. Phase 2 uses it for `export_count`; Phase 5 + /// also calls the inherent admin mutation methods + /// (`add_export`/`remove_export`/`update_export`) on it, so the + /// context stores the concrete `MultiExportFilesystem` rather than + /// `Arc`. + pub filesystem: Arc, + /// Original daemon configuration (e.g. for `bind_address`). + pub config: Arc, + /// Phase 6: structured audit-log destination. The admin server + /// fire-and-forgets one event per accepted request. Wired to a + /// `FileAuditWriter` when `[audit] enabled = true`, otherwise to the + /// inert `NoopAuditWriter` so the dispatch path can call + /// `context.audit.record(event)` unconditionally. + pub audit: Arc, + /// Phase 7: operational counters shared with the RPC servers and the + /// filesystem router. The `metrics` admin command snapshots this; the + /// admin server also bumps the per-command counters here on every + /// accepted request. + pub metrics: Arc, +} + +impl AdminContext { + /// Assemble the context from the daemon's already-resolved startup + /// state. `main.rs` shares the result across per-connection tasks + /// behind the returned `Arc` (see the type-level note on why `Clone` + /// is deliberately not derived). + #[allow(clippy::too_many_arguments)] + pub fn shared( + start_time: Instant, + server_metadata: Arc, + log_reload: LogReloadHandle, + filesystem: Arc, + config: Arc, + audit: Arc, + metrics: Arc, + ) -> Arc { + Arc::new(Self { + start_time, + server_metadata, + log_reload, + filesystem, + config, + audit, + metrics, + }) + } +} + +/// Per-process reload handle used by [`AdminContext::for_test`]. +/// +/// The backing layer is leaked exactly once via `OnceLock` so the handle +/// remains usable for the lifetime of the test process without piling up +/// one leaked layer per `for_test` call. +#[cfg(any(test, feature = "test-util"))] +static SHARED_TEST_LOG_RELOAD: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Serializes access to the shared test reload handle. +/// +/// `for_test` returns a guard against this mutex so tests in the same +/// binary that observe or mutate the filter state (e.g. `handle_get` +/// after `handle_set`, or `status` asserting `log_level == "info"`) +/// don't race on the shared `OnceLock` handle. Tests in different test +/// binaries get separate copies of this mutex and are isolated by +/// construction. +#[cfg(any(test, feature = "test-util"))] +static TEST_LOG_RELOAD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Guard returned by [`AdminContext::for_test`] that serializes test +/// access to the shared tracing reload handle. +/// +/// Keep it alive for the entire test body — dropping it releases the +/// lock and lets another test mutate the shared filter state. +#[cfg(any(test, feature = "test-util"))] +pub type TestLogReloadGuard = std::sync::MutexGuard<'static, ()>; + +#[cfg(any(test, feature = "test-util"))] +impl AdminContext { + /// Construct a minimal `AdminContext` suitable for tests: one + /// tempdir-backed export, fixed ports, `info` log level, and an inert + /// [`NoopAuditWriter`]. The returned [`tempfile::TempDir`] and + /// [`TestLogReloadGuard`] must both be kept alive for as long as the + /// context is used — the tempdir backs the export, and the guard + /// serializes access to the shared tracing reload handle (see + /// `TEST_LOG_RELOAD_LOCK`). + /// + /// The reload handle itself is shared across all `for_test` callers + /// via a process-wide `OnceLock`; the underlying layer is leaked once + /// per test process. The filter is reset to `info` on every call so + /// each test sees a known starting state. + /// + /// Available under `#[cfg(test)]` for in-crate unit tests, or via the + /// `test-util` feature for integration tests under `tests/`. Never + /// shipped in release builds. + /// + /// Tests that need to inspect the audit stream (the Phase 6 integration + /// suite) call [`AdminContext::for_test_with_audit`] instead so they + /// can plug in a `FileAuditWriter` over a tempfile. + pub fn for_test() -> (Arc, tempfile::TempDir, TestLogReloadGuard) { + Self::for_test_with_audit(Arc::new(NoopAuditWriter)) + } + + /// Variant of [`for_test`] that lets the caller supply the audit + /// writer. Used by Phase 6's `tests/test_admin_audit.rs` to point the + /// context at a `FileAuditWriter` backed by a tempfile; all other + /// callers should keep using `for_test`, which threads in a + /// `NoopAuditWriter`. + pub fn for_test_with_audit( + audit: Arc, + ) -> (Arc, tempfile::TempDir, TestLogReloadGuard) { + use crate::config::{BackendConfig, Config, ExportConfig}; + + let tmp = tempfile::tempdir().expect("create tempdir for admin test context"); + let config = Config { + exports: vec![ExportConfig { + name: "/data".to_string(), + uid: 1, + read_only: false, + backend: BackendConfig::Local { + path: tmp.path().to_path_buf(), + }, + }], + ..Config::default() + }; + let filesystem: Arc = Arc::new( + MultiExportFilesystem::build_from_config(&config.exports) + .expect("build test filesystem"), + ); + let metadata = Arc::new(ServerMetadata { + nfs_port: 2049, + mount_port: 20048, + portmap_port: 111, + }); + + // Acquire the shared-handle lock for the duration of the test. + // Recover from poisoning so a panic in an earlier test doesn't + // also poison every subsequent test in the same binary. + let guard = TEST_LOG_RELOAD_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + + // Build a standalone reload handle starting at `info`. The handle + // only stays usable while the layer that owns its backing `Arc` is + // alive; in the daemon that layer lives inside the process-global + // subscriber installed by `main.rs`. Tests install no global + // subscriber, so the layer is leaked *once per test process* via + // `OnceLock` to give the handle the same process-lifetime backing + // — without touching global tracing state. Subsequent `for_test` + // callers receive a clone of the same handle (it's a `Weak` + // internally, so cloning is cheap). + let log_reload = SHARED_TEST_LOG_RELOAD + .get_or_init(|| { + let (filter_layer, handle) = + reload::Layer::::new(EnvFilter::new("info")); + std::mem::forget(filter_layer); + handle + }) + .clone(); + + // Reset the filter to `info` so each test sees a known starting + // state regardless of what a previous test left behind. The + // returned guard then keeps the next `for_test` caller out until + // this test drops its tuple. + log_reload + .reload(EnvFilter::new("info")) + .expect("reset shared test reload handle"); + + let context = Self::shared( + Instant::now(), + metadata, + log_reload, + filesystem, + Arc::new(config), + audit, + Arc::new(Metrics::new()), + ); + (context, tmp, guard) + } +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs new file mode 100644 index 0000000..66f69b0 --- /dev/null +++ b/src/admin/mod.rs @@ -0,0 +1,30 @@ +//! Admin server (issue #25). +//! +//! Phase 1 introduced the transport scaffolding: a length-prefixed JSON +//! codec over a Unix domain socket, the request/response schema, and a +//! dispatcher. Phase 2 wires the first two read-only commands — `status` +//! and `version` — end to end, including the operator-facing +//! `arcticwolfctl` client (see [`client`]). Later phases add the remaining +//! commands (log-level, exports/config, metrics, shutdown). + +pub mod audit; +pub mod client; +pub mod commands; +pub mod context; +pub mod protocol; +pub mod request; +pub mod response; +pub mod server; + +pub use audit::{AuditEvent, AuditWriter, FileAuditWriter, NoopAuditWriter, PeerCreds}; +pub use context::{AdminContext, ServerMetadata}; +pub use request::AdminRequest; +pub use response::AdminResponse; +pub use server::serve_with_shutdown; + +/// Default filesystem path for the admin Unix domain socket. +/// +/// Shared by [`AdminConfig::default`](crate::config::AdminConfig) and the +/// `arcticwolfctl --socket` flag so the daemon and the operator CLI agree +/// on the socket location without writing the literal path in two places. +pub const DEFAULT_ADMIN_SOCKET_PATH: &str = "/run/arcticwolf/admin.sock"; diff --git a/src/admin/protocol.rs b/src/admin/protocol.rs new file mode 100644 index 0000000..4b6269d --- /dev/null +++ b/src/admin/protocol.rs @@ -0,0 +1,246 @@ +//! Length-prefixed JSON wire codec for the admin server. +//! +//! Every admin message is encoded as a 4-byte big-endian length followed by +//! the UTF-8 JSON body, using `tokio_util::codec::LengthDelimitedCodec` for +//! the framing. JSON is chosen over a tighter binary encoding for two +//! reasons: payloads contain free-form strings (log lines, error messages, +//! export paths) where escaping a binary frame buys nothing, and the +//! `arcticwolfctl raw ''` debug command requires the wire format to +//! be human-readable. +//! +//! This module exposes: +//! - [`framed`] wraps a Tokio `AsyncRead + AsyncWrite` stream in a +//! `LengthDelimitedCodec`, so each read or write is one length-prefixed +//! frame of raw bytes — the JSON body is encoded and decoded separately +//! by the helpers below. +//! - [`decode_request`] / [`encode_response`] are the raw JSON conversion +//! functions, kept public so unit tests (and Phase 2 batch tooling) can +//! exercise them without setting up a socket pair. + +use bytes::Bytes; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio_util::codec::{Framed, LengthDelimitedCodec}; + +use super::request::AdminRequest; +use super::response::AdminResponse; + +/// Maximum size of a single admin frame (1 MiB). +/// +/// Admin payloads are small (status snapshots, exports lists, metrics +/// dumps); the cap exists so a buggy or malicious client cannot make the +/// daemon allocate gigabytes by sending a crafted length prefix. 1 MiB is +/// well above any plausible legitimate payload — the largest expected +/// response is `metrics` in Prometheus format, which for a handful of +/// counters/histograms is still firmly in the kilobyte range. +pub const MAX_FRAME_BYTES: usize = 1024 * 1024; + +/// Build the `LengthDelimitedCodec` used by both sides of the connection. +fn codec() -> LengthDelimitedCodec { + LengthDelimitedCodec::builder() + .length_field_length(4) + .max_frame_length(MAX_FRAME_BYTES) + .new_codec() +} + +/// Wrap a stream so each frame is a single JSON-encoded `AdminRequest` or +/// `AdminResponse`. The framing is symmetric (same codec on both ends). +pub fn framed(io: S) -> Framed +where + S: AsyncRead + AsyncWrite, +{ + Framed::new(io, codec()) +} + +/// Decode a single frame's bytes into an `AdminRequest`. +/// +/// Surfaces serde errors as plain `String`s so the dispatcher can wrap +/// them in an `AdminResponse::Err` without leaking the serde error type +/// into the public API. The message starts with "Unknown command" when +/// the tagged-enum discriminator is unrecognized, which the client uses +/// to distinguish "unknown command" from other parse failures. +pub fn decode_request(frame: &[u8]) -> Result { + match serde_json::from_slice::(frame) { + Ok(req) => Ok(req), + Err(err) => Err(translate_decode_error(err)), + } +} + +fn translate_decode_error(err: serde_json::Error) -> String { + // Classify with `serde_json::error::Category` rather than matching the + // English message text. Pattern-matching `err.to_string()` against + // "unknown variant " coupled the CLI's user-facing error to serde_json's + // internal wording — any localization or message tweak there would + // silently break the "Unknown command" contract. + // + // The four categories from `serde_json` are: + // - `Data` : structurally valid JSON, but the schema doesn't match + // (unknown variant, wrong field type, missing field). + // For the admin protocol this is overwhelmingly an + // unknown command, so we surface it as such. + // - `Syntax` : the bytes are not valid JSON at all. + // - `Eof` : valid-so-far JSON cut short mid-token. + // - `Io` : a reader-level failure; not applicable when decoding + // from a `&[u8]`, but matched for completeness so a + // future move to `from_reader` doesn't fall through. + match err.classify() { + serde_json::error::Category::Data => format!("Unknown command: {err}"), + serde_json::error::Category::Syntax | serde_json::error::Category::Eof => { + format!("Malformed admin request: {err}") + } + serde_json::error::Category::Io => { + format!("I/O error reading admin request: {err}") + } + } +} + +/// Encode a response as a single frame's bytes. +/// +/// `serde_json::to_vec` cannot fail for the shapes used by `AdminResponse` +/// (the `data` field is already a `serde_json::Value`, which is always +/// serializable; the error arm is a plain `String`). We still return a +/// `Result` so callers don't have to assume that. +pub fn encode_response(response: &AdminResponse) -> Result { + serde_json::to_vec(response).map(Bytes::from) +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::BytesMut; + use tokio_util::codec::{Decoder, Encoder}; + + /// Round-trip the codec: encode an `AdminRequest`, then run the bytes + /// back through the framing layer and parse them with + /// `decode_request`. Pins both the framing math (4-byte BE length + /// prefix, then payload) and the JSON tagging. + #[test] + fn codec_round_trip_status() { + let original = AdminRequest::Status; + let payload = serde_json::to_vec(&original).expect("serialize"); + + let mut codec = codec(); + let mut buf = BytesMut::new(); + codec + .encode(payload.clone().into(), &mut buf) + .expect("encode"); + + // Length prefix occupies 4 bytes; the rest is the JSON body. + assert_eq!(buf.len(), 4 + payload.len()); + let len_prefix = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]); + assert_eq!(len_prefix as usize, payload.len()); + + let frame = codec + .decode(&mut buf) + .expect("decode") + .expect("frame ready"); + let decoded = decode_request(&frame).expect("decode_request"); + assert_eq!(decoded, original); + } + + /// The dispatcher distinguishes "unknown command" from other parse + /// errors by the error message prefix. This test pins that contract so + /// the CLI's user-facing error stays correct as serde_json's default + /// wording evolves. + #[test] + fn decode_request_unknown_command_has_prefixed_error() { + let raw = br#"{"command":"unknown-thing"}"#; + let err = decode_request(raw).expect_err("unknown variant must fail"); + assert!( + err.contains("Unknown command"), + "unknown-command errors must start with 'Unknown command'; got: {err}", + ); + assert!( + err.contains("unknown-thing"), + "error should mention the offending variant; got: {err}", + ); + } + + /// Malformed JSON is a different failure than an unknown command and + /// must not get rewritten with the "Unknown command" prefix — otherwise + /// the CLI would mis-classify it. Classification goes through + /// `serde_json::error::Category::Syntax`, which we surface as + /// "Malformed admin request: ...". + #[test] + fn decode_request_malformed_json_uses_malformed_prefix() { + let raw = b"not json at all"; + let err = decode_request(raw).expect_err("non-json must fail"); + assert!( + !err.starts_with("Unknown command"), + "syntax errors must not get the unknown-command prefix; got: {err}", + ); + assert!( + err.starts_with("Malformed admin request"), + "syntax errors must be tagged 'Malformed admin request'; got: {err}", + ); + } + + /// A truncated JSON object (valid-so-far but cut short) classifies as + /// `Category::Eof`, which shares the "Malformed admin request" prefix + /// with `Category::Syntax`. + #[test] + fn decode_request_truncated_json_uses_malformed_prefix() { + let raw = br#"{"command":"#; + let err = decode_request(raw).expect_err("truncated json must fail"); + assert!( + err.starts_with("Malformed admin request"), + "EOF mid-frame must be tagged 'Malformed admin request'; got: {err}", + ); + } + + /// Data-category errors include not just unknown discriminators (already + /// covered by `decode_request_unknown_command_has_prefixed_error`) but + /// also requests that are structurally valid JSON yet not a request — + /// the most common being a missing `command` tag. All of these surface + /// to the CLI as "Unknown command", which is the operator-facing + /// phrasing for "I cannot service this request". + #[test] + fn decode_request_missing_tag_uses_unknown_command_prefix() { + // No discriminator at all is a `Category::Data` error in serde_json + // ("missing field `command`"). Previously this would have leaked the + // raw serde message; the new mapping surfaces it as a command issue + // so the CLI's operator-facing wording stays consistent. + let raw = b"{}"; + let err = decode_request(raw).expect_err("missing tag must fail"); + assert!( + err.starts_with("Unknown command"), + "missing discriminator must use the 'Unknown command' prefix; got: {err}", + ); + } + + /// `encode_response` produces bytes that `serde_json::from_slice` + /// decodes back into an equal value. Guards the discriminator key + /// (`status`) and the snake_case naming against accidental changes. + #[test] + fn encode_response_round_trip() { + let resp = AdminResponse::Err { + error: "Command not implemented in phase 1".to_string(), + }; + let encoded = encode_response(&resp).expect("encode"); + let decoded: AdminResponse = serde_json::from_slice(&encoded).expect("decode"); + assert_eq!(decoded, resp); + + // The wire shape uses `status` as the tag — pin it explicitly so a + // refactor that renames the tag doesn't silently break clients. + let as_value: serde_json::Value = + serde_json::from_slice(&encoded).expect("decode as value"); + assert_eq!(as_value["status"], "err"); + } + + /// Bound the maximum frame size so the codec rejects oversize payloads + /// rather than allocating unboundedly. The exact byte limit is a + /// policy choice, but it has to exist. + #[test] + fn codec_rejects_oversize_frames() { + let mut codec = codec(); + let mut buf = BytesMut::new(); + let too_big = vec![b'a'; MAX_FRAME_BYTES + 1]; + let err = codec + .encode(too_big.into(), &mut buf) + .expect_err("oversize encode must fail"); + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("max") || msg.contains("frame"), + "oversize error should mention the limit; got: {msg}", + ); + } +} diff --git a/src/admin/request.rs b/src/admin/request.rs new file mode 100644 index 0000000..91df531 --- /dev/null +++ b/src/admin/request.rs @@ -0,0 +1,279 @@ +//! Admin request schema. +//! +//! Phase 2 wired the two read-only commands (`Status`, `Version`); Phase 3 +//! adds the log-level commands; Phase 5 adds the exports and config-show +//! commands. Later phases add the rest (Phase 7 metrics, Phase 8 shutdown). +//! The variant set is kept in sync with the v1 surface in issue #25 so +//! later phases can land handlers without touching the protocol again. + +use serde::{Deserialize, Serialize}; + +use crate::config::ExportConfig; +use crate::fsal::multi_export::ExportSelector; + +/// An admin command sent by `arcticwolfctl` to the daemon. +/// +/// Serialized as a JSON object whose `command` discriminator selects a +/// variant: `{"command": "status"}`, `{"command": "log-level-get"}`, etc. +/// `kebab-case` is used because the CLI itself uses kebab-cased subcommand +/// names — keeping the wire shape and the CLI in lockstep avoids a mental +/// translation when debugging via `arcticwolfctl raw`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "command", rename_all = "kebab-case")] +pub enum AdminRequest { + /// `arcticwolfctl status` — daemon health snapshot (uptime, ports, + /// log level, export count). + Status, + /// `arcticwolfctl version` — daemon build/version metadata + /// (`CARGO_PKG_VERSION`, git commit, rustc version, build profile). + Version, + /// `arcticwolfctl log-level get` — report the tracing filter directive + /// currently in effect. + LogLevelGet, + /// `arcticwolfctl log-level set ` — swap the live tracing + /// filter. An unrecognized level is rejected and the previously active + /// filter is left intact. + LogLevelSet { level: String }, + /// `arcticwolfctl exports list` — snapshot the live export set + /// (including retired uids) for operator inspection. + ExportsList, + /// `arcticwolfctl exports add` — install a new export at runtime. + /// With `dry_run = true` the daemon only validates and never mutates + /// state, so the CLI can flag obvious problems before committing. + ExportsAdd { + config: ExportConfig, + #[serde(default)] + dry_run: bool, + }, + /// `arcticwolfctl exports remove` — retire an export. The uid moves + /// into `retired_uids` so an old client handle can't be silently + /// reassigned to a freshly-added export. + ExportsRemove { + selector: ExportSelector, + #[serde(default)] + dry_run: bool, + }, + /// `arcticwolfctl exports update` — mutate a live export's fields. + /// v1 only mutates `read_only`; future fields land as additional + /// optional members on this variant. + ExportsUpdate { + selector: ExportSelector, + read_only: bool, + #[serde(default)] + dry_run: bool, + }, + /// `arcticwolfctl config show` — dump the daemon's startup + /// configuration as JSON. Phase 5 ships it unredacted; sensitive-field + /// redaction is a follow-up when non-local FSALs land. + ConfigShow, + /// `arcticwolfctl metrics` — a JSON snapshot of operational counters + /// (server-wide RPC totals, per-NFS-op counts, per-export I/O, per-admin- + /// command counts). In-memory only; resets on daemon restart. No HTTP + /// scrape endpoint (deferred per the Phase 7 spec). + Metrics, +} + +/// Every command tag the daemon recognizes, used to bound the admin +/// `by_command` metrics map (see [`crate::metrics::AdminMetrics`]). +/// +/// This is the wire tag of every [`AdminRequest`] variant *plus* the three +/// synthetic tags the admin server emits for non-dispatchable frames +/// (``, ``, ``, documented in +/// [`crate::admin::audit`]). Anything a client sends that is not in this list +/// is bucketed under [`UNKNOWN_ADMIN_COMMAND_TAG`] rather than allocating a +/// fresh map entry. +/// +/// Convention burden: this array must be kept in sync with the variants of +/// [`AdminRequest`] by hand — there is no compile-time link. A missed entry +/// is not a panic, it only means that command lands in the +/// `` bucket instead of its own. The +/// `known_admin_command_tags_cover_every_variant` test guards the wire-tag +/// half of the list against drift. +pub const KNOWN_ADMIN_COMMAND_TAGS: &[&str] = &[ + // Wire tags — one per `AdminRequest` variant (kebab-case of the variant). + "status", + "version", + "log-level-get", + "log-level-set", + "exports-list", + "exports-add", + "exports-remove", + "exports-update", + "config-show", + "metrics", + // Synthetic tags emitted by the admin server (see `crate::admin::audit`). + "", + "", + "", +]; + +/// Catch-all bucket for any client-supplied tag not in +/// [`KNOWN_ADMIN_COMMAND_TAGS`]. Distinct from the synthetic `` +/// (valid JSON object, no `command` field): `` means the +/// frame *named* a command the daemon doesn't recognize — an operator typo or +/// version skew — so the two can be told apart in the metrics snapshot. +pub const UNKNOWN_ADMIN_COMMAND_TAG: &str = ""; + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::BackendConfig; + use crate::fsal::multi_export::ExportSelector; + use serde_json::{Value, json}; + use std::path::PathBuf; + + /// Pin the `command` tag for every variant of `AdminRequest`. The CLI + /// derives the kebab-case names from the variant identifiers, so a + /// rename here is a wire-incompatible change — this test catches it. + #[test] + fn admin_request_command_tag_format() { + fn tag(req: AdminRequest) -> String { + serde_json::to_value(req).unwrap()["command"] + .as_str() + .expect("command tag must be a string") + .to_string() + } + + assert_eq!(tag(AdminRequest::Status), "status"); + assert_eq!(tag(AdminRequest::Version), "version"); + assert_eq!(tag(AdminRequest::LogLevelGet), "log-level-get"); + assert_eq!( + tag(AdminRequest::LogLevelSet { + level: "info".to_string() + }), + "log-level-set" + ); + assert_eq!(tag(AdminRequest::ExportsList), "exports-list"); + assert_eq!( + tag(AdminRequest::ExportsAdd { + config: ExportConfig { + name: "/data".to_string(), + uid: 1, + read_only: false, + backend: BackendConfig::Local { + path: PathBuf::from("/srv/data"), + }, + }, + dry_run: false, + }), + "exports-add" + ); + assert_eq!( + tag(AdminRequest::ExportsRemove { + selector: ExportSelector::Name("/data".to_string()), + dry_run: false, + }), + "exports-remove" + ); + assert_eq!( + tag(AdminRequest::ExportsUpdate { + selector: ExportSelector::Uid(1), + read_only: true, + dry_run: false, + }), + "exports-update" + ); + assert_eq!(tag(AdminRequest::ConfigShow), "config-show"); + assert_eq!(tag(AdminRequest::Metrics), "metrics"); + } + + /// Every `AdminRequest` wire tag must appear in + /// [`KNOWN_ADMIN_COMMAND_TAGS`], otherwise that command would be counted + /// under the `` bucket instead of its own. This guards + /// the hand-maintained list against a new variant being added without a + /// matching entry. + #[test] + fn known_admin_command_tags_cover_every_variant() { + fn tag(req: AdminRequest) -> String { + serde_json::to_value(req).unwrap()["command"] + .as_str() + .expect("command tag must be a string") + .to_string() + } + + // One representative value per variant. Adding a variant without + // extending this match is a compile error (no `_` arm), which in turn + // forces the author past this test. + let variants = [ + AdminRequest::Status, + AdminRequest::Version, + AdminRequest::LogLevelGet, + AdminRequest::LogLevelSet { + level: "info".to_string(), + }, + AdminRequest::ExportsList, + AdminRequest::ExportsAdd { + config: ExportConfig { + name: "/data".to_string(), + uid: 1, + read_only: false, + backend: BackendConfig::Local { + path: PathBuf::from("/srv/data"), + }, + }, + dry_run: false, + }, + AdminRequest::ExportsRemove { + selector: ExportSelector::Uid(1), + dry_run: false, + }, + AdminRequest::ExportsUpdate { + selector: ExportSelector::Uid(1), + read_only: true, + dry_run: false, + }, + AdminRequest::ConfigShow, + AdminRequest::Metrics, + ]; + + for variant in variants { + let t = tag(variant); + assert!( + KNOWN_ADMIN_COMMAND_TAGS.contains(&t.as_str()), + "wire tag {t:?} is missing from KNOWN_ADMIN_COMMAND_TAGS", + ); + } + + // The exhaustive match below makes the omission of a variant a + // compile error; this asserts the catch-all isn't accidentally listed + // as a known tag (it must stay distinct). + assert!(!KNOWN_ADMIN_COMMAND_TAGS.contains(&UNKNOWN_ADMIN_COMMAND_TAG)); + } + + /// Exhaustive match: adding an `AdminRequest` variant fails to compile + /// here until the author accounts for it, nudging them to update + /// [`KNOWN_ADMIN_COMMAND_TAGS`] and the variant list above. + #[test] + fn admin_request_variants_are_accounted_for() { + fn assert_accounted(req: &AdminRequest) { + match req { + AdminRequest::Status + | AdminRequest::Version + | AdminRequest::LogLevelGet + | AdminRequest::LogLevelSet { .. } + | AdminRequest::ExportsList + | AdminRequest::ExportsAdd { .. } + | AdminRequest::ExportsRemove { .. } + | AdminRequest::ExportsUpdate { .. } + | AdminRequest::ConfigShow + | AdminRequest::Metrics => {} + } + } + assert_accounted(&AdminRequest::Status); + } + + /// `ExportsRemove` and `ExportsUpdate` carry `ExportSelector` as a + /// nested object whose shape the CLI builds explicitly. Lock the + /// embedded form so a future serde-attribute change can't silently + /// flatten or rename it. + #[test] + fn exports_remove_embeds_selector_as_object() { + let v: Value = serde_json::to_value(AdminRequest::ExportsRemove { + selector: ExportSelector::Name("/data".to_string()), + dry_run: false, + }) + .unwrap(); + assert_eq!(v["selector"], json!({ "name": "/data" })); + assert_eq!(v["dry_run"], false); + } +} diff --git a/src/admin/response.rs b/src/admin/response.rs new file mode 100644 index 0000000..41a3ac9 --- /dev/null +++ b/src/admin/response.rs @@ -0,0 +1,26 @@ +//! Admin response schema. +//! +//! The response is a tagged union with two arms: a success carrier with an +//! opaque JSON `data` payload, and an error carrier with a human-readable +//! message. Each command picks the shape of its own `data` (Phase 2+ +//! defines those). The error path is shared so transport-level failures +//! (codec errors, unknown commands) and handler-level failures use the +//! same wire form. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum AdminResponse { + Ok { data: serde_json::Value }, + Err { error: String }, +} + +impl AdminResponse { + /// Construct an error response with the given message. + pub fn error(message: impl Into) -> Self { + Self::Err { + error: message.into(), + } + } +} diff --git a/src/admin/server.rs b/src/admin/server.rs new file mode 100644 index 0000000..4bc8a2c --- /dev/null +++ b/src/admin/server.rs @@ -0,0 +1,806 @@ +//! Unix-domain-socket admin server. +//! +//! `serve()` is the entry point invoked from `main.rs`. It owns the +//! listener (already bound and chmod'd by `bind_admin_socket()`) and +//! spawns one task per accepted connection. Each connection is a framed +//! JSON request/response stream; for Phase 1 every request is answered +//! with a not-implemented error. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use bytes::Bytes; +use futures_util::{SinkExt, StreamExt}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use super::audit::{AuditEvent, PeerCreds, peer_creds, rfc3339_micros_now}; +use super::commands; +use super::context::AdminContext; +use super::protocol::{decode_request, encode_response, framed}; +use super::request::AdminRequest; +use super::response::AdminResponse; +use crate::shutdown::{InFlight, InFlightGuard}; + +/// Upper bound for the post-codec-error courtesy response. After a frame +/// decode failure the socket is in an unknown state (e.g. oversize payload +/// left half-read), and the client may not be draining its read half — we +/// MUST not let a blocked `send` keep the connection task alive forever. +const ERROR_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Bind the admin Unix domain socket described by `socket_path` and apply +/// `socket_mode` via `chmod(2)`. Fails fast if the parent directory does +/// not exist or is not writable so the operator sees a clear startup +/// error rather than a vague "bind failed" deep in the listener. +pub fn bind_admin_socket(socket_path: &Path, socket_mode: u32) -> Result { + let parent = socket_path.parent().ok_or_else(|| { + anyhow::anyhow!( + "admin socket_path {} has no parent directory", + socket_path.display() + ) + })?; + + if !parent.as_os_str().is_empty() && !parent.exists() { + anyhow::bail!( + "admin socket parent directory {} does not exist (refusing to start with [admin] enabled = true)", + parent.display(), + ); + } + + // Probe writability by attempting to create a tempfile-like path next + // to the target socket. This catches the common "directory exists but + // daemon user lacks write permission" case before bind() returns a + // less informative EACCES. + if !parent.as_os_str().is_empty() { + let writability_probe = + parent.join(format!(".arcticwolf-admin-probe-{}", std::process::id())); + match std::fs::File::create(&writability_probe) { + Ok(_) => { + let _ = std::fs::remove_file(&writability_probe); + } + Err(err) => { + anyhow::bail!( + "admin socket parent directory {} is not writable: {} \ + (refusing to start with [admin] enabled = true)", + parent.display(), + err, + ); + } + } + } + + // Remove a stale socket file from a previous run, if any. We only + // unlink a path of file-type socket; refusing to remove regular files + // protects an operator who accidentally configured `socket_path` to + // an existing data file. + if socket_path.exists() { + let metadata = std::fs::symlink_metadata(socket_path).with_context(|| { + format!( + "failed to stat existing admin socket path {}", + socket_path.display() + ) + })?; + use std::os::unix::fs::FileTypeExt; + if metadata.file_type().is_socket() { + std::fs::remove_file(socket_path).with_context(|| { + format!( + "failed to remove stale admin socket {}", + socket_path.display() + ) + })?; + } else { + anyhow::bail!( + "admin socket_path {} already exists and is not a socket; refusing to overwrite", + socket_path.display(), + ); + } + } + + // Close the bind→chmod race window. `UnixListener::bind` creates the + // socket file using the process umask, which on a typical system leaves + // a brief window where the socket is reachable by other local users + // before `set_permissions` tightens it down to `socket_mode`. Saving the + // umask, narrowing it to 0o077 for just the bind, and restoring it + // afterwards bounds that window to a no-op for non-owner principals. + // The subsequent `set_permissions` is still required: umask only + // *removes* bits, so any non-default `socket_mode` (e.g. 0o640 with + // group access) still needs an explicit chmod to set the exact mode. + // + // SAFETY: `umask(2)` is a process-global syscall. We capture the prior + // value and restore it immediately after `bind` returns, so any + // concurrent thread observes the relaxed mask only for the duration of + // a single `bind` call. There is no other safe wrapper in `libc`/`nix` + // for this; the operation is inherently `unsafe` because it mutates + // process state visible to all threads. + let saved_umask = unsafe { libc::umask(0o077) }; + let listener_result = UnixListener::bind(socket_path); + unsafe { + libc::umask(saved_umask); + } + let listener = listener_result + .with_context(|| format!("failed to bind admin socket at {}", socket_path.display()))?; + + use std::os::unix::fs::PermissionsExt; + let permissions = std::fs::Permissions::from_mode(socket_mode); + std::fs::set_permissions(socket_path, permissions).with_context(|| { + format!( + "failed to chmod admin socket {} to {:o}", + socket_path.display(), + socket_mode, + ) + })?; + + info!( + "admin socket bound at {} (mode {:o})", + socket_path.display(), + socket_mode, + ); + Ok(listener) +} + +/// Best-effort RAII unlink of the admin socket file. Removing the socket on +/// *every* exit from [`serve_with_shutdown`] — clean drain, an `accept()` +/// error propagated via `?`, or a panic — is what stops a failed accept from +/// leaking the socket file. `main.rs` has its own fallback unlink for the +/// timeout path, but tests that call `serve_with_shutdown` directly have no +/// such fallback and relied on this guard. (Fix 15) +struct SocketCleanup<'a> { + path: &'a Path, +} + +impl Drop for SocketCleanup<'_> { + fn drop(&mut self) { + let _ = std::fs::remove_file(self.path); + } +} + +/// Run the admin server until `shutdown` is cancelled (issue #25 Phase 8). +/// +/// The accept loop races `shutdown.cancelled()` against the next +/// `accept()`: a cancellation breaks the loop so no new connections are +/// taken, while connections already accepted keep running. Per-connection +/// handlers are tracked in a [`JoinSet`] and drained after the loop breaks, +/// so an admin command in flight when the signal arrives completes rather +/// than being cut off. The whole-daemon drain timeout lives in `main.rs`, so +/// this await is intentionally unbounded here. +/// +/// Known limitation (follow-up to #25): a per-connection handler parked in +/// [`StreamExt::next`] waiting for the client's next frame does not race the +/// cancellation token, so a *connected but idle* admin client blocks the drain +/// until the whole-daemon timeout ceiling. Active connections drain promptly; +/// only silent-idle ones hit the ceiling. Nudging idle connections on cancel +/// is tracked as a follow-up. +/// +/// On any exit the socket file is unlinked by the [`SocketCleanup`] guard. +/// `main.rs` also unlinks it as a fallback for the timeout path (both use a +/// best-effort remove, so the double-unlink is harmless). +/// +/// `in_flight` is the process-wide handler counter shared by all four servers; +/// each spawned handler holds an [`InFlightGuard`] so the drain-timeout warn +/// can report the aggregate still-running count. (Fix 9) +pub async fn serve_with_shutdown( + listener: UnixListener, + socket_path: PathBuf, + context: Arc, + shutdown: CancellationToken, + in_flight: Arc, +) -> Result<()> { + info!("admin server accepting on {}", socket_path.display()); + // Arm the unconditional socket-file cleanup before the first fallible + // `accept()` so an `accept` error (propagated via `?`) can't leak it. (Fix 15) + let _cleanup = SocketCleanup { path: &socket_path }; + let mut tasks: JoinSet<()> = JoinSet::new(); + loop { + tokio::select! { + _ = shutdown.cancelled() => break, + accept_result = listener.accept() => { + let (socket, _) = accept_result + .with_context(|| format!("admin accept failed on {}", socket_path.display()))?; + debug!("admin connection accepted"); + + // Extract `SO_PEERCRED` once per connection. The triple is + // fixed for the lifetime of the socket (the kernel snapshots + // it at connect time), so we don't re-query on every request. + let peer = peer_creds(&socket); + + let context = context.clone(); + // Count this handler in the shared in-flight gauge; the guard + // moves into the task and decrements on completion (including + // panic), so the gauge always settles. (Fix 9) + let guard = InFlightGuard::new(in_flight.clone()); + tasks.spawn(async move { + let _in_flight = guard; + if let Err(err) = handle_connection(socket, context, peer).await { + warn!("admin connection error: {err:#}"); + } + }); + } + } + } + + info!( + in_flight = tasks.len(), + "admin server shutting down, draining connections" + ); + while tasks.join_next().await.is_some() {} + + // Close the listener fd so the path is no longer bound, then drop the + // cleanup guard to unlink the socket file at a defined point (rather than + // at the end of scope) so the log line below reflects reality. The guard + // also runs on the early `?`/panic paths above, where this explicit drop + // is never reached. + drop(listener); + drop(_cleanup); + info!("admin socket {} removed", socket_path.display()); + Ok(()) +} + +async fn handle_connection( + socket: UnixStream, + context: Arc, + peer: Option, +) -> Result<()> { + let mut framed = framed(socket); + + // TODO(follow-up to #25): nudge idle connections on cancel. This loop + // parks in `framed.next()` waiting for the client's next frame and does + // not race the shutdown token, so an idle admin client holds its handler + // (and the drain) open until the whole-daemon timeout ceiling. + while let Some(frame) = framed.next().await { + let bytes = match frame { + Ok(bytes) => bytes, + Err(err) => { + // Codec-level errors (oversize frame, eof mid-frame). Record + // a synthetic audit event *before* anything else, so an + // operator who triggers a frame error (e.g. an oversize + // payload) still leaves an audit trail even though the + // request never decoded. `duration_ms` is 0: there is no + // per-request decode→dispatch span to measure here. + context.audit.record(AuditEvent { + ts: rfc3339_micros_now(), + peer, + command: "".to_string(), + request: serde_json::Value::Null, + result: "err", + error: Some(format!("{err:#}")), + duration_ms: 0, + }); + + // A frame error is still an admin request the server + // accepted (and rejected). Count it under the same synthetic + // tag the audit uses so the two surfaces agree. + context.metrics.admin.record(""); + + // Try to send a structured error back, then close. The write + // half may be wedged if the client isn't reading (e.g. it + // sent an oversize frame and then walked away), so bound + // the courtesy send by `ERROR_RESPONSE_TIMEOUT` rather + // than letting the spawned connection task hang forever. + let response = AdminResponse::error(format!("frame error: {err}")); + match tokio::time::timeout( + ERROR_RESPONSE_TIMEOUT, + send_response(&mut framed, &response), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(send_err)) => { + debug!("admin: failed to deliver frame-error response: {send_err:#}"); + } + Err(_) => { + warn!( + "admin: timed out after {:?} sending frame-error response; closing connection", + ERROR_RESPONSE_TIMEOUT, + ); + } + } + return Err(err.into()); + } + }; + + // Phase 6: build the audit event around dispatch and hand it to + // the writer *before* the response is encoded. `record` is + // fire-and-forget — the call returns immediately. + let start = Instant::now(); + let (command_tag, request_payload) = extract_command_and_request(&bytes); + // Count every accepted request under the same tag the audit records, + // before dispatch — `metrics` itself counts, matching the spec. + context.metrics.admin.record(&command_tag); + let response = dispatch(&context, &bytes); + let duration = start.elapsed(); + let event = build_audit_event( + command_tag, + request_payload, + &response, + peer, + duration.as_millis(), + ); + context.audit.record(event); + + send_response(&mut framed, &response).await?; + } + Ok(()) +} + +/// Peel the `command` discriminator out of the wire frame and return both +/// the tag (`"exports-add"`, `""`, …) and the request body +/// minus the tag — that's what the audit `request` field carries. +/// +/// We don't reuse [`crate::admin::protocol::decode_request`] for this: the +/// audit schema wants the payload "verbatim", and parsing into the typed +/// `AdminRequest` enum would drop omitted-default fields and renormalize +/// member order. Parsing as a free-form `Value` keeps the wire shape. +fn extract_command_and_request(bytes: &[u8]) -> (String, serde_json::Value) { + match serde_json::from_slice::(bytes) { + Ok(serde_json::Value::Object(mut map)) => { + let tag = map + .remove("command") + .and_then(|v| match v { + serde_json::Value::String(s) => Some(s), + _ => None, + }) + .unwrap_or_else(|| "".to_string()); + (tag, serde_json::Value::Object(map)) + } + Ok(other) => ("".to_string(), other), + Err(_) => ("".to_string(), serde_json::Value::Null), + } +} + +fn build_audit_event( + command: String, + request: serde_json::Value, + response: &AdminResponse, + peer: Option, + duration_ms: u128, +) -> AuditEvent { + let (result, error) = match response { + AdminResponse::Ok { .. } => ("ok", None), + AdminResponse::Err { error } => ("err", Some(error.clone())), + }; + AuditEvent { + ts: rfc3339_micros_now(), + peer, + command, + request, + result, + error, + duration_ms, + } +} + +async fn send_response( + framed: &mut tokio_util::codec::Framed, + response: &AdminResponse, +) -> Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let encoded: Bytes = encode_response(response).context("encoding admin response")?; + framed.send(encoded).await.context("sending admin response") +} + +/// Decode an admin request frame and route it to its handler. +/// +/// Handlers are synchronous — Phase 2's `status`/`version` only read +/// already-resolved context state, so no `.await` is needed on this path. +fn dispatch(context: &AdminContext, frame: &[u8]) -> AdminResponse { + let request = match decode_request(frame) { + Ok(request) => request, + // Includes the "Unknown command: ..." prefix the CLI keys on for + // its user-facing error. + Err(err) => return AdminResponse::error(err), + }; + debug!("admin: dispatching {request:?}"); + match request { + AdminRequest::Status => commands::status::handle(context), + AdminRequest::Version => commands::version::handle(), + AdminRequest::LogLevelGet => commands::log_level::handle_get(context), + AdminRequest::LogLevelSet { level } => commands::log_level::handle_set(context, &level), + AdminRequest::ExportsList => commands::exports_list::handle(context), + AdminRequest::ExportsAdd { config, dry_run } => { + commands::exports_add::handle(context, &config, dry_run) + } + AdminRequest::ExportsRemove { selector, dry_run } => { + commands::exports_remove::handle(context, &selector, dry_run) + } + AdminRequest::ExportsUpdate { + selector, + read_only, + dry_run, + } => commands::exports_update::handle(context, &selector, read_only, dry_run), + AdminRequest::ConfigShow => commands::config_show::handle(context), + AdminRequest::Metrics => commands::metrics::handle(context), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio_util::codec::{Framed, LengthDelimitedCodec}; + + fn check_dispatch(frame: &[u8]) -> AdminResponse { + let (context, _tmp, _log_guard) = AdminContext::for_test(); + dispatch(&context, frame) + } + + fn client_codec() -> LengthDelimitedCodec { + // Match the server's framing: 4-byte BE length prefix, 1 MiB cap. + // Kept inline (rather than calling into the protocol helper) so a + // future change to either side of the wire fails this test loudly. + LengthDelimitedCodec::builder() + .length_field_length(4) + .max_frame_length(1024 * 1024) + .new_codec() + } + + #[test] + fn dispatch_status_returns_ok_snapshot() { + // Phase 2: `status` is wired end to end and answers with an Ok + // carrier whose `data` is the health snapshot. + let payload = serde_json::to_vec(&AdminRequest::Status).unwrap(); + match check_dispatch(&payload) { + AdminResponse::Ok { data } => { + assert_eq!(data["daemon_version"], env!("CARGO_PKG_VERSION")); + assert!(data.get("export_count").is_some()); + } + AdminResponse::Err { error } => panic!("status must return Ok; got: {error}"), + } + } + + #[test] + fn dispatch_version_returns_ok_build_info() { + let payload = serde_json::to_vec(&AdminRequest::Version).unwrap(); + match check_dispatch(&payload) { + AdminResponse::Ok { data } => { + assert_eq!(data["daemon_version"], env!("CARGO_PKG_VERSION")); + assert!(data["build_profile"].is_string()); + } + AdminResponse::Err { error } => panic!("version must return Ok; got: {error}"), + } + } + + #[test] + fn dispatch_unknown_command_returns_unknown_command_error() { + // The server layer is responsible for surfacing "Unknown command: + // foo" through `AdminResponse::Err` (not for example by closing + // the socket). Pins the AC from issue #25's "unknown command error + // shape" test bullet. + let frame = br#"{"command":"banana"}"#; + let response = check_dispatch(frame); + match response { + AdminResponse::Err { error } => { + assert!( + error.contains("Unknown command"), + "unknown commands must surface as 'Unknown command: ...'; got: {error}", + ); + } + AdminResponse::Ok { .. } => panic!("unknown command must not return Ok"), + } + } + + #[test] + fn bind_admin_socket_fails_fast_on_missing_parent_dir() { + // Acceptance criterion from issue #25: the daemon must refuse to + // start with `[admin] enabled = true` if the socket's parent + // directory does not exist. `bind_admin_socket` is the choke + // point — `main.rs` propagates this error via `?` so verifying + // the failure here pins the AC end-to-end without needing to + // boot the full daemon. + let socket_path = std::path::PathBuf::from("/does/not/exist/admin.sock"); + let err = bind_admin_socket(&socket_path, 0o600) + .expect_err("missing parent dir must error") + .to_string(); + assert!( + err.contains("does not exist"), + "error should mention the missing parent dir; got: {err}", + ); + } + + #[tokio::test] + async fn bind_admin_socket_applies_mode_even_under_permissive_umask() { + // The bind→chmod sequence has a microsecond race window: between + // `UnixListener::bind` (which creates the socket file using the + // process umask) and `set_permissions` (which tightens it down), + // any other local process could `connect(2)` to it. To shrink that + // window we tighten the umask to 0o077 across the bind. This test + // pins the operator-facing invariant: with an artificially + // permissive umask (0o000 — every bit allowed) the resulting + // socket must still end up at the requested mode rather than the + // umask-derived `0o777`. + // + // SAFETY: `umask` is process-global; `cargo test` may run other + // tests in parallel threads. We save the prior value and restore + // it on the way out. Concurrent calls in `bind_admin_socket` from + // other test threads also save+restore around their own bind, so + // the only observable effect on this test is at most a brief + // intermediate change; the post-bind `chmod` ensures the final + // mode is precise regardless. + use std::os::unix::fs::PermissionsExt; + let saved_umask = unsafe { libc::umask(0o000) }; + let dir = tempfile::tempdir().expect("tempdir"); + let socket_path = dir.path().join("admin.sock"); + let bind_result = bind_admin_socket(&socket_path, 0o600); + let mode = std::fs::metadata(&socket_path) + .expect("stat socket") + .permissions() + .mode() + & 0o777; + unsafe { + libc::umask(saved_umask); + } + let _listener = bind_result.expect("bind admin sock"); + assert_eq!( + mode, 0o600, + "socket mode must match `socket_mode`, not be derived from the relaxed process umask; got {mode:o}", + ); + } + + #[tokio::test] + async fn bind_admin_socket_sets_requested_mode() { + // The chmod step is what gives operators the `0o600` permission + // they configured. Round-trip the mode through `stat(2)` so a + // future refactor (eg. moving to `bind_with_mode`) can't silently + // drop the chmod. `bind_admin_socket` is itself synchronous but + // it calls `tokio::net::UnixListener::bind` which requires an + // active Tokio reactor — `#[tokio::test]` supplies one. + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().expect("tempdir"); + let socket_path = dir.path().join("admin.sock"); + let _listener = bind_admin_socket(&socket_path, 0o600).expect("bind admin sock"); + let mode = std::fs::metadata(&socket_path) + .expect("stat socket") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o600, + "bind_admin_socket must apply the requested mode; got {mode:o}", + ); + } + + #[tokio::test] + async fn bind_admin_socket_unlinks_stale_socket() { + // A previous daemon process may have exited without unlinking its + // socket file (kill -9, container OOM, etc.). The next start must + // recover by removing the stale entry and binding fresh, otherwise + // operators would have to manually `rm` the socket after every + // unclean shutdown. + let dir = tempfile::tempdir().expect("tempdir"); + let socket_path = dir.path().join("admin.sock"); + let stale = UnixListener::bind(&socket_path).expect("create stale socket"); + // Drop the listener without `remove_file` — same shape as a previous + // run that died before its Drop ran. + drop(stale); + assert!( + socket_path.exists(), + "test precondition: stale socket file must remain after drop", + ); + let _listener = bind_admin_socket(&socket_path, 0o600) + .expect("bind_admin_socket must recover from a stale socket file"); + } + + #[tokio::test] + async fn bind_admin_socket_refuses_non_socket_path() { + // If the operator typos `socket_path` to point at an existing data + // file (e.g. `/etc/passwd`, or some innocent file under + // `/run/arcticwolf/`), the daemon MUST refuse rather than unlink + // and overwrite. The branch in `bind_admin_socket` that gates the + // unlink on `file_type().is_socket()` is the guard for this; this + // test pins it. + let dir = tempfile::tempdir().expect("tempdir"); + let socket_path = dir.path().join("not-actually-a-socket"); + std::fs::write(&socket_path, b"hello").expect("create regular file"); + let err = bind_admin_socket(&socket_path, 0o600) + .expect_err("regular file at socket_path must error") + .to_string(); + assert!( + err.contains("not a socket"), + "error should explain why the path can't be reused; got: {err}", + ); + // Critical: the daemon must NOT have removed the operator's file. + let contents = std::fs::read(&socket_path).expect("file must still exist"); + assert_eq!( + contents, b"hello", + "daemon must leave the existing non-socket file untouched", + ); + } + + /// End-to-end wire test: bind a socket in a tempdir, connect a client, + /// send a length-prefixed `AdminRequest::Status` frame, and assert the + /// server replies with an Ok `AdminResponse` carrying the snapshot. + /// Exercises the same code path `main.rs` uses, just over a tempdir + /// socket instead of `/run/arcticwolf/admin.sock`. + #[tokio::test] + async fn end_to_end_round_trip_through_unix_socket() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket_path = dir.path().join("admin.sock"); + + let listener = bind_admin_socket(&socket_path, 0o600).expect("bind admin sock"); + let (context, _export_tmp, _log_guard) = AdminContext::for_test(); + let socket_path_for_serve = socket_path.clone(); + let token = CancellationToken::new(); + let server = tokio::spawn(serve_with_shutdown( + listener, + socket_path_for_serve, + context, + token.clone(), + Arc::new(InFlight::new()), + )); + + let stream = tokio::net::UnixStream::connect(&socket_path) + .await + .expect("connect to admin socket"); + let mut framed: Framed = + Framed::new(stream, client_codec()); + + let payload = serde_json::to_vec(&AdminRequest::Status).expect("serialize"); + framed + .send(bytes::Bytes::from(payload)) + .await + .expect("send request"); + + let frame = tokio::time::timeout(Duration::from_secs(2), framed.next()) + .await + .expect("server replied within 2s") + .expect("server produced a frame") + .expect("frame is well-formed"); + let response: AdminResponse = serde_json::from_slice(&frame).expect("response decodes"); + match response { + AdminResponse::Ok { data } => { + assert_eq!(data["daemon_version"], env!("CARGO_PKG_VERSION")); + } + AdminResponse::Err { error } => { + panic!("phase 2 server must answer status with Ok; got: {error}") + } + } + + // Close the client so its handler task ends, then cancel and await a + // clean drain — the representative shutdown path (Fix 18). + drop(framed); + token.cancel(); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("serve drains within 5s") + .expect("serve task joins") + .expect("serve returns Ok"); + } + + /// The RAII `SocketCleanup` guard is what makes `serve_with_shutdown` + /// unlink the socket file on *every* exit — including the `accept()`-error + /// `?` return that, before Fix 15, leaked the file (tests have no + /// `main.rs` fallback unlink). Rather than fight tokio's readiness-based + /// accept loop to synthesize that error path, we pin the guard's contract + /// directly: it removes the file on a normal drop. + #[test] + fn socket_cleanup_guard_removes_file_on_drop() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("admin.sock"); + std::fs::write(&path, b"").expect("create placeholder socket file"); + assert!(path.exists(), "precondition: file present"); + { + let _guard = SocketCleanup { + path: path.as_path(), + }; + } // guard drops here + assert!( + !path.exists(), + "SocketCleanup must remove the socket file when it drops (Fix 15)", + ); + } + + /// The guard must also fire on the panic-unwind path, since a panicking + /// connection handler must not leave the socket file behind. + #[test] + fn socket_cleanup_guard_runs_on_panic() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("admin.sock"); + std::fs::write(&path, b"").expect("create placeholder socket file"); + assert!(path.exists(), "precondition: file present"); + let path_for_closure = path.clone(); + let result = std::panic::catch_unwind(|| { + let _guard = SocketCleanup { + path: path_for_closure.as_path(), + }; + panic!("simulated handler panic"); + }); + assert!(result.is_err(), "the closure must have panicked"); + assert!( + !path.exists(), + "SocketCleanup must remove the socket file even on a panic unwind (Fix 15)", + ); + } + + /// A codec-level frame error must still leave an audit trail. We point + /// the context at a `FileAuditWriter` over a tempfile, connect a raw + /// client, and write a 4-byte length prefix declaring a ~4 GiB frame — + /// far past the server codec's 1 MiB cap, so the decoder errors on the + /// length field alone (without waiting for the body). The connection + /// then dies, but the synthetic `` event must have been + /// recorded first. + #[tokio::test] + async fn frame_error_records_synthetic_audit_event() { + use crate::admin::audit::FileAuditWriter; + use tokio::io::AsyncWriteExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let socket_path = dir.path().join("admin.sock"); + let audit_path = dir.path().join("audit.jsonl"); + + let listener = bind_admin_socket(&socket_path, 0o600).expect("bind admin sock"); + let audit = Arc::new(FileAuditWriter::open(&audit_path).expect("open audit writer")); + let (context, _export_tmp, _log_guard) = AdminContext::for_test_with_audit(audit); + let socket_path_for_serve = socket_path.clone(); + let token = CancellationToken::new(); + let server = tokio::spawn(serve_with_shutdown( + listener, + socket_path_for_serve, + context, + token.clone(), + Arc::new(InFlight::new()), + )); + + let mut stream = tokio::net::UnixStream::connect(&socket_path) + .await + .expect("connect to admin socket"); + stream + .write_all(&[0xFF, 0xFF, 0xFF, 0xFF]) + .await + .expect("write oversize length prefix"); + stream.flush().await.expect("flush"); + + let lines = wait_for_audit_lines(&audit_path, 1).await; + let value: serde_json::Value = + serde_json::from_str(&lines[0]).expect("audit line is valid JSON"); + assert_eq!(value["command"], ""); + assert_eq!(value["result"], "err"); + assert!( + value["error"].as_str().is_some(), + "frame-error event must carry an error string; got: {value}", + ); + assert!( + value["request"].is_null(), + "frame-error event request must be null; got: {value}", + ); + + // The frame-error handler has already returned (the server closed the + // connection), so cancel + await drains cleanly (Fix 18). + token.cancel(); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("serve drains within 5s") + .expect("serve task joins") + .expect("serve returns Ok"); + } + + /// Poll `path` until at least `min_lines` non-empty lines are present. + /// The `FileAuditWriter` drains on a `tokio::spawn` task we can't join, + /// so the test observes the file. Bounded by a 2s ceiling so a wedged + /// writer surfaces as a failure, not a hang. + async fn wait_for_audit_lines(path: &std::path::Path, min_lines: usize) -> Vec { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Ok(contents) = tokio::fs::read_to_string(path).await { + let lines: Vec = contents + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(); + if lines.len() >= min_lines { + return lines; + } + } + if Instant::now() >= deadline { + panic!( + "audit writer never produced {min_lines} lines at {}", + path.display() + ); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } +} diff --git a/src/bin/arcticwolfctl.rs b/src/bin/arcticwolfctl.rs new file mode 100644 index 0000000..a421b61 --- /dev/null +++ b/src/bin/arcticwolfctl.rs @@ -0,0 +1,1145 @@ +//! `arcticwolfctl` — operator CLI for the Arctic Wolf admin socket. +//! +//! Speaks the length-prefixed JSON admin protocol (issue #25). Phase 2 +//! shipped two read-only subcommands (`status`, `version`); Phase 3 added +//! the nested `log-level get` / `log-level set`; Phase 5 adds the +//! `exports list/add/remove/update` and `config show` subcommands. A +//! global `--socket` flag overrides the socket path and `--json` swaps +//! the human-readable summary for the raw response payload. + +use std::path::PathBuf; +use std::process::ExitCode; + +use arcticwolf::admin::{DEFAULT_ADMIN_SOCKET_PATH, client}; +use arcticwolf::config::{BackendConfig, ExportConfig}; +use arcticwolf::fsal::multi_export::ExportSelector; +use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; + +#[derive(Parser, Debug)] +#[command(name = "arcticwolfctl", about = "Arctic Wolf admin client", long_about = None)] +struct Cli { + /// Path to the daemon's admin Unix domain socket. + #[arg(long, global = true, default_value = DEFAULT_ADMIN_SOCKET_PATH)] + socket: PathBuf, + + /// Print the raw JSON payload instead of a human-readable summary. + #[arg(long, global = true)] + json: bool, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Show a daemon health snapshot (uptime, ports, exports). + Status, + /// Show client and daemon version information. + Version, + /// Inspect or change the daemon's live log level. + LogLevel { + #[command(subcommand)] + action: LogLevelAction, + }, + /// Inspect or mutate the live NFS export set. + Exports { + #[command(subcommand)] + action: ExportsAction, + }, + /// Inspect daemon configuration. + Config { + #[command(subcommand)] + action: ConfigAction, + }, + /// Show a JSON snapshot of runtime metrics (counters reset on restart). + Metrics, +} + +/// Sub-actions of the `log-level` command. +#[derive(Subcommand, Debug)] +enum LogLevelAction { + /// Print the tracing filter currently in effect. + Get, + /// Set the daemon's log level (error/warn/info/debug/trace/off). + Set { + /// The log level to apply. + level: String, + }, +} + +/// Sub-actions of the `exports` command. +#[derive(Subcommand, Debug)] +enum ExportsAction { + /// List the active exports (and retired uids in `--json`). + List, + /// Add a new export to the daemon. `--dry-run` validates only. + Add(ExportsAddArgs), + /// Remove an export. The uid is retired until daemon restart. + Remove(ExportsSelectorArgs), + /// Update an existing export. v1 only flips `read_only`. + Update(ExportsUpdateArgs), +} + +/// Sub-actions of the `config` command. +#[derive(Subcommand, Debug)] +enum ConfigAction { + /// Dump the daemon's startup configuration as JSON. + Show, +} + +/// FSAL backend kind accepted by `exports add`. v1 only ships `local`; +/// `clap::ValueEnum` rejects anything else at parse-time with +/// `possible values: local`, so the daemon never sees an unknown variant. +#[derive(Clone, Debug, ValueEnum)] +#[clap(rename_all = "lowercase")] +enum FsalKind { + Local, +} + +#[derive(Args, Debug)] +struct ExportsAddArgs { + /// Export path advertised to clients (e.g. `/data`). + #[arg(long)] + name: String, + /// Non-zero export uid (encoded in every file handle for this export). + #[arg(long)] + uid: u32, + /// FSAL backend kind. v1 only supports `local`; clap rejects others. + #[arg(long, value_enum)] + fsal: FsalKind, + /// For `--fsal local`: absolute path on the server's filesystem. + #[arg(long)] + path: Option, + /// Mark the new export read-only. + #[arg(long, default_value_t = false)] + read_only: bool, + /// Validate inputs against the live snapshot without mutating it. + #[arg(long, default_value_t = false)] + dry_run: bool, +} + +/// Selector arg pair shared by `remove`: exactly one of `--name`/`--uid`. +#[derive(Args, Debug)] +#[command(group( + ArgGroup::new("selector") + .required(true) + .multiple(false) + .args(["name", "uid"]), +))] +struct ExportsSelectorArgs { + /// Select the export by its advertised path (e.g. `/data`). + #[arg(long)] + name: Option, + /// Select the export by its uid. + #[arg(long)] + uid: Option, + /// Validate the selector against the live snapshot without mutating it. + #[arg(long, default_value_t = false)] + dry_run: bool, +} + +/// Selector + the v1-only `--read-only ` flag for `update`. +#[derive(Args, Debug)] +#[command(group( + ArgGroup::new("selector") + .required(true) + .multiple(false) + .args(["name", "uid"]), +))] +struct ExportsUpdateArgs { + /// Select the export by its advertised path (e.g. `/data`). + #[arg(long)] + name: Option, + /// Select the export by its uid. + #[arg(long)] + uid: Option, + /// New `read_only` value. v1 requires this — `update_export` rejects a + /// no-field-change call. Use `action = Set` so the flag takes an + /// explicit `true`/`false` value rather than acting as a presence + /// switch. + #[arg(long, action = clap::ArgAction::Set, required = true)] + read_only: bool, + /// Validate against the live snapshot without mutating it. + #[arg(long, default_value_t = false)] + dry_run: bool, +} + +#[tokio::main] +async fn main() -> ExitCode { + let cli = Cli::parse(); + // Match by reference so the nested `LogLevel { action }` bindings do not + // partially move `cli` out from under the `&cli` the run_* helpers take. + match &cli.command { + Command::Status => run_status(&cli).await, + Command::Version => run_version(&cli).await, + Command::LogLevel { action } => match action { + LogLevelAction::Get => run_log_level_get(&cli).await, + LogLevelAction::Set { level } => run_log_level_set(&cli, level).await, + }, + Command::Exports { action } => match action { + ExportsAction::List => run_exports_list(&cli).await, + ExportsAction::Add(args) => run_exports_add(&cli, args).await, + ExportsAction::Remove(args) => run_exports_remove(&cli, args).await, + ExportsAction::Update(args) => run_exports_update(&cli, args).await, + }, + Command::Config { action } => match action { + ConfigAction::Show => run_config_show(&cli).await, + }, + Command::Metrics => run_metrics(&cli).await, + } +} + +/// `arcticwolfctl status` — fetch and print the daemon health snapshot. +/// Any failure (connection refused, admin error) exits non-zero. +async fn run_status(cli: &Cli) -> ExitCode { + let data = match client::fetch_status(&cli.socket).await { + Ok(data) => data, + Err(err) => { + eprintln!("arcticwolfctl: status failed: {err:#}"); + return ExitCode::FAILURE; + } + }; + match client::render_status(&data, cli.json) { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("arcticwolfctl: {err:#}"); + ExitCode::FAILURE + } + } +} + +/// `arcticwolfctl version` — print the client's own version, then the +/// daemon's. The client version is always shown; if the daemon cannot be +/// reached the command still succeeds and reports it as unreachable. +async fn run_version(cli: &Cli) -> ExitCode { + let client_version = env!("CARGO_PKG_VERSION"); + let daemon = client::fetch_version(&cli.socket).await; + + if cli.json { + let daemon_value = match &daemon { + Ok(data) => { + // Add an explicit `reachable` discriminator so scripts can + // branch on `.daemon.reachable` instead of inferring + // reachability from which keys happen to be present. + let mut value = data.clone(); + if let serde_json::Value::Object(map) = &mut value { + map.insert("reachable".to_string(), serde_json::Value::Bool(true)); + } + value + } + Err(err) => serde_json::json!({ + "reachable": false, + "error": err.to_string(), + }), + }; + let combined = serde_json::json!({ + "client": { "version": client_version }, + "daemon": daemon_value, + }); + match serde_json::to_string_pretty(&combined) { + Ok(text) => { + println!("{text}"); + return ExitCode::SUCCESS; + } + Err(err) => { + eprintln!("arcticwolfctl: {err:#}"); + return ExitCode::FAILURE; + } + } + } + + println!("Client:"); + println!(" arcticwolfctl version: {client_version}"); + println!(); + println!("Daemon:"); + match daemon { + Ok(data) => match client::render_version(&data, false) { + Ok(text) => { + for line in text.lines() { + println!(" {line}"); + } + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("arcticwolfctl: {err:#}"); + ExitCode::FAILURE + } + }, + Err(err) => { + println!(" daemon unreachable: {err}"); + ExitCode::SUCCESS + } + } +} + +/// `arcticwolfctl log-level get` — print the daemon's active log filter. +/// Any failure (connection refused, admin error) exits non-zero. +async fn run_log_level_get(cli: &Cli) -> ExitCode { + let data = match client::fetch_log_level(&cli.socket).await { + Ok(data) => data, + Err(err) => { + eprintln!("arcticwolfctl: log-level get failed: {err:#}"); + return ExitCode::FAILURE; + } + }; + match client::render_log_level_get(&data, cli.json) { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("arcticwolfctl: {err:#}"); + ExitCode::FAILURE + } + } +} + +/// `arcticwolfctl log-level set ` — change the daemon's log level. +/// An unrecognized level is rejected by the daemon, which surfaces here as +/// a stderr error and a non-zero exit (the issue #25 acceptance criterion). +async fn run_log_level_set(cli: &Cli, level: &str) -> ExitCode { + let data = match client::set_log_level(&cli.socket, level).await { + Ok(data) => data, + Err(err) => { + eprintln!("arcticwolfctl: log-level set failed: {err:#}"); + return ExitCode::FAILURE; + } + }; + match client::render_log_level_set(&data, cli.json) { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("arcticwolfctl: {err:#}"); + ExitCode::FAILURE + } + } +} + +/// Translate the CLI's `--name`/`--uid` pair into the wire `ExportSelector`. +/// +/// Clap's `ArgGroup(required=true, multiple=false)` guarantees that exactly +/// one of the two is `Some`, so the caller never sees a `None`/`None` pair. +/// The `unreachable!` branch documents that invariant for the reader and +/// pins a regression test if the group config ever gets weakened. +fn selector_from(name: Option<&str>, uid: Option) -> ExportSelector { + match (name, uid) { + (Some(name), None) => ExportSelector::Name(name.to_string()), + (None, Some(uid)) => ExportSelector::Uid(uid), + _ => unreachable!("clap ArgGroup guarantees exactly one selector arg"), + } +} + +/// Build the wire-level [`ExportConfig`] from the CLI's `exports add` args. +/// +/// `--fsal` is validated by clap as a `ValueEnum`, so this function only +/// handles the dispatch from `FsalKind` to `BackendConfig` and the +/// per-backend required-flag check (`--path` is mandatory for `local`). +fn export_config_from(args: &ExportsAddArgs) -> Result { + match args.fsal { + FsalKind::Local => { + let path = args + .path + .clone() + .ok_or_else(|| "exports add --fsal local requires --path".to_string())?; + Ok(ExportConfig { + name: args.name.clone(), + uid: args.uid, + read_only: args.read_only, + backend: BackendConfig::Local { path }, + }) + } + } +} + +/// `arcticwolfctl exports list` — print the snapshot. +async fn run_exports_list(cli: &Cli) -> ExitCode { + let data = match client::fetch_exports_list(&cli.socket).await { + Ok(data) => data, + Err(err) => { + eprintln!("arcticwolfctl: exports list failed: {err:#}"); + return ExitCode::FAILURE; + } + }; + match client::render_exports_list(&data, cli.json) { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("arcticwolfctl: {err:#}"); + ExitCode::FAILURE + } + } +} + +/// `arcticwolfctl exports add` — add or pre-flight an export. +async fn run_exports_add(cli: &Cli, args: &ExportsAddArgs) -> ExitCode { + let config = match export_config_from(args) { + Ok(c) => c, + Err(err) => { + eprintln!("arcticwolfctl: {err}"); + return ExitCode::FAILURE; + } + }; + let data = match client::exports_add(&cli.socket, config, args.dry_run).await { + Ok(data) => data, + Err(err) => { + eprintln!("arcticwolfctl: exports add failed: {err:#}"); + return ExitCode::FAILURE; + } + }; + match client::render_exports_add(&data, cli.json) { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("arcticwolfctl: {err:#}"); + ExitCode::FAILURE + } + } +} + +/// `arcticwolfctl exports remove` — retire or pre-flight an export. +async fn run_exports_remove(cli: &Cli, args: &ExportsSelectorArgs) -> ExitCode { + let selector = selector_from(args.name.as_deref(), args.uid); + let data = match client::exports_remove(&cli.socket, selector, args.dry_run).await { + Ok(data) => data, + Err(err) => { + eprintln!("arcticwolfctl: exports remove failed: {err:#}"); + return ExitCode::FAILURE; + } + }; + match client::render_exports_remove(&data, cli.json) { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("arcticwolfctl: {err:#}"); + ExitCode::FAILURE + } + } +} + +/// `arcticwolfctl exports update` — update or pre-flight an export. +async fn run_exports_update(cli: &Cli, args: &ExportsUpdateArgs) -> ExitCode { + let selector = selector_from(args.name.as_deref(), args.uid); + let data = + match client::exports_update(&cli.socket, selector, args.read_only, args.dry_run).await { + Ok(data) => data, + Err(err) => { + eprintln!("arcticwolfctl: exports update failed: {err:#}"); + return ExitCode::FAILURE; + } + }; + match client::render_exports_update(&data, cli.json) { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("arcticwolfctl: {err:#}"); + ExitCode::FAILURE + } + } +} + +/// `arcticwolfctl config show` — dump the startup config as JSON. +async fn run_config_show(cli: &Cli) -> ExitCode { + let data = match client::fetch_config_show(&cli.socket).await { + Ok(data) => data, + Err(err) => { + eprintln!("arcticwolfctl: config show failed: {err:#}"); + return ExitCode::FAILURE; + } + }; + match client::render_config_show(&data, cli.json) { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("arcticwolfctl: {err:#}"); + ExitCode::FAILURE + } + } +} + +/// `arcticwolfctl metrics` — fetch and pretty-print the runtime counter +/// snapshot. Any failure (connection refused, admin error) exits non-zero. +async fn run_metrics(cli: &Cli) -> ExitCode { + let data = match client::fetch_metrics(&cli.socket).await { + Ok(data) => data, + Err(err) => { + eprintln!("arcticwolfctl: metrics failed: {err:#}"); + return ExitCode::FAILURE; + } + }; + match client::render_metrics(&data, cli.json) { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("arcticwolfctl: {err:#}"); + ExitCode::FAILURE + } + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use arcticwolf::admin::{AdminResponse, protocol}; + use futures_util::{SinkExt, StreamExt}; + use serde_json::json; + use tokio::net::UnixListener; + use tokio::task::JoinHandle; + + use super::*; + + /// `ExitCode` implements neither `PartialEq` nor a value accessor, so + /// the contract is asserted by comparing `Debug` output — `SUCCESS` + /// and `FAILURE` render distinctly, which is all these checks need. + fn assert_exit(actual: ExitCode, expected: ExitCode) { + assert_eq!(format!("{actual:?}"), format!("{expected:?}")); + } + + /// Build a `Cli` for the in-process `run_*` entry points, bypassing + /// `clap` argument parsing. + fn make_cli(socket: PathBuf, command: Command) -> Cli { + Cli { + socket, + json: false, + command, + } + } + + /// Bind a one-shot fake admin server at `socket_path` that answers the + /// first request with `response`, then closes. The socket is bound + /// before this returns so a client can connect without a race; the + /// accept-and-answer step runs on the returned task. + fn spawn_fake_server(socket_path: &Path, response: AdminResponse) -> JoinHandle<()> { + let listener = UnixListener::bind(socket_path).expect("bind fake admin socket"); + tokio::spawn(async move { + if let Ok((stream, _)) = listener.accept().await { + let mut connection = protocol::framed(stream); + // The request content is irrelevant to the exit-code + // contract under test — just drain the single frame. + let _ = connection.next().await; + if let Ok(encoded) = protocol::encode_response(&response) { + let _ = connection.send(encoded).await; + } + } + }) + } + + #[tokio::test] + async fn run_status_exits_failure_when_socket_is_absent() { + let dir = tempfile::tempdir().expect("tempdir"); + let cli = make_cli(dir.path().join("absent.sock"), Command::Status); + assert_exit(run_status(&cli).await, ExitCode::FAILURE); + } + + #[tokio::test] + async fn run_status_exits_failure_when_daemon_returns_err() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let server = spawn_fake_server(&socket, AdminResponse::error("simulated admin failure")); + + let code = run_status(&make_cli(socket, Command::Status)).await; + assert_exit(code, ExitCode::FAILURE); + server.abort(); + } + + #[tokio::test] + async fn run_status_exits_success_on_ok_response() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let response = AdminResponse::Ok { + data: json!({ + "daemon_version": "0.1.0", + "uptime_seconds": 1, + "bind_address": "0.0.0.0", + "nfs_port": 2049, + "mount_port": 20048, + "portmap_port": 111, + "log_level": "info", + "export_count": 1, + }), + }; + let server = spawn_fake_server(&socket, response); + + let code = run_status(&make_cli(socket, Command::Status)).await; + assert_exit(code, ExitCode::SUCCESS); + server.abort(); + } + + #[tokio::test] + async fn run_version_exits_success_when_daemon_unreachable() { + // The client version is always printable, so an unreachable + // daemon must not turn `version` into a failure — operator + // scripts rely on `version` succeeding offline. + let dir = tempfile::tempdir().expect("tempdir"); + let cli = make_cli(dir.path().join("absent.sock"), Command::Version); + assert_exit(run_version(&cli).await, ExitCode::SUCCESS); + } + + #[tokio::test] + async fn run_version_exits_success_on_ok_response() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let response = AdminResponse::Ok { + data: json!({ + "daemon_version": "0.1.0", + "build_commit": "abc123", + "rustc_version": "1.91.0", + "build_profile": "release", + }), + }; + let server = spawn_fake_server(&socket, response); + + let code = run_version(&make_cli(socket, Command::Version)).await; + assert_exit(code, ExitCode::SUCCESS); + server.abort(); + } + + #[tokio::test] + async fn run_log_level_get_exits_failure_when_socket_is_absent() { + let dir = tempfile::tempdir().expect("tempdir"); + let cli = make_cli( + dir.path().join("absent.sock"), + Command::LogLevel { + action: LogLevelAction::Get, + }, + ); + assert_exit(run_log_level_get(&cli).await, ExitCode::FAILURE); + } + + #[tokio::test] + async fn run_log_level_get_exits_success_on_ok_response() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let response = AdminResponse::Ok { + data: json!({ "level": "info" }), + }; + let server = spawn_fake_server(&socket, response); + + let cli = make_cli( + socket, + Command::LogLevel { + action: LogLevelAction::Get, + }, + ); + assert_exit(run_log_level_get(&cli).await, ExitCode::SUCCESS); + server.abort(); + } + + #[tokio::test] + async fn run_log_level_set_exits_success_on_ok_response() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let response = AdminResponse::Ok { + data: json!({ "level": "debug" }), + }; + let server = spawn_fake_server(&socket, response); + + let cli = make_cli( + socket, + Command::LogLevel { + action: LogLevelAction::Set { + level: "debug".to_string(), + }, + }, + ); + assert_exit(run_log_level_set(&cli, "debug").await, ExitCode::SUCCESS); + server.abort(); + } + + #[tokio::test] + async fn run_log_level_set_exits_failure_when_daemon_returns_err() { + // Pins the issue #25 acceptance criterion: `log-level set tomato` + // (an unrecognized level the daemon rejects with an error) must + // exit non-zero so operator scripts can detect the failure. + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let server = spawn_fake_server( + &socket, + AdminResponse::error("Invalid log level 'tomato': expected one of ..."), + ); + + let cli = make_cli( + socket, + Command::LogLevel { + action: LogLevelAction::Set { + level: "tomato".to_string(), + }, + }, + ); + assert_exit(run_log_level_set(&cli, "tomato").await, ExitCode::FAILURE); + server.abort(); + } + + // --------------------------------------------------------------- + // Phase 5: exports list/add/remove/update and config show + // --------------------------------------------------------------- + + #[tokio::test] + async fn run_exports_list_exits_success_on_ok_response() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let response = AdminResponse::Ok { + data: json!({ + "exports": [ + { "uid": 1, "name": "/data", "fsal": "local", "read_only": false } + ], + "retired_uids": [], + }), + }; + let server = spawn_fake_server(&socket, response); + + let code = run_exports_list(&make_cli( + socket, + Command::Exports { + action: ExportsAction::List, + }, + )) + .await; + assert_exit(code, ExitCode::SUCCESS); + server.abort(); + } + + #[tokio::test] + async fn run_exports_list_exits_failure_when_socket_is_absent() { + let dir = tempfile::tempdir().expect("tempdir"); + let cli = make_cli( + dir.path().join("absent.sock"), + Command::Exports { + action: ExportsAction::List, + }, + ); + assert_exit(run_exports_list(&cli).await, ExitCode::FAILURE); + } + + #[tokio::test] + async fn run_exports_add_exits_success_on_ok_response() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let response = AdminResponse::Ok { + data: json!({ "name": "/extra", "uid": 7 }), + }; + let server = spawn_fake_server(&socket, response); + + let args = ExportsAddArgs { + name: "/extra".to_string(), + uid: 7, + fsal: FsalKind::Local, + path: Some(PathBuf::from("/srv/extra")), + read_only: false, + dry_run: false, + }; + let code = run_exports_add( + &make_cli( + socket, + Command::Exports { + action: ExportsAction::Add(ExportsAddArgs { + name: args.name.clone(), + uid: args.uid, + fsal: args.fsal.clone(), + path: args.path.clone(), + read_only: args.read_only, + dry_run: args.dry_run, + }), + }, + ), + &args, + ) + .await; + assert_exit(code, ExitCode::SUCCESS); + server.abort(); + } + + /// Reject `--fsal s3` at clap parse time: with `FsalKind` as a + /// `ValueEnum`, the rejection moves from `export_config_from`'s + /// `String` match to clap's "possible values" validation, and the + /// failure surfaces as a non-zero exit code (whatever message clap + /// renders, which we don't pin further to keep the test robust). + #[test] + fn run_exports_add_rejects_unsupported_fsal_before_dialing_socket() { + let result = Cli::try_parse_from([ + "arcticwolfctl", + "exports", + "add", + "--name", + "/extra", + "--uid", + "7", + "--fsal", + "s3", + "--path", + "/srv/extra", + ]); + let err = result.expect_err("unsupported --fsal must fail at parse time"); + // Don't pin the exact wording — only that clap is the source and + // that it mentioned the one supported value. + let rendered = err.to_string(); + assert!( + rendered.contains("local"), + "clap error should mention 'local' as a possible value; got: {rendered}" + ); + } + + #[tokio::test] + async fn run_exports_remove_exits_success_on_ok_response() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let response = AdminResponse::Ok { + data: json!({ "uid": 1, "name": "/data" }), + }; + let server = spawn_fake_server(&socket, response); + + let args = ExportsSelectorArgs { + name: Some("/data".to_string()), + uid: None, + dry_run: false, + }; + let code = run_exports_remove( + &make_cli( + socket, + Command::Exports { + action: ExportsAction::Remove(ExportsSelectorArgs { + name: args.name.clone(), + uid: args.uid, + dry_run: args.dry_run, + }), + }, + ), + &args, + ) + .await; + assert_exit(code, ExitCode::SUCCESS); + server.abort(); + } + + #[tokio::test] + async fn run_exports_update_exits_success_on_ok_response() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let response = AdminResponse::Ok { + data: json!({ "uid": 1, "name": "/data", "read_only": true }), + }; + let server = spawn_fake_server(&socket, response); + + let args = ExportsUpdateArgs { + name: Some("/data".to_string()), + uid: None, + read_only: true, + dry_run: false, + }; + let code = run_exports_update( + &make_cli( + socket, + Command::Exports { + action: ExportsAction::Update(ExportsUpdateArgs { + name: args.name.clone(), + uid: args.uid, + read_only: args.read_only, + dry_run: args.dry_run, + }), + }, + ), + &args, + ) + .await; + assert_exit(code, ExitCode::SUCCESS); + server.abort(); + } + + // ----- Daemon-Err exit-code coverage for Phase 5 commands. ---------- + // Mirrors `run_log_level_set_exits_failure_when_daemon_returns_err`: if + // the daemon returns `AdminResponse::Err`, the CLI must surface a + // non-zero exit so operator scripts notice. One test per command — + // combinatorial coverage adds no value over the underlying contract. + + #[tokio::test] + async fn run_exports_list_exits_failure_when_daemon_returns_err() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let server = spawn_fake_server(&socket, AdminResponse::error("simulated")); + let cli = make_cli( + socket, + Command::Exports { + action: ExportsAction::List, + }, + ); + assert_exit(run_exports_list(&cli).await, ExitCode::FAILURE); + server.abort(); + } + + #[tokio::test] + async fn run_exports_add_exits_failure_when_daemon_returns_err() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let server = spawn_fake_server(&socket, AdminResponse::error("simulated")); + let args = ExportsAddArgs { + name: "/extra".to_string(), + uid: 7, + fsal: FsalKind::Local, + path: Some(PathBuf::from("/srv/extra")), + read_only: false, + dry_run: false, + }; + let code = run_exports_add( + &make_cli( + socket, + Command::Exports { + action: ExportsAction::Add(ExportsAddArgs { + name: args.name.clone(), + uid: args.uid, + fsal: args.fsal.clone(), + path: args.path.clone(), + read_only: args.read_only, + dry_run: args.dry_run, + }), + }, + ), + &args, + ) + .await; + assert_exit(code, ExitCode::FAILURE); + server.abort(); + } + + #[tokio::test] + async fn run_exports_remove_exits_failure_when_daemon_returns_err() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let server = spawn_fake_server(&socket, AdminResponse::error("simulated")); + let args = ExportsSelectorArgs { + name: Some("/data".to_string()), + uid: None, + dry_run: false, + }; + let code = run_exports_remove( + &make_cli( + socket, + Command::Exports { + action: ExportsAction::Remove(ExportsSelectorArgs { + name: args.name.clone(), + uid: args.uid, + dry_run: args.dry_run, + }), + }, + ), + &args, + ) + .await; + assert_exit(code, ExitCode::FAILURE); + server.abort(); + } + + #[tokio::test] + async fn run_exports_update_exits_failure_when_daemon_returns_err() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let server = spawn_fake_server(&socket, AdminResponse::error("simulated")); + let args = ExportsUpdateArgs { + name: Some("/data".to_string()), + uid: None, + read_only: true, + dry_run: false, + }; + let code = run_exports_update( + &make_cli( + socket, + Command::Exports { + action: ExportsAction::Update(ExportsUpdateArgs { + name: args.name.clone(), + uid: args.uid, + read_only: args.read_only, + dry_run: args.dry_run, + }), + }, + ), + &args, + ) + .await; + assert_exit(code, ExitCode::FAILURE); + server.abort(); + } + + #[tokio::test] + async fn run_config_show_exits_failure_when_daemon_returns_err() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let server = spawn_fake_server(&socket, AdminResponse::error("simulated")); + let cli = make_cli( + socket, + Command::Config { + action: ConfigAction::Show, + }, + ); + assert_exit(run_config_show(&cli).await, ExitCode::FAILURE); + server.abort(); + } + + #[tokio::test] + async fn run_config_show_exits_success_on_ok_response() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let response = AdminResponse::Ok { + data: json!({ + "server": { "bind_address": "0.0.0.0", "nfs_port": 2049, "mount_port": 0 }, + "exports": [], + "logging": { "level": null }, + "admin": { + "enabled": false, + "socket_path": "/run/arcticwolf/admin.sock", + "socket_mode": 384, + }, + }), + }; + let server = spawn_fake_server(&socket, response); + + let code = run_config_show(&make_cli( + socket, + Command::Config { + action: ConfigAction::Show, + }, + )) + .await; + assert_exit(code, ExitCode::SUCCESS); + server.abort(); + } + + #[tokio::test] + async fn run_metrics_exits_success_on_ok_response() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let response = AdminResponse::Ok { + data: json!({ + "server": { "uptime_seconds": 1, "rpc_requests_total": 0, "rpc_errors_total": 0 }, + "nfs_ops": { "getattr": 0 }, + "exports": [], + "admin": { "commands_total": 1, "by_command": { "metrics": 1 } }, + }), + }; + let server = spawn_fake_server(&socket, response); + + let code = run_metrics(&make_cli(socket, Command::Metrics)).await; + assert_exit(code, ExitCode::SUCCESS); + server.abort(); + } + + #[tokio::test] + async fn run_metrics_exits_failure_when_socket_is_absent() { + let dir = tempfile::tempdir().expect("tempdir"); + let cli = make_cli(dir.path().join("absent.sock"), Command::Metrics); + assert_exit(run_metrics(&cli).await, ExitCode::FAILURE); + } + + #[tokio::test] + async fn run_metrics_exits_failure_when_daemon_returns_err() { + let dir = tempfile::tempdir().expect("tempdir"); + let socket = dir.path().join("admin.sock"); + let server = spawn_fake_server(&socket, AdminResponse::error("simulated")); + let code = run_metrics(&make_cli(socket, Command::Metrics)).await; + assert_exit(code, ExitCode::FAILURE); + server.abort(); + } + + /// Parse-level smoke checks for the new clap subtrees: the + /// mutually-exclusive selector group on `exports remove` and the + /// required `--read-only` flag on `exports update`. + #[test] + fn cli_parse_exports_remove_requires_exactly_one_selector() { + // Neither --name nor --uid → ArgGroup error. + assert!( + Cli::try_parse_from(["arcticwolfctl", "exports", "remove"]).is_err(), + "missing selector must fail" + ); + // Both --name and --uid → ArgGroup error. + assert!( + Cli::try_parse_from([ + "arcticwolfctl", + "exports", + "remove", + "--name", + "/data", + "--uid", + "1", + ]) + .is_err(), + "two selectors must fail" + ); + // --name alone → ok. + Cli::try_parse_from(["arcticwolfctl", "exports", "remove", "--name", "/data"]) + .expect("--name alone must parse"); + // --uid alone → ok. + Cli::try_parse_from(["arcticwolfctl", "exports", "remove", "--uid", "5"]) + .expect("--uid alone must parse"); + } + + #[test] + fn cli_parse_exports_update_requires_read_only_flag() { + // Missing --read-only → clap error. + assert!( + Cli::try_parse_from(["arcticwolfctl", "exports", "update", "--name", "/data"]).is_err(), + "missing --read-only must fail" + ); + // With --read-only → ok. + Cli::try_parse_from([ + "arcticwolfctl", + "exports", + "update", + "--name", + "/data", + "--read-only", + "true", + ]) + .expect("update --read-only=true must parse"); + } + + /// `--read-only false` must parse exactly like `--read-only true`. + /// Locking this avoids a regression where clap's `ArgAction::Set` on + /// a `bool` field silently turns into a presence switch (which would + /// make `false` a synonym for `true`). + #[test] + fn cli_parse_exports_update_accepts_read_only_false() { + Cli::try_parse_from([ + "arcticwolfctl", + "exports", + "update", + "--name", + "/data", + "--read-only", + "false", + ]) + .expect("update --read-only=false must parse"); + } + + #[test] + fn cli_parse_exports_add_accepts_local_fsal() { + Cli::try_parse_from([ + "arcticwolfctl", + "exports", + "add", + "--name", + "/extra", + "--uid", + "9", + "--fsal", + "local", + "--path", + "/srv/extra", + ]) + .expect("typical exports add must parse"); + } +} diff --git a/src/config.rs b/src/config.rs index 9203a3e..4816cf3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,7 +6,7 @@ use anyhow::bail; use clap::Parser; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; @@ -21,15 +21,18 @@ pub struct Cli { pub config: Option, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default, deny_unknown_fields)] pub struct Config { pub server: ServerConfig, pub exports: Vec, pub logging: LoggingConfig, + pub admin: AdminConfig, + pub audit: AuditConfig, + pub shutdown: ShutdownConfig, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct ServerConfig { pub bind_address: String, @@ -50,7 +53,7 @@ pub struct ServerConfig { /// "unknown field `readOnly`, expected `path`". For non-flattened sections /// like `[server]` and `[logging]`, `Config::load` additionally wraps /// deserialization with `serde_ignored` to catch typos there as well. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct ExportConfig { /// Export path as advertised to NFS clients (e.g. "/data"). Must start with `/`. pub name: String, @@ -68,7 +71,7 @@ pub struct ExportConfig { /// /// Tagged union — the `backend` key in TOML selects the variant and the remaining /// keys deserialize into that variant's fields. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(tag = "backend", rename_all = "lowercase", deny_unknown_fields)] pub enum BackendConfig { Local { path: PathBuf }, @@ -83,12 +86,92 @@ impl BackendConfig { } } -#[derive(Debug, Clone, Deserialize, Default)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct LoggingConfig { /// Log level. If not set, falls back to RUST_LOG env var, then "info" pub level: Option, } +/// Admin Unix-domain-socket server settings. +/// +/// The admin transport (issue #25) is opt-in: by default `enabled = false` +/// so the scaffolding is inert and existing deployments are unaffected. +/// When enabled, the daemon binds a length-prefixed JSON server at +/// `socket_path` and applies `socket_mode` (default `0o600`). +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct AdminConfig { + /// Whether the admin server is started at all. Defaults to `false`. + pub enabled: bool, + /// Filesystem path for the admin Unix domain socket. + pub socket_path: PathBuf, + /// File mode applied to the socket via `chmod(2)` after bind. + /// + /// TOML doesn't have an octal literal, so values are written in either + /// decimal (e.g. `384`) or Rust-style octal (e.g. `0o600`) — both + /// deserialize through `u32` here. We don't try to validate the mode + /// bits beyond their range; the kernel will reject anything wider when + /// `chmod` is called. + pub socket_mode: u32, +} + +impl Default for AdminConfig { + fn default() -> Self { + Self { + enabled: false, + socket_path: PathBuf::from(crate::admin::DEFAULT_ADMIN_SOCKET_PATH), + socket_mode: 0o600, + } + } +} + +/// Admin audit-log settings (issue #25 Phase 6). +/// +/// When `enabled = true`, every admin request the daemon accepts produces +/// one JSON-lines record in `path`. `enabled = false` (the default) makes +/// the audit subsystem inert — no file is opened, no events are recorded. +/// +/// `path` is required when `enabled = true`; `Config::validate()` rejects +/// the combination of `enabled = true` and `path = None`. The parent +/// directory of `path` must already exist at daemon startup — the daemon +/// will not auto-create it (paths under `/var/log` are operator territory). +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +#[serde(default, deny_unknown_fields)] +pub struct AuditConfig { + /// Whether the audit writer is started. Defaults to `false`. + pub enabled: bool, + /// Filesystem path for the JSON-lines audit log. Required when + /// `enabled = true`. Opened with `append(true).create(true)` and + /// chmod'd to `0o640` (umask-respecting); never truncated. + pub path: Option, +} + +/// Graceful-shutdown settings (issue #25 Phase 8). +/// +/// On `SIGTERM`/`SIGINT` the daemon stops accepting new connections and +/// waits for in-flight requests to finish before exiting. The wait is +/// bounded by `drain_timeout_seconds`: it is a *ceiling*, not a target — +/// a server that drains in 5ms exits in 5ms; only a stuck handler makes +/// the daemon wait the full window. When the ceiling is hit the daemon +/// logs a warning and exits anyway (with status 0 — a slow drain is not a +/// failure). The section is optional; an absent `[shutdown]` block uses +/// the 30-second default. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct ShutdownConfig { + /// Maximum number of seconds to wait for in-flight requests to drain + /// before forcing exit. Default 30. + pub drain_timeout_seconds: u64, +} + +impl Default for ShutdownConfig { + fn default() -> Self { + Self { + drain_timeout_seconds: 30, + } + } +} + impl Default for ServerConfig { fn default() -> Self { Self { @@ -112,6 +195,9 @@ impl Default for Config { }, }], logging: LoggingConfig::default(), + admin: AdminConfig::default(), + audit: AuditConfig::default(), + shutdown: ShutdownConfig::default(), } } } @@ -181,6 +267,37 @@ impl Config { bail!("Configuration must define at least one [[exports]] entry"); } + // Reject mode values that overflow the chmod(2) permission bits. + // `chmod` silently masks anything above 0o777, so accepting e.g. + // `0o7777` would lead to a surprising "I set 7777 but stat shows + // 0777" mismatch. Validating up front gives the operator a clear + // error pointing at the field they got wrong. + if self.admin.socket_mode > 0o777 { + bail!( + "admin.socket_mode = {:o} is invalid; must be <= 0o777 (chmod silently masks higher bits)", + self.admin.socket_mode, + ); + } + + // Phase 6 audit config: `path` is mandatory and non-empty when the + // audit writer is enabled. Without it we'd have nowhere to write + // events, and silently disabling audit on a half-configured section + // would be worse than failing fast at startup. An empty `path = ""` + // is caught here too: otherwise the error only surfaces deep inside + // `FileAuditWriter::open` as a confusing "No such file or directory" + // against an empty display. + if self.audit.enabled { + match &self.audit.path { + None => bail!( + "audit.enabled = true requires audit.path to be set (no default audit-log path)" + ), + Some(path) if path.as_os_str().is_empty() => { + bail!("[audit] path must not be empty when enabled = true"); + } + Some(_) => {} + } + } + let mut seen_uids: HashMap = HashMap::new(); let mut seen_names: HashSet<&str> = HashSet::new(); @@ -200,12 +317,7 @@ impl Config { ); } seen_uids.insert(export.uid, export.name.as_str()); - if !export.name.starts_with('/') { - bail!( - "Export name '{}' must start with '/' (e.g. '/data')", - export.name - ); - } + crate::fsal::multi_export::validate_export_name(&export.name)?; if !seen_names.insert(export.name.as_str()) { bail!("Duplicate export name '{}'", export.name); } @@ -419,6 +531,9 @@ mod tests { server: ServerConfig::default(), exports: vec![], logging: LoggingConfig::default(), + admin: AdminConfig::default(), + audit: AuditConfig::default(), + shutdown: ShutdownConfig::default(), }; let err = config.validate().expect_err("empty exports must fail"); let msg = err.to_string(); @@ -434,6 +549,9 @@ mod tests { local_export("/b", 5, "/srv/b"), ], logging: LoggingConfig::default(), + admin: AdminConfig::default(), + audit: AuditConfig::default(), + shutdown: ShutdownConfig::default(), }; let err = config.validate().expect_err("duplicate uid must fail"); let msg = err.to_string(); @@ -457,6 +575,9 @@ mod tests { local_export("/data", 2, "/srv/b"), ], logging: LoggingConfig::default(), + admin: AdminConfig::default(), + audit: AuditConfig::default(), + shutdown: ShutdownConfig::default(), }; let err = config.validate().expect_err("duplicate name must fail"); let msg = err.to_string(); @@ -472,6 +593,9 @@ mod tests { server: ServerConfig::default(), exports: vec![local_export("/data", 0, "/srv/a")], logging: LoggingConfig::default(), + admin: AdminConfig::default(), + audit: AuditConfig::default(), + shutdown: ShutdownConfig::default(), }; let err = config.validate().expect_err("uid 0 must fail"); let msg = err.to_string(); @@ -484,6 +608,9 @@ mod tests { server: ServerConfig::default(), exports: vec![local_export("data", 1, "/srv/a")], logging: LoggingConfig::default(), + admin: AdminConfig::default(), + audit: AuditConfig::default(), + shutdown: ShutdownConfig::default(), }; let err = config.validate().expect_err("relative name must fail"); let msg = err.to_string(); @@ -518,6 +645,9 @@ mod tests { local_export("/c", 3, "/srv/c"), ], logging: LoggingConfig::default(), + admin: AdminConfig::default(), + audit: AuditConfig::default(), + shutdown: ShutdownConfig::default(), }; config.validate().expect("distinct exports must validate"); } @@ -628,4 +758,272 @@ path = "/srv/data" err ); } + + #[test] + fn test_admin_config_defaults_when_section_absent() { + // The defining contract for #25 phase 1: a config file that doesn't + // mention `[admin]` at all must still parse and produce an inert + // (`enabled = false`) admin section. Existing deployments must not + // need to touch their TOML to keep building. + let toml = r#" + [[exports]] + name = "/data" + uid = 1 + backend = "local" + path = "/srv/data" + "#; + let config: Config = toml::from_str(toml).expect("should parse without [admin]"); + assert!(!config.admin.enabled); + assert_eq!( + config.admin.socket_path, + PathBuf::from("/run/arcticwolf/admin.sock") + ); + assert_eq!(config.admin.socket_mode, 0o600); + } + + #[test] + fn test_admin_config_parses_explicit_values() { + // Operators write the mode either as a decimal number or a Rust-style + // octal literal; both must reach `AdminConfig::socket_mode` as the + // same `u32`. We exercise the octal form here — TOML accepts `0o600` + // as a numeric literal, but the assertion is on the decoded value. + let toml = r#" + [admin] + enabled = true + socket_path = "/tmp/aw-admin.sock" + socket_mode = 0o640 + + [[exports]] + name = "/data" + uid = 1 + backend = "local" + path = "/srv/data" + "#; + let config: Config = toml::from_str(toml).expect("should parse [admin]"); + assert!(config.admin.enabled); + assert_eq!( + config.admin.socket_path, + PathBuf::from("/tmp/aw-admin.sock") + ); + assert_eq!(config.admin.socket_mode, 0o640); + } + + #[test] + fn test_admin_socket_mode_decimal_value() { + // TOML has no octal syntax outside the Rust-style `0o...` literal, so + // operators may write the mode as plain decimal. `384 == 0o600` must + // round-trip through deserialization to the same numeric value as + // `0o600` would. Pairs with `test_admin_config_parses_explicit_values` + // (which exercises the `0o640` form). + let toml = r#" + [admin] + enabled = true + socket_path = "/tmp/aw-admin.sock" + socket_mode = 384 + + [[exports]] + name = "/data" + uid = 1 + backend = "local" + path = "/srv/data" + "#; + let config: Config = toml::from_str(toml).expect("should parse decimal socket_mode"); + assert_eq!(config.admin.socket_mode, 0o600); + config + .validate() + .expect("decimal socket_mode within range must validate"); + } + + #[test] + fn test_admin_socket_mode_too_high_is_rejected() { + // `chmod(2)` silently masks anything wider than 0o777, so passing + // a 4-digit octal (or a very large decimal) would silently drop + // bits without warning the operator. `validate()` must reject it. + let mut config = Config::default(); + config.admin.socket_mode = 0o7777; + let err = config + .validate() + .expect_err("socket_mode > 0o777 must be rejected") + .to_string(); + assert!( + err.contains("must be <= 0o777"), + "error should explain the upper bound; got: {err}", + ); + + // Decimal far outside the chmod range — same code path, just confirms + // we don't have a magic-octal shortcut bypass. + let mut config = Config::default(); + config.admin.socket_mode = 9_999_999; + let err = config + .validate() + .expect_err("absurd decimal socket_mode must be rejected") + .to_string(); + assert!( + err.contains("must be <= 0o777"), + "error should explain the upper bound; got: {err}", + ); + } + + #[test] + fn test_admin_config_rejects_unknown_field() { + // `AdminConfig` carries `deny_unknown_fields`, so a typo such as + // `socket_perms` instead of `socket_mode` must fail at load time + // rather than be silently dropped. + let toml = r#" + [admin] + enabled = true + socket_perms = 384 + + [[exports]] + name = "/data" + uid = 1 + backend = "local" + path = "/srv/data" + "#; + let result: Result = toml::from_str(toml); + assert!( + result.is_err(), + "unknown field inside [admin] must fail to parse" + ); + } + + #[test] + fn test_audit_config_defaults_when_section_absent() { + // Phase 6: a config file that doesn't mention `[audit]` must still + // parse and produce an inert (`enabled = false`, `path = None`) + // audit section. The audit subsystem must be opt-in. + let toml = r#" + [[exports]] + name = "/data" + uid = 1 + backend = "local" + path = "/srv/data" + "#; + let config: Config = toml::from_str(toml).expect("should parse without [audit]"); + assert!(!config.audit.enabled); + assert!(config.audit.path.is_none()); + config.validate().expect("disabled audit must validate"); + } + + #[test] + fn test_audit_enabled_without_path_is_rejected() { + // `[audit] enabled = true` with no `path` is a half-configured + // section — silently disabling audit would mask the operator's + // intent. `validate()` must fail fast. + let mut config = Config::default(); + config.audit.enabled = true; + config.audit.path = None; + let err = config + .validate() + .expect_err("enabled audit without path must fail") + .to_string(); + assert!( + err.contains("audit.path"), + "error should mention the missing field; got: {err}", + ); + } + + #[test] + fn audit_enabled_with_empty_path_is_rejected() { + // `path = ""` is `Some` but not a usable destination. `validate()` + // must reject it up front with a clear message rather than letting + // it slip through to `FileAuditWriter::open`, where it surfaces as a + // confusing "No such file or directory" against an empty path. + let mut config = Config::default(); + config.audit.enabled = true; + config.audit.path = Some(PathBuf::from("")); + let err = config + .validate() + .expect_err("empty audit path must be rejected") + .to_string(); + assert!( + err.contains("must not be empty"), + "error should explain the empty path; got: {err}", + ); + } + + #[test] + fn test_audit_enabled_with_path_validates() { + let mut config = Config::default(); + config.audit.enabled = true; + config.audit.path = Some(PathBuf::from("/var/log/arcticwolf/admin-audit.jsonl")); + config.validate().expect("enabled+path must validate"); + } + + #[test] + fn test_shutdown_config_defaults_when_section_absent() { + // Phase 8: a config file without `[shutdown]` must still parse and + // yield the 30-second default drain timeout. The section is opt-in. + let toml = r#" + [[exports]] + name = "/data" + uid = 1 + backend = "local" + path = "/srv/data" + "#; + let config: Config = toml::from_str(toml).expect("should parse without [shutdown]"); + assert_eq!(config.shutdown.drain_timeout_seconds, 30); + config + .validate() + .expect("absent shutdown section must validate"); + } + + #[test] + fn test_shutdown_config_parses_explicit_value() { + let toml = r#" + [shutdown] + drain_timeout_seconds = 5 + + [[exports]] + name = "/data" + uid = 1 + backend = "local" + path = "/srv/data" + "#; + let config: Config = toml::from_str(toml).expect("should parse [shutdown]"); + assert_eq!(config.shutdown.drain_timeout_seconds, 5); + } + + #[test] + fn test_shutdown_config_rejects_unknown_field() { + // `ShutdownConfig` carries `deny_unknown_fields`, so a typo such as + // `drain_timeout` instead of `drain_timeout_seconds` must fail at + // load time rather than be silently dropped. + let toml = r#" + [shutdown] + drain_timeout = 5 + + [[exports]] + name = "/data" + uid = 1 + backend = "local" + path = "/srv/data" + "#; + let result: Result = toml::from_str(toml); + assert!( + result.is_err(), + "unknown field inside [shutdown] must fail to parse" + ); + } + + #[test] + fn test_audit_config_rejects_unknown_field() { + let toml = r#" + [audit] + enabled = true + path = "/tmp/x" + file = "/tmp/y" + + [[exports]] + name = "/data" + uid = 1 + backend = "local" + path = "/srv/data" + "#; + let result: Result = toml::from_str(toml); + assert!( + result.is_err(), + "unknown field inside [audit] must fail to parse" + ); + } } diff --git a/src/fsal/mod.rs b/src/fsal/mod.rs index 38f3c1a..d4e5561 100644 --- a/src/fsal/mod.rs +++ b/src/fsal/mod.rs @@ -17,6 +17,7 @@ pub mod multi_export; use anyhow::Result; use async_trait::async_trait; +use serde::Serialize; use std::path::PathBuf; #[allow(unused_imports)] @@ -361,7 +362,12 @@ pub trait Filesystem: Send + Sync { /// /// Returned by [`ExportRegistry::list_exports`] so MOUNT EXPORT (and startup /// banners) can enumerate exports without touching backend internals. -#[derive(Debug, Clone, PartialEq, Eq)] +/// +/// `#[non_exhaustive]` so future per-export metadata (e.g. squash policy, +/// auth flavor) can land without breaking out-of-crate consumers. Same-crate +/// destructuring is unaffected. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] pub struct ExportInfo { /// Export path as advertised to NFS clients (e.g. `/data`). pub name: String, @@ -369,6 +375,9 @@ pub struct ExportInfo { pub uid: u32, /// True if writes are denied against this export. pub read_only: bool, + /// Short FSAL discriminator (`"local"` today). Forward-compatible: when + /// other backends land, this becomes their variant name. + pub fsal: String, } /// Registry of NFS exports, decoupled from per-handle filesystem operations. @@ -397,6 +406,13 @@ pub trait ExportRegistry: Send + Sync { /// Decode the export uid embedded in `handle`'s prefix, if present. #[allow(dead_code)] fn export_for_handle(&self, handle: &FileHandle) -> Option; + + /// List uids that were live during this daemon's run and have since + /// been removed. Sorted ascending. Defaults to empty so backends that + /// don't track retirement (e.g. test doubles) don't have to implement it. + fn retired_uids(&self) -> Vec { + Vec::new() + } } /// Combined trait used by [`crate::rpc::server::RpcServer`]. diff --git a/src/fsal/multi_export.rs b/src/fsal/multi_export.rs index 290849e..c6d0662 100644 --- a/src/fsal/multi_export.rs +++ b/src/fsal/multi_export.rs @@ -7,38 +7,260 @@ // MOUNT consumes this type via `ExportRegistry`; the NFS dispatcher consumes // it via the `NfsBackend` super-trait, which combines `Filesystem` and // `ExportRegistry` (see #26). - -use anyhow::{Context, Result, anyhow}; +// +// Live state lives behind `ArcSwap` so the admin path +// (add/remove/update_export, wired up by issue #25 Phase 5) can replace the +// snapshot atomically while NFS RPCs are in flight. The contract every trait +// method below upholds is: `load()` the snapshot, clone the inner +// `Arc` out, drop the snapshot guard, and only then +// `.await`. No `Guard` ever crosses an await point. + +use anyhow::{Context, Result, anyhow, bail}; +use arc_swap::ArcSwap; use async_trait::async_trait; -use std::collections::HashMap; -use std::sync::Arc; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; use crate::config::{BackendConfig as ConfigBackend, ExportConfig}; +use crate::metrics::{ExportMetrics, ExportMetricsSnapshot}; use super::handle::{FileHandle, FileHandleExt}; use super::local::LocalFilesystem; use super::{DirEntry, ExportInfo, ExportRegistry, FileAttributes, FileType, Filesystem, FsStats}; +/// Normalize and validate an export name. +/// +/// Shared by [`MultiExportFilesystem::add_export`] and +/// [`crate::config::Config::validate`] so admin input and config-file input +/// are held to the exact same rules. Both call sites previously only checked +/// `starts_with('/')`, which let `..`/`//`/trailing-slash forms through — +/// harmless in the static config case but dangerous on the admin path, where +/// the input is more hostile. +/// +/// Rules: +/// - empty is rejected +/// - must start with `/` +/// - rejects `..` as a path component (anywhere) +/// - rejects `//` (repeated slashes) +/// - rejects trailing `/` unless the name is exactly `/` +pub(crate) fn validate_export_name(name: &str) -> Result<()> { + if name.is_empty() { + bail!("invalid export name '': must not be empty"); + } + if !name.starts_with('/') { + bail!("invalid export name '{name}': must start with '/' (e.g. '/data')"); + } + if name.contains("//") { + bail!("invalid export name '{name}': must not contain '//'"); + } + if name.len() > 1 && name.ends_with('/') { + bail!("invalid export name '{name}': must not end with '/'"); + } + for component in name.split('/') { + if component == ".." { + bail!("invalid export name '{name}': must not contain '..'"); + } + } + Ok(()) +} + +/// Validate the input-only invariants both `add_export` and `dry_run_add` +/// enforce *before* consulting the live snapshot: name shape, non-zero +/// uid, backend-specific path rules. Snapshot-relative checks (duplicate +/// name/uid, retired uid) live in the callers because they need the +/// already-loaded snapshot. +fn validate_input_invariants(config: &ExportConfig) -> Result<()> { + validate_export_name(&config.name)?; + if config.uid == 0 { + bail!( + "Export '{}' has uid 0; uid must be a non-zero u32", + config.name + ); + } + match &config.backend { + ConfigBackend::Local { path } => { + if !path.is_absolute() { + bail!( + "Export '{}' local backend path '{}' must be absolute", + config.name, + path.display() + ); + } + } + } + Ok(()) +} + +/// Construct the backend `LocalFilesystem` for `config`. Used by both +/// `add_export` (which stores the result in the new snapshot) and +/// `dry_run_add` (which drops it immediately) so both paths agree on +/// whether a given input *can* initialize a backend. +/// +/// The HandleManager allocation inside `LocalFilesystem::new` is cheap and +/// invisible — the value lies in catching path-doesn't-exist and +/// path-not-a-directory in the dry-run before a real `add` is attempted. +fn build_backend(config: &ExportConfig) -> Result { + match &config.backend { + ConfigBackend::Local { path } => { + LocalFilesystem::new(path, config.uid).with_context(|| { + format!( + "Failed to initialize local backend for export '{}' at {:?}", + config.name, path + ) + }) + } + } +} + /// Internal record for one configured export. +/// +/// `Clone` so `add_export`/`remove_export`/`update_export` can build a fresh +/// snapshot from the current one with `HashMap::clone()` (which moves each +/// entry through `Clone`); cloning an `ExportEntry` only clones the inner +/// `Arc` and `Arc` (cheap refcount bumps), it +/// does not duplicate the backend's `HandleManager` state. The +/// `Arc` being shared rather than deep-copied is load-bearing: +/// it is why per-export counters survive an `update_export` rebuild (the same +/// allocation is carried into the new snapshot) yet reset on `remove_export` +/// (the entry, and its last `Arc`, are dropped — see [`crate::metrics`]). +#[derive(Clone)] struct ExportEntry { name: String, read_only: bool, + /// Short discriminator for the active backend variant (`"local"`). + /// Reported in the `exports list` admin response so operators can see + /// which FSAL each export is using without consulting the config. + fsal: &'static str, /// Concrete backend so the wrapper can call the inherent /// `LocalFilesystem::root_file_handle()` without going through an /// `async fn` trait dispatch. fs: Arc, + /// Per-export operational counters (Phase 7). Behind an `Arc` so a + /// snapshot rebuild that doesn't touch this export carries the same + /// counters forward (see the `Clone` note above). + metrics: Arc, +} + +/// Immutable snapshot of the live export set. +/// +/// `ArcSwap` owns the live snapshot; admin mutations build +/// a new `ExportsSnapshot` and `store` it. Indexes inside the snapshot are +/// never mutated in place — every change is copy-on-write at the snapshot +/// granularity, so readers that have already `load()`ed a snapshot continue +/// to see a consistent view for the duration of their request. +struct ExportsSnapshot { + /// uid → entry, used to dispatch handle-based operations. + by_uid: HashMap, + /// name → uid, used by MOUNT MNT to resolve a path to its root handle. + by_name: HashMap, + /// Export uids that were live and then removed during this daemon's + /// lifetime. `add_export` refuses to reuse one, because in-flight client + /// handles encoded with the old uid would silently re-route to a new + /// (different!) backend if the slot were recycled. Cleared on restart, + /// since the configuration file is re-read fresh on startup. + retired_uids: HashSet, +} + +impl ExportsSnapshot { + /// Look up the entry that owns `handle`, decoding the uid prefix. + /// + /// Error strings start with `"Invalid handle"` so the per-operation NFS + /// handlers map them to `NFS3ERR_STALE`: `fsstat` and `pathconf` go + /// through [`crate::nfs::error::classify_handle_error`] (which also walks + /// the error chain for `io::ErrorKind::NotFound`), while `read`, `write`, + /// `access`, and `fsinfo` still substring-match `"Invalid handle"` + /// directly. Without that prefix they would fall through to + /// `NFS3ERR_IO`. The wider unification of error mapping is tracked in + /// the FSAL typed-error issue (#28). + fn entry_for_handle(&self, handle: &FileHandle) -> Result<&ExportEntry> { + let uid = handle + .as_slice() + .export_uid() + .ok_or_else(|| anyhow!("Invalid handle: too short to carry an export uid"))?; + self.by_uid + .get(&uid) + .ok_or_else(|| anyhow!("Invalid handle: stale export uid {} (no such export)", uid)) + } +} + +/// Result of a successful [`MultiExportFilesystem::remove_export`]. +/// +/// Both fields are read off the same locked snapshot that performs the +/// swap, so the (uid, name) pair always reflects the exact entry that was +/// retired — there is no second `load()` that another mutation could +/// invalidate between the lookup and the swap. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemovedExport { + pub uid: u32, + pub name: String, +} + +/// Result of a successful [`MultiExportFilesystem::update_export`]. +/// +/// Carries the before/after `read_only` pair so callers can render a +/// `from → to` diff without re-loading the snapshot (where another +/// mutation could have flipped the bit again). v1 only mutates +/// `read_only`; when more fields land, mirror them here. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdatedExport { + pub uid: u32, + pub name: String, + pub prev_read_only: bool, + pub new_read_only: bool, +} + +/// Selector used by admin mutations to identify an existing export. +/// +/// V1 ships only by-name and by-uid; the public admin CLI surfaces the +/// by-name form (`arcticwolfctl export remove /data`), while the by-uid +/// form is used by tests and by future audit-log replay flows that have +/// the uid but not necessarily the name. +/// +/// Serialized externally-tagged so the admin wire form is `{"name": "/data"}` +/// or `{"uid": 5}`, matching the CLI's `--name`/`--uid` flag pair. +/// +/// `#[non_exhaustive]` marks this as a forward-compatibility lever: a +/// future selector variant (e.g. by-path or by-handle-prefix) can land +/// without breaking out-of-crate consumers that match on it. Same-crate +/// code (this file's tests, the admin handlers) is unaffected. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum ExportSelector { + Name(String), + Uid(u32), } /// Routes NFS operations to the right backend based on the export uid /// prefix carried in every file handle. -/// -/// Indexes: -/// - `exports`: uid → entry, used to dispatch handle-based operations. -/// - `name_index`: name → uid, used by MOUNT MNT to resolve a path to its -/// root handle. pub struct MultiExportFilesystem { - exports: HashMap, - name_index: HashMap, + /// Atomic-swap snapshot of the live export set. + /// + /// Read path (NFS RPC) calls `load()`, clones the inner + /// `Arc` out, drops the snapshot guard, and only then + /// `.await`s the inner operation — no `Guard` is ever held across an + /// await point, so the writer's `store(Arc::new(_))` cannot stall on a + /// long-running RPC. + /// + /// Write path (`add_export`/`remove_export`/`update_export`) builds a + /// new `ExportsSnapshot` from the current one (cloning the two HashMaps + /// and the `HashSet`) and swaps it in atomically. + state: ArcSwap, + /// Serializes admin mutations against each other. + /// + /// Each of `add_export`/`remove_export`/`update_export` follows a + /// `load → check → build → store` sequence; without a writer-side lock, + /// two concurrent mutations can both pass duplicate-checks against the + /// same `load()`ed snapshot and one's `store` silently overwrites the + /// other. Readers (the `Filesystem` and `ExportRegistry` impls) never + /// touch this lock — they go straight to `state.load()`, so contention + /// is bounded by concurrent admin calls only. + /// + /// `std::sync::Mutex`, not `tokio::sync::Mutex`: the critical section is + /// fully synchronous (snapshot clone + `LocalFilesystem::new`, both sync) + /// and never crosses an `.await`. + write_lock: Mutex<()>, } impl MultiExportFilesystem { @@ -55,8 +277,8 @@ impl MultiExportFilesystem { )); } - let mut entries: HashMap = HashMap::with_capacity(exports.len()); - let mut name_index: HashMap = HashMap::with_capacity(exports.len()); + let mut by_uid: HashMap = HashMap::with_capacity(exports.len()); + let mut by_name: HashMap = HashMap::with_capacity(exports.len()); for export in exports { if export.uid == 0 { @@ -79,17 +301,19 @@ impl MultiExportFilesystem { let entry = ExportEntry { name: export.name.clone(), read_only: export.read_only, + fsal: export.backend.name(), fs: Arc::new(fs), + metrics: Arc::new(ExportMetrics::default()), }; - if entries.insert(export.uid, entry).is_some() { + if by_uid.insert(export.uid, entry).is_some() { return Err(anyhow!( "Duplicate export uid {} for '{}' (Config::validate should have caught this)", export.uid, export.name )); } - if name_index.insert(export.name.clone(), export.uid).is_some() { + if by_name.insert(export.name.clone(), export.uid).is_some() { return Err(anyhow!( "Duplicate export name '{}' (Config::validate should have caught this)", export.name @@ -97,37 +321,363 @@ impl MultiExportFilesystem { } } + let snapshot = ExportsSnapshot { + by_uid, + by_name, + retired_uids: HashSet::new(), + }; Ok(Self { - exports: entries, - name_index, + state: ArcSwap::new(Arc::new(snapshot)), + write_lock: Mutex::new(()), }) } - /// Look up the entry that owns `handle`, decoding the uid prefix. + /// Resolve `handle` against the live snapshot and return clones of the + /// owning backend's `Arc` and its `Arc`. /// - /// Error strings start with `"Invalid handle"` so the per-operation NFS - /// handlers map them to `NFS3ERR_STALE`: `fsstat` and `pathconf` go - /// through [`crate::nfs::error::classify_handle_error`] (which also walks - /// the error chain for `io::ErrorKind::NotFound`), while `read`, `write`, - /// `access`, and `fsinfo` still substring-match `"Invalid handle"` - /// directly. Without that prefix they would fall through to - /// `NFS3ERR_IO`. The wider unification of error mapping is tracked in - /// the FSAL typed-error issue (#28). - fn entry_for_handle(&self, handle: &FileHandle) -> Result<&ExportEntry> { - let uid = handle - .as_slice() - .export_uid() - .ok_or_else(|| anyhow!("Invalid handle: too short to carry an export uid"))?; - self.exports - .get(&uid) - .ok_or_else(|| anyhow!("Invalid handle: stale export uid {} (no such export)", uid)) + /// This is the single chokepoint every single-handle async trait method + /// funnels through: the snapshot `Guard` is dropped when this function + /// returns, so the caller awaits on a plain `Arc` and + /// never holds a guard across an await point. + /// + /// The per-export `requests` counter is bumped here, *after* the handle + /// resolves to a live export but *before* the caller awaits the backend + /// op — so a stale/unknown handle (which errors out above) is not + /// counted against any export, and every dispatched op is. + fn inner_for_handle( + &self, + handle: &FileHandle, + ) -> Result<(Arc, Arc)> { + let snapshot = self.state.load(); + let entry = snapshot.entry_for_handle(handle)?; + entry + .metrics + .requests + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Ok((entry.fs.clone(), entry.metrics.clone())) + } + + /// Resolve a pair of handles for an inherently single-export operation + /// (`rename`, `link`) and return the shared backend. + /// + /// Both handles must decode to the same export uid; cross-export pairs + /// produce an error string containing `"cross-device"` so the NFS + /// dispatchers can map it to `NFS3ERR_XDEV` (RFC 1813 §3.3.14 for + /// RENAME, §3.3.15 for LINK). The shared backend is cloned out before + /// the snapshot guard is dropped, preserving the "no guard across + /// `.await`" invariant the trait methods rely on. + fn inner_for_same_export_pair( + &self, + a: &FileHandle, + b: &FileHandle, + op: &str, + a_label: &str, + b_label: &str, + ) -> Result<(Arc, Arc)> { + let a_uid = a.as_slice().export_uid().ok_or_else(|| { + anyhow!( + "Invalid handle: {} too short to carry an export uid", + a_label + ) + })?; + let b_uid = b.as_slice().export_uid().ok_or_else(|| { + anyhow!( + "Invalid handle: {} too short to carry an export uid", + b_label + ) + })?; + if a_uid != b_uid { + return Err(anyhow!( + "cross-device {} not supported ({} export uid {}, {} export uid {})", + op, + a_label, + a_uid, + b_label, + b_uid + )); + } + let snapshot = self.state.load(); + let entry = snapshot.by_uid.get(&a_uid).ok_or_else(|| { + anyhow!( + "Invalid handle: stale export uid {} (no such export)", + a_uid + ) + })?; + entry + .metrics + .requests + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Ok((entry.fs.clone(), entry.metrics.clone())) + } + + /// Resolve `selector` to the uid of the matching live export. + /// + /// Reads the supplied snapshot rather than re-`load()`ing so admin + /// mutations can do the uid lookup and the consistency checks against + /// the same snapshot they're about to derive a successor from. + fn resolve_selector(snapshot: &ExportsSnapshot, selector: &ExportSelector) -> Result { + match selector { + ExportSelector::Name(name) => snapshot + .by_name + .get(name.as_str()) + .copied() + .ok_or_else(|| anyhow!("No export named {:?}", name)), + ExportSelector::Uid(uid) => { + if snapshot.by_uid.contains_key(uid) { + Ok(*uid) + } else { + Err(anyhow!("No export with uid {}", uid)) + } + } + } + } + + /// Add a new export to the live snapshot. + /// + /// Validates the same invariants `Config::validate()` enforces at + /// startup (uid != 0, name starts with `/`, backend-specific path + /// rules) and additionally rejects uids that were retired earlier in + /// this daemon's lifetime — see `ExportsSnapshot::retired_uids` for why. + /// + /// Rebuilds the snapshot copy-on-write and `store`s it in a single + /// atomic swap; on `Err` the live snapshot is untouched. + /// + /// Serialized against other admin mutations via `write_lock`; readers + /// (NFS RPC handlers) are unaffected. + pub fn add_export(&self, config: &ExportConfig) -> Result<()> { + // Name normalization runs before the write lock so cheap input + // rejections don't serialize behind a slower mutation. + validate_input_invariants(config)?; + + let _guard = self.write_lock.lock().expect("write_lock poisoned"); + let current = self.state.load(); + if current.by_uid.contains_key(&config.uid) { + bail!("Export uid {} is already in use", config.uid); + } + if current.retired_uids.contains(&config.uid) { + bail!( + "Export uid {} was retired this run and cannot be reused until daemon restart", + config.uid + ); + } + if current.by_name.contains_key(&config.name) { + bail!("Export name {:?} is already in use", config.name); + } + + // Build the backend before mutating any indexes so a backend + // initialization failure (path doesn't exist, not a directory, etc.) + // surfaces with the operator's input still rejected and the live + // snapshot unchanged. + let fs = build_backend(config)?; + + let entry = ExportEntry { + name: config.name.clone(), + read_only: config.read_only, + fsal: config.backend.name(), + fs: Arc::new(fs), + // A freshly-added export starts with zeroed counters; a uid + // retired earlier this run can never be re-added (see + // `retired_uids`), so this never silently revives stale metrics. + metrics: Arc::new(ExportMetrics::default()), + }; + + let mut by_uid = current.by_uid.clone(); + let mut by_name = current.by_name.clone(); + by_uid.insert(config.uid, entry); + by_name.insert(config.name.clone(), config.uid); + + let new_snapshot = ExportsSnapshot { + by_uid, + by_name, + retired_uids: current.retired_uids.clone(), + }; + // Drop the read guard before the store so we don't hold it across + // the allocation in `Arc::new` (harmless but tidier). + drop(current); + self.state.store(Arc::new(new_snapshot)); + Ok(()) + } + + /// Remove an export by name or uid. Returns the (uid, name) that were + /// retired; errors if no live export matches. + /// + /// Both fields are sourced from the snapshot loaded *under the write + /// lock*, so the response is guaranteed to describe the exact entry + /// that was swapped out — no second `load()` that another concurrent + /// mutation could race against. + /// + /// Serialized against other admin mutations via `write_lock`; readers + /// (NFS RPC handlers) are unaffected. + pub fn remove_export(&self, selector: &ExportSelector) -> Result { + let _guard = self.write_lock.lock().expect("write_lock poisoned"); + let current = self.state.load(); + let uid = Self::resolve_selector(¤t, selector)?; + + // `unwrap` is safe: `resolve_selector` returns only uids that are + // present in `current.by_uid`. + let name = current.by_uid[&uid].name.clone(); + + let mut by_uid = current.by_uid.clone(); + let mut by_name = current.by_name.clone(); + let mut retired_uids = current.retired_uids.clone(); + by_uid.remove(&uid); + by_name.remove(&name); + retired_uids.insert(uid); + + let new_snapshot = ExportsSnapshot { + by_uid, + by_name, + retired_uids, + }; + drop(current); + self.state.store(Arc::new(new_snapshot)); + Ok(RemovedExport { uid, name }) + } + + /// Dry-run counterpart to [`add_export`]: applies the same input and + /// snapshot-consistency checks but neither acquires the write lock nor + /// mutates state. Returns `Ok(())` if a real `add_export` with this + /// config *would likely succeed* against the snapshot loaded right now. + /// + /// "Likely" because there is an intentional TOCTOU window: another + /// admin mutation could land between this check and a subsequent + /// `add_export` call. Phase 5 accepts this — dry-run is a pre-flight + /// "does this look sane" check, not a transactional reservation. + /// + /// Backend initialization (path exists, is a directory) is exercised + /// the same way `add_export` does it: by constructing the backend and + /// discarding it. The HandleManager allocation is cheap; the goal is + /// that `dry_run_add` cannot return `Ok` for an input that would fail + /// `add_export`. + pub fn dry_run_add(&self, config: &ExportConfig) -> Result<()> { + validate_input_invariants(config)?; + let current = self.state.load(); + if current.by_uid.contains_key(&config.uid) { + bail!("Export uid {} is already in use", config.uid); + } + if current.retired_uids.contains(&config.uid) { + bail!( + "Export uid {} was retired this run and cannot be reused until daemon restart", + config.uid + ); + } + if current.by_name.contains_key(&config.name) { + bail!("Export name {:?} is already in use", config.name); + } + // Exercise the same backend-init path `add_export` will take. The + // returned `LocalFilesystem` is dropped immediately; only the + // existence/dir checks inside `LocalFilesystem::new` matter here. + let _ = build_backend(config)?; + Ok(()) + } + + /// Dry-run counterpart to [`remove_export`]. Same TOCTOU caveat as + /// [`dry_run_add`]. Returns `(uid, name)` of the export that would be + /// removed if the selector resolves; errors otherwise. + pub fn dry_run_remove(&self, selector: &ExportSelector) -> Result<(u32, String)> { + let current = self.state.load(); + let uid = Self::resolve_selector(¤t, selector)?; + let name = current.by_uid[&uid].name.clone(); + Ok((uid, name)) + } + + /// Dry-run counterpart to [`update_export`]. Returns + /// `(uid, name, current_read_only)` for the matched export, so the + /// caller can render a `from → to` diff in the response. + pub fn dry_run_update(&self, selector: &ExportSelector) -> Result<(u32, String, bool)> { + let current = self.state.load(); + let uid = Self::resolve_selector(¤t, selector)?; + let entry = ¤t.by_uid[&uid]; + Ok((uid, entry.name.clone(), entry.read_only)) + } + + /// Update mutable fields on an existing export. + /// + /// V1 only supports flipping `read_only`. Renaming an export or moving + /// its backing path requires more care (handles already issued to + /// clients embed the uid, not the name, so a rename is observable + /// without invalidating handles, but a path change would silently + /// re-route them) and is deferred to a future phase per the issue plan. + /// + /// Returns the (uid, name, prev_read_only, new_read_only) describing the + /// swap, all sourced from the snapshot loaded *under the write lock* so + /// the response cannot disagree with the snapshot that was just stored. + /// + /// Serialized against other admin mutations via `write_lock`; readers + /// (NFS RPC handlers) are unaffected. + pub fn update_export( + &self, + selector: &ExportSelector, + new_read_only: Option, + ) -> Result { + let new_ro = match new_read_only { + Some(ro) => ro, + None => bail!("update_export called with no fields to mutate"), + }; + + let _guard = self.write_lock.lock().expect("write_lock poisoned"); + let current = self.state.load(); + let uid = Self::resolve_selector(¤t, selector)?; + // `unwrap` safe: `resolve_selector` proved the key exists. + let entry = ¤t.by_uid[&uid]; + let name = entry.name.clone(); + let prev_ro = entry.read_only; + + let mut by_uid = current.by_uid.clone(); + // `unwrap` safe: just cloned the map, key still present. + by_uid.get_mut(&uid).unwrap().read_only = new_ro; + + let new_snapshot = ExportsSnapshot { + by_uid, + by_name: current.by_name.clone(), + retired_uids: current.retired_uids.clone(), + }; + drop(current); + self.state.store(Arc::new(new_snapshot)); + Ok(UpdatedExport { + uid, + name, + prev_read_only: prev_ro, + new_read_only: new_ro, + }) + } + + /// Snapshot per-export operational counters, one entry per live export, + /// sorted by uid for deterministic output. Backs the `exports` array of + /// the admin `metrics` command. + /// + /// Each counter is loaded with `Relaxed`; the result is not atomic + /// across exports or against the rest of the metrics snapshot (see + /// [`crate::metrics`]). Retired/removed exports are intentionally + /// absent — v1 keeps no historical metrics for them. + pub fn export_metrics(&self) -> Vec { + use std::sync::atomic::Ordering::Relaxed; + let snapshot = self.state.load(); + let mut uids: Vec = snapshot.by_uid.keys().copied().collect(); + uids.sort_unstable(); + uids.into_iter() + .map(|uid| { + let entry = &snapshot.by_uid[&uid]; + let m = &entry.metrics; + ExportMetricsSnapshot { + uid, + name: entry.name.clone(), + requests: m.requests.load(Relaxed), + bytes_read: m.bytes_read.load(Relaxed), + bytes_written: m.bytes_written.load(Relaxed), + errors: m.errors.load(Relaxed), + } + }) + .collect() } } impl ExportRegistry for MultiExportFilesystem { fn root_handle_for(&self, name: &str) -> Option { - let uid = self.name_index.get(name)?; - let entry = self.exports.get(uid)?; + // Synchronous — no `.await` follows, so holding the guard is safe. + let snapshot = self.state.load(); + let uid = snapshot.by_name.get(name)?; + let entry = snapshot.by_uid.get(uid)?; Some(entry.fs.root_file_handle()) } @@ -135,23 +685,33 @@ impl ExportRegistry for MultiExportFilesystem { // Sort by uid for stable, predictable output (HashMap iteration // order is otherwise nondeterministic and would destabilize logs // and the eventual MOUNT EXPORT response). - let mut uids: Vec = self.exports.keys().copied().collect(); + let snapshot = self.state.load(); + let mut uids: Vec = snapshot.by_uid.keys().copied().collect(); uids.sort_unstable(); uids.into_iter() .map(|uid| { - let entry = &self.exports[&uid]; + let entry = &snapshot.by_uid[&uid]; ExportInfo { name: entry.name.clone(), uid, read_only: entry.read_only, + fsal: entry.fsal.to_string(), } }) .collect() } + fn retired_uids(&self) -> Vec { + let snapshot = self.state.load(); + let mut uids: Vec = snapshot.retired_uids.iter().copied().collect(); + uids.sort_unstable(); + uids + } + fn is_read_only(&self, handle: &FileHandle) -> bool { + let snapshot = self.state.load(); match handle.as_slice().export_uid() { - Some(uid) => self.exports.get(&uid).is_some_and(|e| e.read_only), + Some(uid) => snapshot.by_uid.get(&uid).is_some_and(|e| e.read_only), None => false, } } @@ -163,22 +723,39 @@ impl ExportRegistry for MultiExportFilesystem { #[async_trait] impl Filesystem for MultiExportFilesystem { + // Each method resolves the handle to `(inner backend, per-export + // metrics)` via `inner_for_handle`/`inner_for_same_export_pair` — which + // bump the per-export `requests` counter before the op runs — then tags + // the export's `errors` counter on an `Err` return. `read`/`write` + // additionally credit `bytes_read`/`bytes_written` by the actual byte + // count on success. async fn lookup(&self, dir_handle: &FileHandle, name: &str) -> Result { - self.entry_for_handle(dir_handle)? - .fs - .lookup(dir_handle, name) - .await + let (inner, metrics) = self.inner_for_handle(dir_handle)?; + let r = inner.lookup(dir_handle, name).await; + metrics.record_result(&r); + r } async fn getattr(&self, handle: &FileHandle) -> Result { - self.entry_for_handle(handle)?.fs.getattr(handle).await + let (inner, metrics) = self.inner_for_handle(handle)?; + let r = inner.getattr(handle).await; + metrics.record_result(&r); + r } async fn read(&self, handle: &FileHandle, offset: u64, count: u32) -> Result> { - self.entry_for_handle(handle)? - .fs - .read(handle, offset, count) - .await + use std::sync::atomic::Ordering::Relaxed; + let (inner, metrics) = self.inner_for_handle(handle)?; + match inner.read(handle, offset, count).await { + Ok(data) => { + metrics.bytes_read.fetch_add(data.len() as u64, Relaxed); + Ok(data) + } + Err(err) => { + metrics.errors.fetch_add(1, Relaxed); + Err(err) + } + } } async fn readdir( @@ -187,31 +764,39 @@ impl Filesystem for MultiExportFilesystem { cookie: u64, count: u32, ) -> Result<(Vec, bool)> { - self.entry_for_handle(dir_handle)? - .fs - .readdir(dir_handle, cookie, count) - .await + let (inner, metrics) = self.inner_for_handle(dir_handle)?; + let r = inner.readdir(dir_handle, cookie, count).await; + metrics.record_result(&r); + r } async fn write(&self, handle: &FileHandle, offset: u64, data: &[u8]) -> Result { - self.entry_for_handle(handle)? - .fs - .write(handle, offset, data) - .await + use std::sync::atomic::Ordering::Relaxed; + let (inner, metrics) = self.inner_for_handle(handle)?; + match inner.write(handle, offset, data).await { + Ok(written) => { + metrics.bytes_written.fetch_add(u64::from(written), Relaxed); + Ok(written) + } + Err(err) => { + metrics.errors.fetch_add(1, Relaxed); + Err(err) + } + } } async fn setattr_size(&self, handle: &FileHandle, size: u64) -> Result<()> { - self.entry_for_handle(handle)? - .fs - .setattr_size(handle, size) - .await + let (inner, metrics) = self.inner_for_handle(handle)?; + let r = inner.setattr_size(handle, size).await; + metrics.record_result(&r); + r } async fn setattr_mode(&self, handle: &FileHandle, mode: u32) -> Result<()> { - self.entry_for_handle(handle)? - .fs - .setattr_mode(handle, mode) - .await + let (inner, metrics) = self.inner_for_handle(handle)?; + let r = inner.setattr_mode(handle, mode).await; + metrics.record_result(&r); + r } async fn setattr_owner( @@ -220,38 +805,38 @@ impl Filesystem for MultiExportFilesystem { uid: Option, gid: Option, ) -> Result<()> { - self.entry_for_handle(handle)? - .fs - .setattr_owner(handle, uid, gid) - .await + let (inner, metrics) = self.inner_for_handle(handle)?; + let r = inner.setattr_owner(handle, uid, gid).await; + metrics.record_result(&r); + r } async fn create(&self, dir_handle: &FileHandle, name: &str, mode: u32) -> Result { - self.entry_for_handle(dir_handle)? - .fs - .create(dir_handle, name, mode) - .await + let (inner, metrics) = self.inner_for_handle(dir_handle)?; + let r = inner.create(dir_handle, name, mode).await; + metrics.record_result(&r); + r } async fn remove(&self, dir_handle: &FileHandle, name: &str) -> Result<()> { - self.entry_for_handle(dir_handle)? - .fs - .remove(dir_handle, name) - .await + let (inner, metrics) = self.inner_for_handle(dir_handle)?; + let r = inner.remove(dir_handle, name).await; + metrics.record_result(&r); + r } async fn mkdir(&self, dir_handle: &FileHandle, name: &str, mode: u32) -> Result { - self.entry_for_handle(dir_handle)? - .fs - .mkdir(dir_handle, name, mode) - .await + let (inner, metrics) = self.inner_for_handle(dir_handle)?; + let r = inner.mkdir(dir_handle, name, mode).await; + metrics.record_result(&r); + r } async fn rmdir(&self, dir_handle: &FileHandle, name: &str) -> Result<()> { - self.entry_for_handle(dir_handle)? - .fs - .rmdir(dir_handle, name) - .await + let (inner, metrics) = self.inner_for_handle(dir_handle)?; + let r = inner.rmdir(dir_handle, name).await; + metrics.record_result(&r); + r } async fn rename( @@ -261,36 +846,18 @@ impl Filesystem for MultiExportFilesystem { to_dir_handle: &FileHandle, to_name: &str, ) -> Result<()> { - // Cross-export rename is not supported: both directories must live - // in the same backend so the rename(2) call stays on a single - // filesystem. The error string contains "cross-device" so the NFS - // RENAME handler maps it to NFS3ERR_XDEV (RFC 1813 §3.3.14) rather - // than the catch-all NFS3ERR_IO. - let from_uid = from_dir_handle - .as_slice() - .export_uid() - .ok_or_else(|| anyhow!("Invalid handle: source too short to carry an export uid"))?; - let to_uid = to_dir_handle - .as_slice() - .export_uid() - .ok_or_else(|| anyhow!("Invalid handle: target too short to carry an export uid"))?; - if from_uid != to_uid { - return Err(anyhow!( - "cross-device rename not supported (source export uid {}, target export uid {})", - from_uid, - to_uid - )); - } - let entry = self.exports.get(&from_uid).ok_or_else(|| { - anyhow!( - "Invalid handle: stale export uid {} (no such export)", - from_uid - ) - })?; - entry - .fs + let (inner, metrics) = self.inner_for_same_export_pair( + from_dir_handle, + to_dir_handle, + "rename", + "source", + "target", + )?; + let r = inner .rename(from_dir_handle, from_name, to_dir_handle, to_name) - .await + .await; + metrics.record_result(&r); + r } async fn symlink( @@ -299,14 +866,17 @@ impl Filesystem for MultiExportFilesystem { name: &str, target: &str, ) -> Result { - self.entry_for_handle(dir_handle)? - .fs - .symlink(dir_handle, name, target) - .await + let (inner, metrics) = self.inner_for_handle(dir_handle)?; + let r = inner.symlink(dir_handle, name, target).await; + metrics.record_result(&r); + r } async fn readlink(&self, handle: &FileHandle) -> Result { - self.entry_for_handle(handle)?.fs.readlink(handle).await + let (inner, metrics) = self.inner_for_handle(handle)?; + let r = inner.readlink(handle).await; + metrics.record_result(&r); + r } async fn link( @@ -315,38 +885,18 @@ impl Filesystem for MultiExportFilesystem { dir_handle: &FileHandle, name: &str, ) -> Result { - // Hard links cannot cross filesystems; require both handles to - // live in the same export. The error string contains "cross-device" - // so the NFS LINK handler maps it to NFS3ERR_XDEV (RFC 1813 §3.3.15). - let file_uid = file_handle - .as_slice() - .export_uid() - .ok_or_else(|| anyhow!("Invalid handle: file too short to carry an export uid"))?; - let dir_uid = dir_handle - .as_slice() - .export_uid() - .ok_or_else(|| anyhow!("Invalid handle: dir too short to carry an export uid"))?; - if file_uid != dir_uid { - return Err(anyhow!( - "cross-device link not supported (file export uid {}, dir export uid {})", - file_uid, - dir_uid - )); - } - let entry = self.exports.get(&file_uid).ok_or_else(|| { - anyhow!( - "Invalid handle: stale export uid {} (no such export)", - file_uid - ) - })?; - entry.fs.link(file_handle, dir_handle, name).await + let (inner, metrics) = + self.inner_for_same_export_pair(file_handle, dir_handle, "link", "file", "dir")?; + let r = inner.link(file_handle, dir_handle, name).await; + metrics.record_result(&r); + r } async fn commit(&self, handle: &FileHandle, offset: u64, count: u32) -> Result<()> { - self.entry_for_handle(handle)? - .fs - .commit(handle, offset, count) - .await + let (inner, metrics) = self.inner_for_handle(handle)?; + let r = inner.commit(handle, offset, count).await; + metrics.record_result(&r); + r } async fn mknod( @@ -357,14 +907,17 @@ impl Filesystem for MultiExportFilesystem { mode: u32, rdev: (u32, u32), ) -> Result { - self.entry_for_handle(dir_handle)? - .fs - .mknod(dir_handle, name, file_type, mode, rdev) - .await + let (inner, metrics) = self.inner_for_handle(dir_handle)?; + let r = inner.mknod(dir_handle, name, file_type, mode, rdev).await; + metrics.record_result(&r); + r } async fn fs_stats(&self, handle: &FileHandle) -> Result { - self.entry_for_handle(handle)?.fs.fs_stats(handle).await + let (inner, metrics) = self.inner_for_handle(handle)?; + let r = inner.fs_stats(handle).await; + metrics.record_result(&r); + r } } @@ -402,9 +955,11 @@ mod tests { #[test] fn build_from_config_succeeds_with_two_exports() { let (router, _data, _backup) = build_two_export_router(); - assert_eq!(router.exports.len(), 2); - assert_eq!(router.name_index.get("/data"), Some(&1)); - assert_eq!(router.name_index.get("/backup"), Some(&2)); + let snap = router.state.load(); + assert_eq!(snap.by_uid.len(), 2); + assert_eq!(snap.by_name.get("/data"), Some(&1)); + assert_eq!(snap.by_name.get("/backup"), Some(&2)); + assert!(snap.retired_uids.is_empty()); } #[test] @@ -446,11 +1001,13 @@ mod tests { name: "/data".to_string(), uid: 1, read_only: false, + fsal: "local".to_string(), }, ExportInfo { name: "/backup".to_string(), uid: 2, read_only: true, + fsal: "local".to_string(), }, ] ); @@ -621,4 +1178,452 @@ mod tests { let short: FileHandle = vec![0u8; 3]; assert!(!router.is_read_only(&short)); } + + // --------------------------------------------------------------- + // Phase 4: add_export / remove_export / update_export unit tests. + // --------------------------------------------------------------- + + #[test] + fn add_export_makes_it_visible_in_list_and_routes() { + let (router, _data, _backup) = build_two_export_router(); + let new_dir = TempDir::new().unwrap(); + let cfg = export("/extra", 99, new_dir.path().to_path_buf(), false); + + router.add_export(&cfg).expect("add must succeed"); + + let infos = router.list_exports(); + assert!( + infos + .iter() + .any(|i| i.name == "/extra" && i.uid == 99 && !i.read_only), + "list missing new export: {infos:?}" + ); + + // The new export must be reachable by name; its root handle must + // carry the configured uid in the prefix. + let h = router.root_handle_for("/extra").expect("name must resolve"); + assert_eq!(h.as_slice().export_uid(), Some(99)); + } + + #[test] + fn add_export_rejects_existing_uid() { + let (router, _data, _backup) = build_two_export_router(); + let dup_dir = TempDir::new().unwrap(); + // uid 1 is already taken by /data. + let cfg = export("/other", 1, dup_dir.path().to_path_buf(), false); + + let err = router + .add_export(&cfg) + .expect_err("duplicate uid must fail") + .to_string(); + assert!(err.contains("already in use"), "got: {err}"); + + // Live snapshot is unchanged. + assert_eq!(router.list_exports().len(), 2); + } + + #[test] + fn add_export_rejects_existing_name() { + let (router, _data, _backup) = build_two_export_router(); + let dup_dir = TempDir::new().unwrap(); + // name /data is already taken by uid 1. + let cfg = export("/data", 7, dup_dir.path().to_path_buf(), false); + + let err = router + .add_export(&cfg) + .expect_err("duplicate name must fail") + .to_string(); + assert!(err.contains("already in use"), "got: {err}"); + assert_eq!(router.list_exports().len(), 2); + } + + #[test] + fn add_export_with_invalid_config_errors() { + let (router, _data, _backup) = build_two_export_router(); + let new_dir = TempDir::new().unwrap(); + + // uid 0 is reserved. + let bad_uid = export("/zero", 0, new_dir.path().to_path_buf(), false); + let err = router + .add_export(&bad_uid) + .expect_err("uid 0 must fail") + .to_string(); + assert!(err.contains("uid 0"), "got: {err}"); + + // Name must start with '/'. + let bad_name = export("nope", 11, new_dir.path().to_path_buf(), false); + let err = router + .add_export(&bad_name) + .expect_err("bad name must fail") + .to_string(); + assert!(err.contains("must start with '/'"), "got: {err}"); + + // Local backend path must be absolute. + let bad_path = export("/rel", 12, PathBuf::from("relative/path"), false); + let err = router + .add_export(&bad_path) + .expect_err("relative path must fail") + .to_string(); + assert!(err.contains("must be absolute"), "got: {err}"); + + // None of the failures should have touched the live snapshot. + assert_eq!(router.list_exports().len(), 2); + } + + #[test] + fn remove_export_by_name_disappears_from_list() { + let (router, _data, _backup) = build_two_export_router(); + let removed = router + .remove_export(&ExportSelector::Name("/data".to_string())) + .expect("remove must succeed"); + assert_eq!(removed.uid, 1); + assert_eq!(removed.name, "/data"); + + let infos = router.list_exports(); + assert!(!infos.iter().any(|i| i.name == "/data"), "got: {infos:?}"); + assert_eq!(infos.len(), 1); + assert!(router.root_handle_for("/data").is_none()); + } + + #[test] + fn remove_export_by_uid_disappears_from_list() { + let (router, _data, _backup) = build_two_export_router(); + let removed = router + .remove_export(&ExportSelector::Uid(2)) + .expect("remove must succeed"); + assert_eq!(removed.uid, 2); + assert_eq!(removed.name, "/backup"); + assert!(router.root_handle_for("/backup").is_none()); + } + + #[test] + fn remove_export_moves_uid_to_retired_and_blocks_reuse() { + let (router, _data, _backup) = build_two_export_router(); + router + .remove_export(&ExportSelector::Uid(1)) + .expect("remove must succeed"); + + // Re-adding uid 1, even with a fresh name and path, must fail — + // in-flight client handles still encode uid 1 against the old + // backend. + let new_dir = TempDir::new().unwrap(); + let cfg = export("/new", 1, new_dir.path().to_path_buf(), false); + let err = router + .add_export(&cfg) + .expect_err("retired uid reuse must fail") + .to_string(); + assert!(err.contains("retired"), "got: {err}"); + } + + #[test] + fn remove_export_errors_when_not_found() { + let (router, _data, _backup) = build_two_export_router(); + + let err = router + .remove_export(&ExportSelector::Name("/missing".to_string())) + .expect_err("missing name must fail") + .to_string(); + assert!(err.contains("No export named"), "got: {err}"); + + let err = router + .remove_export(&ExportSelector::Uid(404)) + .expect_err("missing uid must fail") + .to_string(); + assert!(err.contains("No export with uid"), "got: {err}"); + + // Failed lookups leave the live snapshot untouched. + assert_eq!(router.list_exports().len(), 2); + } + + #[test] + fn update_export_flips_read_only_round_trip() { + let (router, _data, _backup) = build_two_export_router(); + let data_root = router.root_handle_for("/data").unwrap(); + let backup_root = router.root_handle_for("/backup").unwrap(); + + // Baseline: /data is rw, /backup is ro. + assert!(!router.is_read_only(&data_root)); + assert!(router.is_read_only(&backup_root)); + + // Flip /data to ro. + let updated = router + .update_export(&ExportSelector::Name("/data".to_string()), Some(true)) + .expect("update must succeed"); + assert_eq!(updated.uid, 1); + assert_eq!(updated.name, "/data"); + assert!(!updated.prev_read_only); + assert!(updated.new_read_only); + assert!(router.is_read_only(&data_root)); + // The other export is untouched. + assert!(router.is_read_only(&backup_root)); + + // Flip /backup to rw via uid selector. + let updated = router + .update_export(&ExportSelector::Uid(2), Some(false)) + .expect("update must succeed"); + assert_eq!(updated.uid, 2); + assert_eq!(updated.name, "/backup"); + assert!(updated.prev_read_only); + assert!(!updated.new_read_only); + assert!(!router.is_read_only(&backup_root)); + + // And list_exports reflects both mutations. + let infos = router.list_exports(); + let data_info = infos.iter().find(|i| i.uid == 1).unwrap(); + let backup_info = infos.iter().find(|i| i.uid == 2).unwrap(); + assert!(data_info.read_only); + assert!(!backup_info.read_only); + } + + #[test] + fn update_export_no_change_args_errors() { + let (router, _data, _backup) = build_two_export_router(); + let err = router + .update_export(&ExportSelector::Name("/data".to_string()), None) + .expect_err("no-op update must fail") + .to_string(); + assert!(err.contains("no fields to mutate"), "got: {err}"); + } + + #[test] + fn update_export_errors_when_not_found() { + let (router, _data, _backup) = build_two_export_router(); + let err = router + .update_export(&ExportSelector::Name("/missing".to_string()), Some(true)) + .expect_err("missing export must fail") + .to_string(); + assert!(err.contains("No export named"), "got: {err}"); + } + + /// A name retired by `remove_export` must be reusable by a fresh `add_export` + /// as long as the new uid is also fresh. Only uids are retired for the + /// lifetime of the daemon — names are not, because no in-flight file handle + /// references the name (handles carry the uid prefix). + #[test] + fn remove_then_readd_same_name_with_fresh_uid_succeeds() { + let (router, _data, _backup) = build_two_export_router(); + + router + .remove_export(&ExportSelector::Name("/data".to_string())) + .expect("remove must succeed"); + + // Re-add /data with a fresh uid — must succeed. + let new_dir = TempDir::new().unwrap(); + let cfg = export("/data", 3, new_dir.path().to_path_buf(), false); + router.add_export(&cfg).expect("readd must succeed"); + + let infos = router.list_exports(); + let data_entries: Vec<_> = infos.iter().filter(|i| i.name == "/data").collect(); + assert_eq!( + data_entries.len(), + 1, + "exactly one /data entry expected, got: {infos:?}" + ); + assert_eq!(data_entries[0].uid, 3); + assert!(!data_entries[0].read_only); + + // And uid retirement is asymmetric: trying to recycle the original + // uid 1 (now retired) must still fail, even under a brand-new name. + let bogus_dir = TempDir::new().unwrap(); + let cfg_retired = export("/elsewhere", 1, bogus_dir.path().to_path_buf(), false); + let err = router + .add_export(&cfg_retired) + .expect_err("retired uid reuse must fail") + .to_string(); + assert!(err.contains("retired"), "got: {err}"); + } + + // --------------------------------------------------------------- + // Export-name normalization (validate_export_name) tests. + // --------------------------------------------------------------- + + #[test] + fn add_export_rejects_name_with_parent_dir_component() { + let (router, _data, _backup) = build_two_export_router(); + let new_dir = TempDir::new().unwrap(); + let cfg = export("/foo/../bar", 50, new_dir.path().to_path_buf(), false); + let err = router + .add_export(&cfg) + .expect_err("'..' must be rejected") + .to_string(); + assert!(err.contains("'..'"), "got: {err}"); + assert_eq!(router.list_exports().len(), 2); + } + + #[test] + fn add_export_rejects_name_with_double_slash() { + let (router, _data, _backup) = build_two_export_router(); + let new_dir = TempDir::new().unwrap(); + let cfg = export("/foo//bar", 51, new_dir.path().to_path_buf(), false); + let err = router + .add_export(&cfg) + .expect_err("'//' must be rejected") + .to_string(); + assert!(err.contains("'//'"), "got: {err}"); + assert_eq!(router.list_exports().len(), 2); + } + + #[test] + fn add_export_rejects_name_with_trailing_slash() { + let (router, _data, _backup) = build_two_export_router(); + let new_dir = TempDir::new().unwrap(); + let cfg = export("/foo/", 52, new_dir.path().to_path_buf(), false); + let err = router + .add_export(&cfg) + .expect_err("trailing '/' must be rejected") + .to_string(); + assert!(err.contains("must not end with '/'"), "got: {err}"); + assert_eq!(router.list_exports().len(), 2); + } + + #[test] + fn add_export_rejects_empty_name() { + let (router, _data, _backup) = build_two_export_router(); + let new_dir = TempDir::new().unwrap(); + let cfg = export("", 53, new_dir.path().to_path_buf(), false); + let err = router + .add_export(&cfg) + .expect_err("empty name must be rejected") + .to_string(); + assert!(err.contains("must not be empty"), "got: {err}"); + assert_eq!(router.list_exports().len(), 2); + } + + #[test] + fn validate_export_name_accepts_root_and_typical_names() { + // Mirrors Config::validate() behavior: "/" is accepted because it + // passes starts_with('/') and our trailing-slash rule special-cases it. + validate_export_name("/").expect("'/' must be accepted"); + validate_export_name("/data").expect("'/data' must be accepted"); + validate_export_name("/srv/nfs/data").expect("nested path must be accepted"); + } + + /// `dry_run_add` must reject inputs that `add_export` would reject for + /// the same reason. Before this test, the dry-run only checked + /// `path.is_absolute()`, so `--dry-run --path /does/not/exist` + /// returned "would succeed" while the real add would then fail with + /// "Failed to canonicalize root path". Now both paths go through + /// `build_backend`, which canonicalizes the root and verifies it's a + /// directory. + #[test] + fn dry_run_add_rejects_path_that_does_not_exist() { + let (router, _data, _backup) = build_two_export_router(); + let cfg = export( + "/missing", + 42, + PathBuf::from("/this/path/does/not/exist/arcticwolf"), + false, + ); + let err = router + .dry_run_add(&cfg) + .expect_err("nonexistent path must fail dry-run") + .to_string(); + assert!( + err.contains("Failed to initialize local backend"), + "got: {err}" + ); + // Live snapshot unchanged. + assert_eq!(router.list_exports().len(), 2); + } + + /// Pin the externally-tagged wire shape of `ExportSelector` — + /// `{"name": "/data"}` / `{"uid": 5}`. The CLI relies on it on the + /// way out and tests + future audit-log replay rely on it on the way in. + #[test] + fn export_selector_wire_format() { + use serde_json::json; + + assert_eq!( + serde_json::to_value(ExportSelector::Name("/data".to_string())).unwrap(), + json!({ "name": "/data" }) + ); + assert_eq!( + serde_json::to_value(ExportSelector::Uid(5)).unwrap(), + json!({ "uid": 5 }) + ); + + let v: ExportSelector = serde_json::from_value(json!({ "name": "/data" })).unwrap(); + assert!(matches!(v, ExportSelector::Name(ref s) if s == "/data")); + let v: ExportSelector = serde_json::from_value(json!({ "uid": 5 })).unwrap(); + assert!(matches!(v, ExportSelector::Uid(5))); + } + + // --------------------------------------------------------------- + // Phase 7: per-export metrics survive update, reset on remove. + // --------------------------------------------------------------- + + /// Bumping a counter then `update_export`ing an unrelated field + /// (read_only) must leave the counter intact — the same + /// `Arc` is carried into the rebuilt snapshot rather than + /// a fresh zeroed one. + #[test] + fn export_metrics_survive_update_export_snapshot_rebuild() { + use std::sync::atomic::Ordering::Relaxed; + let (router, _data, _backup) = build_two_export_router(); + + // Capture uid 1's metrics Arc and bump every counter. + let before = router.state.load().by_uid[&1].metrics.clone(); + before.requests.fetch_add(5, Relaxed); + before.bytes_read.fetch_add(100, Relaxed); + before.bytes_written.fetch_add(40, Relaxed); + before.errors.fetch_add(1, Relaxed); + + // Flip read_only — rebuilds the snapshot copy-on-write. + router + .update_export(&ExportSelector::Uid(1), Some(true)) + .expect("update must succeed"); + + let after = router.state.load().by_uid[&1].metrics.clone(); + assert!( + Arc::ptr_eq(&before, &after), + "update_export must preserve the existing Arc, not reallocate", + ); + assert_eq!(after.requests.load(Relaxed), 5); + assert_eq!(after.bytes_read.load(Relaxed), 100); + assert_eq!(after.bytes_written.load(Relaxed), 40); + assert_eq!(after.errors.load(Relaxed), 1); + + // The public snapshot agrees. + let snap = router.export_metrics(); + let one = snap.iter().find(|e| e.uid == 1).expect("uid 1 present"); + assert_eq!(one.requests, 5); + assert_eq!(one.bytes_read, 100); + assert_eq!(one.bytes_written, 40); + assert_eq!(one.errors, 1); + } + + /// Removing an export drops its metrics; re-adding under the same name + /// (with a fresh uid, since the old one is retired) yields a brand-new + /// zeroed `Arc`. + #[test] + fn export_metrics_reset_on_remove_then_readd() { + use std::sync::atomic::Ordering::Relaxed; + let (router, _data, _backup) = build_two_export_router(); + + let before = router.state.load().by_uid[&1].metrics.clone(); + before.requests.fetch_add(9, Relaxed); + + router + .remove_export(&ExportSelector::Uid(1)) + .expect("remove must succeed"); + + // Re-add the same name with a fresh uid (uid 1 is retired). + let new_dir = TempDir::new().unwrap(); + let cfg = export("/data", 3, new_dir.path().to_path_buf(), false); + router.add_export(&cfg).expect("re-add must succeed"); + + let after = router.state.load().by_uid[&3].metrics.clone(); + assert!( + !Arc::ptr_eq(&before, &after), + "a re-added export must get a fresh Arc", + ); + assert_eq!(after.requests.load(Relaxed), 0); + + let snap = router.export_metrics(); + let three = snap.iter().find(|e| e.uid == 3).expect("uid 3 present"); + assert_eq!(three.requests, 0); + assert_eq!(three.bytes_read, 0); + assert_eq!(three.bytes_written, 0); + assert_eq!(three.errors, 0); + } } diff --git a/src/lib.rs b/src/lib.rs index 0cbf7bf..2619a39 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,13 +5,16 @@ compile_error!("Arctic Wolf NFS server only supports Linux"); // // This library provides the core components for building an NFSv3 server +pub mod admin; pub mod config; pub mod fsal; +pub mod metrics; pub mod mount; pub mod nfs; pub mod portmap; pub mod protocol; pub mod rpc; +pub mod shutdown; // Re-export commonly used types pub use fsal::{FileHandle, Filesystem, LocalFilesystem, MultiExportFilesystem, NfsBackend}; diff --git a/src/main.rs b/src/main.rs index 261d118..a4854ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,29 +1,21 @@ #[cfg(not(target_os = "linux"))] compile_error!("Arctic Wolf NFS server only supports Linux"); -use anyhow::Result; +use anyhow::{Context, Result}; use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::signal::unix::{SignalKind, signal}; +use tokio_util::sync::CancellationToken; +use tracing_subscriber::{EnvFilter, layer::SubscriberExt, reload, util::SubscriberInitExt}; -// `config` is intentionally NOT redeclared with `mod config;` here. It lives -// in the library crate (see `src/lib.rs`) and is consumed from the binary -// via `arcticwolf::config`. This avoids the dual-compilation issue that -// happens when both `lib.rs` and `main.rs` declare the same module (the -// module gets compiled twice into separate types). -// -// The other modules (fsal/mount/nfs/portmap/protocol/rpc) are still -// declared below for backward compatibility — they are the binary's own -// view of the same sources. Migrating those is a separate cleanup; the -// `config` consolidation is what mattered for #26 because the new -// `Config` / `ExportConfig` types crossed the lib boundary. -mod fsal; -mod mount; -mod nfs; -mod portmap; -mod protocol; -mod rpc; - +use arcticwolf::admin; use arcticwolf::config::{self, Config}; -use protocol::v3::portmap::mapping; +use arcticwolf::fsal; +use arcticwolf::metrics::Metrics; +use arcticwolf::portmap; +use arcticwolf::protocol::v3::portmap::mapping; +use arcticwolf::rpc; +use arcticwolf::shutdown::{self, Signal}; /// Portmapper port is fixed at 111 per RFC 1833 const PORTMAP_PORT: u16 = 111; @@ -74,25 +66,156 @@ fn register_services( println!(); } +/// Build the admin server future based on `[admin]` config. +/// +/// When `enabled = false`, returns a future that simply waits for the +/// shutdown token and then resolves `Ok(())`. (A `pending()` here would +/// never resolve, forcing the whole-daemon drain to always hit its timeout +/// when admin is disabled.) When enabled, binds the socket eagerly so any +/// setup failure (missing parent dir, EACCES, stale non-socket at the path) +/// returns from `main()` with a clear error instead of being deferred, then +/// serves until the token is cancelled. +/// +/// The future is boxed `+ Send` so the caller can `tokio::spawn` it +/// alongside the RPC server tasks. +fn build_admin_future( + admin: &config::AdminConfig, + context: Arc, + shutdown: CancellationToken, + in_flight: Arc, +) -> Result> + Send>>> { + if !admin.enabled { + return Ok(Box::pin(async move { + shutdown.cancelled().await; + Ok(()) + })); + } + + println!("Admin server:"); + println!(" Socket: {}", admin.socket_path.display()); + println!(" Mode: {:o}", admin.socket_mode); + println!(); + + let listener = admin::server::bind_admin_socket(&admin.socket_path, admin.socket_mode)?; + let socket_path = admin.socket_path.clone(); + Ok(Box::pin(admin::serve_with_shutdown( + listener, + socket_path, + context, + shutdown, + in_flight, + ))) +} + #[tokio::main] async fn main() -> Result<()> { - // Load configuration first (before tracing init) - let config = Config::load()?; - - // Initialize tracing with configured log level - // Priority: config file -> RUST_LOG env -> "info" - let log_level_str = config.logging.effective_level(); - let log_level = match log_level_str.parse() { - Ok(level) => level, - Err(_) => { + // Capture the process start instant up front so the admin `status` + // command can report an accurate uptime. + let process_start_instant = std::time::Instant::now(); + + // Load configuration first (before tracing init). Wrapped in an `Arc` + // so it can be shared with the admin context without a deep copy. + let config = Arc::new(Config::load()?); + + // Initialize tracing with the configured log filter. + // Priority: config file -> RUST_LOG env -> "info". + // + // The filter is an `EnvFilter` wrapped in a `reload::Layer` so the admin + // `log-level` commands can swap it at runtime; `log_reload` is threaded + // into the `AdminContext` below. The `EnvFilter` layer filters globally + // for the registry, so the `fmt` layer only sees events that pass it. + let log_filter_str = config.logging.effective_level(); + let env_filter = match EnvFilter::try_new(&log_filter_str) { + Ok(filter) => filter, + Err(err) => { eprintln!( - "Warning: Invalid log level '{}', falling back to 'info'", - log_level_str + "Warning: invalid log filter '{log_filter_str}': {err}; falling back to 'info'" ); - tracing::Level::INFO + EnvFilter::new("info") } }; - tracing_subscriber::fmt().with_max_level(log_level).init(); + // The directive that actually took effect — captured here because + // `env_filter` is moved into the reload layer on the next line. The + // startup banner surfaces it to operators; the admin `status` command + // reads the live value through the reload handle instead. + let effective_log_filter = env_filter.to_string(); + let (filter_layer, log_reload) = reload::Layer::new(env_filter); + tracing_subscriber::registry() + .with(filter_layer) + .with(tracing_subscriber::fmt::layer()) + .init(); + + // === Graceful shutdown wiring (issue #25 Phase 8) === + // + // Install the signal handlers as early as possible — immediately after + // tracing init (so the watch task's `tracing::info!` isn't dropped, per + // the logging-order rule) and *before* any side-effecting init (FSAL + // build, port binds, audit-file open, admin-socket bind). A SIGTERM that + // arrives during init must be caught and turned into a clean shutdown + // rather than killing the process with the default action — which would + // leave half-bound listeners and possibly an orphaned admin socket file + // behind. (Fix 3) + // + // We deliberately do *not* try to abort init when a signal lands mid-init: + // init runs to completion, the four server tasks are spawned as usual, and + // each one observes the already-cancelled token on its first select! and + // breaks out of its accept loop before taking a single connection. The + // drain then joins them immediately (0 in-flight), and the normal + // audit-flush + socket-cleanup tail runs. Because that tail only runs after + // init has finished, `audit` always exists by the time we touch it — there + // is no early-exit path that could reach cleanup before `audit` is built, + // so no guarding is required. + // + // A single `CancellationToken` fans the signal out to all four accept + // loops. We chose `tokio_util::sync::CancellationToken` over a + // `tokio::sync::watch`: it is already on the dependency tree, its + // `cancelled()` future is exactly the shape each accept loop's + // `tokio::select!` arm wants, and `cancel()` is idempotent and broadcast + // to every clone — no `*borrow()`/`changed()` dance. + // + // Flow: the first SIGTERM/SIGINT cancels the token, so every server stops + // accepting and drains its in-flight tasks (bounded by `[shutdown] + // drain_timeout_seconds`); then the audit writer is flushed and the admin + // socket file removed; finally the process exits 0. A *second* signal while + // draining escalates to an immediate `std::process::exit` with the + // conventional `128 + signum` code (130 for SIGINT, 143 for SIGTERM). + let shutdown_token = CancellationToken::new(); + + // Install the signal handlers up front so a `signal()` failure is a clear + // startup error (propagated via `?`) rather than a silent drop inside a + // spawned task. + let mut sigterm = signal(SignalKind::terminate()).context("install SIGTERM handler")?; + let mut sigint = signal(SignalKind::interrupt()).context("install SIGINT handler")?; + { + let shutdown_token = shutdown_token.clone(); + tokio::spawn(async move { + // First signal: begin graceful shutdown. + tokio::select! { + _ = sigterm.recv() => tracing::info!("received SIGTERM, beginning graceful shutdown"), + _ = sigint.recv() => tracing::info!("received SIGINT, beginning graceful shutdown"), + } + shutdown_token.cancel(); + // Second signal: abandon the drain and exit immediately. This + // `process::exit` is why the escalation path can't be unit-tested + // in-process (see `arcticwolf::shutdown` tests, which cover the + // pure exit-code mapping instead). + tokio::select! { + _ = sigterm.recv() => { + tracing::warn!("second signal (SIGTERM) during drain, exiting immediately"); + std::process::exit(Signal::Terminate.escalation_exit_code()); + } + _ = sigint.recv() => { + tracing::warn!("second signal (SIGINT) during drain, exiting immediately"); + std::process::exit(Signal::Interrupt.escalation_exit_code()); + } + } + }); + } + + // Process-wide in-flight handler counter, shared by all four servers so + // the drain-timeout warn can report the aggregate still-running count. + // (Fix 9) + let in_flight = Arc::new(shutdown::InFlight::new()); println!("Arctic Wolf NFS Server"); println!("======================"); @@ -108,7 +231,7 @@ async fn main() -> Result<()> { } ); println!(" NFS port: {}", config.server.nfs_port); - println!(" Log level: {}", log_level_str); + println!(" Log level: {}", effective_log_filter); println!(); // Initialize FSAL (File System Abstraction Layer). @@ -119,9 +242,19 @@ async fn main() -> Result<()> { // still consumes the wrapper as `&dyn Filesystem`. println!("Initializing FSAL:"); - let filesystem: Arc = Arc::new( - fsal::MultiExportFilesystem::build_from_config(&config.exports)?, - ); + // The concrete `MultiExportFilesystem` is shared with both the RPC + // servers (as `Arc`) and the admin context (as the + // concrete type, so admin handlers can call the inherent mutators). + let multi_export = Arc::new(fsal::MultiExportFilesystem::build_from_config( + &config.exports, + )?); + let filesystem: Arc = multi_export.clone(); + + // Phase 7: single in-memory metrics registry, constructed once and + // shared (by `Arc` clone) with every RPC server and the admin context. + // Per-export counters live on each `ExportEntry` inside the filesystem + // router, so they are not threaded through here. + let metrics = Arc::new(Metrics::new()); let exports = filesystem.list_exports(); for export in &exports { @@ -156,6 +289,7 @@ async fn main() -> Result<()> { registry.clone(), filesystem.clone(), vec![100000], // PORTMAP only + metrics.clone(), ) .await?; @@ -164,6 +298,7 @@ async fn main() -> Result<()> { registry.clone(), filesystem.clone(), vec![100005], // MOUNT only + metrics.clone(), ) .await?; @@ -175,6 +310,7 @@ async fn main() -> Result<()> { registry.clone(), filesystem.clone(), vec![100003], // NFS only + metrics.clone(), ) .await?; @@ -186,18 +322,143 @@ async fn main() -> Result<()> { config.server.nfs_port as u32, ); - // Run all three servers concurrently - tokio::select! { - result = portmap_server.serve() => { - result?; - } - result = mount_server.serve() => { - result?; - } - result = nfs_server.serve() => { - result?; + // Admin Unix-domain-socket server (issue #25). + // + // When `[admin] enabled = false` (the default) we deliberately do not + // bind a socket or spawn any task — `admin_future` becomes a + // never-resolving sentinel so it's selectable with the other servers + // but never wakes the select!. + // + // The admin `status` command reports the *actual* listening ports, so + // they are read back from the bound listeners here — a configured port + // of 0 means the OS picked one. + let server_metadata = Arc::new(admin::ServerMetadata { + nfs_port: nfs_server.local_port()?, + mount_port: mount_server.local_port()?, + portmap_port: portmap_server.local_port()?, + }); + + // Phase 6: build the audit writer based on `[audit]` config. When + // disabled (the default) we get a `NoopAuditWriter` so the dispatch + // path can `record(event)` without branching on whether audit is on. + // When enabled, opening the file fails fast — the daemon refuses to + // start with audit configured but unwritable. + let audit_writer: Arc = if config.audit.enabled { + let path = config + .audit + .path + .as_ref() + .expect("validate() guarantees audit.path is set when audit.enabled is true"); + println!("Audit log:"); + println!(" Path: {}", path.display()); + println!(); + Arc::new(admin::FileAuditWriter::open(path)?) + } else { + Arc::new(admin::NoopAuditWriter) + }; + // Keep a clone for the shutdown flush. The other clone is moved into the + // admin context below; on shutdown `shutdown()` closes the channel and + // joins the writer task regardless of how many `Arc` clones remain (see + // `FileAuditWriter::shutdown`). + let audit_for_shutdown = audit_writer.clone(); + + // The admin context shares the daemon's single `MultiExportFilesystem` + // instance with the RPC servers, so the admin `status` command and the + // NFS path always observe the same set of exports. + let admin_context = admin::AdminContext::shared( + process_start_instant, + server_metadata, + log_reload, + multi_export.clone(), + config.clone(), + audit_writer, + metrics.clone(), + ); + + // Spawn all four servers, each holding a clone of the shutdown token (set + // up early, above) and the shared in-flight counter. If a signal already + // fired during init the token is cancelled, so each server breaks out of + // its accept loop on the first select! without taking a connection. + let portmap_handle = { + let token = shutdown_token.clone(); + let in_flight = in_flight.clone(); + tokio::spawn(async move { portmap_server.serve(token, in_flight).await }) + }; + let mount_handle = { + let token = shutdown_token.clone(); + let in_flight = in_flight.clone(); + tokio::spawn(async move { mount_server.serve(token, in_flight).await }) + }; + let nfs_handle = { + let token = shutdown_token.clone(); + let in_flight = in_flight.clone(); + tokio::spawn(async move { nfs_server.serve(token, in_flight).await }) + }; + let admin_handle = { + let admin_future = build_admin_future( + &config.admin, + admin_context, + shutdown_token.clone(), + in_flight.clone(), + )?; + tokio::spawn(admin_future) + }; + + // Block until the shutdown signal fires (the signal task cancels the + // token). Until then every server is busy in its own task. + shutdown_token.cancelled().await; + let drain_start_instant = Instant::now(); + tracing::info!("shutdown signal received, draining in-flight requests"); + + // Drain every server, bounded by the configured timeout. The join + // collects each server's exit status; errors are logged (not + // propagated) because we're already shutting down and want the other + // servers to finish regardless. If the timeout fires, `drain` is dropped + // — detaching (not aborting) the still-running tasks — and the daemon + // exits anyway with status 0. + let drain_timeout = Duration::from_secs(config.shutdown.drain_timeout_seconds); + let drain = async { + let (portmap_res, mount_res, nfs_res, admin_res) = + tokio::join!(portmap_handle, mount_handle, nfs_handle, admin_handle); + for (label, res) in [ + ("portmap", portmap_res), + ("mount", mount_res), + ("nfs", nfs_res), + ("admin", admin_res), + ] { + match res { + Ok(Ok(())) => {} + Ok(Err(err)) => { + tracing::warn!( + server = label, + "server exited with error during shutdown: {err:#}" + ) + } + Err(join_err) => { + tracing::warn!( + server = label, + "server task panicked during shutdown: {join_err}" + ) + } + } } + }; + let _ = shutdown::drain_with_timeout(drain, drain_timeout, &in_flight).await; + + // Flush the audit writer: closes its channel and joins the writer task so + // every queued event reaches disk. A no-op for the `NoopAuditWriter`. + audit_for_shutdown.shutdown().await; + + // Fallback admin-socket cleanup. `serve_with_shutdown` already removes + // the socket on a clean drain; this covers the timeout path where that + // task may not have returned. Best-effort — the file is usually gone. + if config.admin.enabled { + let _ = std::fs::remove_file(&config.admin.socket_path); } + tracing::info!( + total_ms = drain_start_instant.elapsed().as_millis(), + "shutdown complete" + ); Ok(()) } diff --git a/src/metrics.rs b/src/metrics.rs new file mode 100644 index 0000000..af8ec76 --- /dev/null +++ b/src/metrics.rs @@ -0,0 +1,640 @@ +//! In-memory operational metrics (issue #25, Phase 7). +//! +//! A single [`Metrics`] value, constructed once at startup and shared as an +//! `Arc`, holds every operational counter the daemon exposes via +//! the admin `metrics` command. There is no HTTP scrape endpoint and no +//! external metrics crate — counters are plain [`AtomicU64`]s bumped with +//! [`Ordering::Relaxed`]. +//! +//! ## Semantics +//! +//! - **Monotonic within a daemon lifetime.** Every counter only ever +//! increases while the process runs; nothing decrements. Errors bump an +//! error counter *in addition to* the op/request counter they belong to, +//! they never roll one back. +//! - **Reset on restart.** Counters live only in memory. A daemon restart +//! starts every counter back at zero; there is no persistence. +//! - **Lock-free, non-atomic snapshots.** [`Metrics::snapshot_json`] loads +//! each counter independently with `Relaxed`. The resulting JSON is NOT a +//! globally consistent point-in-time view: a request counted in +//! `rpc_requests_total` may not yet be reflected in the per-op or +//! per-export counter it also touches, and vice versa. This is +//! deliberate — taking a global lock to make the snapshot atomic would +//! serialize the hot NFS path against the rare admin read. Operators may +//! observe `rpc_requests_total < sum(nfs_ops)` (or vice versa) when +//! reading mid-request — counters are loaded in sequence and an in-flight +//! RPC can advance one before the other is observed. +//! - **Per-export metrics are dropped on remove.** Each export's +//! [`ExportMetrics`] lives behind an `Arc` on its export entry (see +//! [`crate::fsal::multi_export`]). The same `Arc` is preserved across +//! `update_export` snapshot rebuilds, so unrelated admin mutations don't +//! reset an export's counters. But when an export is *removed*, its entry +//! — and therefore its `Arc` — is dropped once no in-flight +//! request still holds a clone. v1 keeps no historical metrics for removed +//! exports. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::RwLock; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::admin::request::{KNOWN_ADMIN_COMMAND_TAGS, UNKNOWN_ADMIN_COMMAND_TAG}; + +/// Top-level metrics registry, constructed once and shared via `Arc`. +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct Metrics { + /// Server-wide RPC counters (across portmap, mount, and NFS). + pub server: ServerMetrics, + /// Per-NFSv3-procedure invocation counters. + pub nfs_ops: NfsOpsMetrics, + /// Admin-command counters. + pub admin: AdminMetrics, +} + +impl Metrics { + /// Construct a fresh registry with every counter initialized to zero. + pub fn new() -> Self { + Self::default() + } + + /// Build the JSON snapshot returned by the admin `metrics` command. + /// + /// Shape: + /// ```json + /// { + /// "server": { "uptime_seconds": .., "rpc_requests_total": .., "rpc_errors_total": .., "rpc_decode_errors_total": .. }, + /// "nfs_ops": { "getattr": .., "read": .., ... }, + /// "exports": [ { "uid": .., "name": .., "requests": .., "bytes_read": .., "bytes_written": .., "errors": .. } ], + /// "admin": { "commands_total": .., "by_command": { "status": .., ... } } + /// } + /// ``` + /// + /// `uptime_seconds` and `exports` are supplied by the caller (the + /// uptime comes from `AdminContext::start_time`, the per-export + /// snapshots from the filesystem) because they live outside this + /// registry. Every counter is loaded with `Relaxed`; see the module + /// doc on why the result is not cross-counter atomic. + pub fn snapshot_json(&self, uptime_seconds: u64, exports: Vec) -> Value { + json!({ + "server": { + "uptime_seconds": uptime_seconds, + "rpc_requests_total": self.server.rpc_requests_total.load(Ordering::Relaxed), + "rpc_errors_total": self.server.rpc_errors_total.load(Ordering::Relaxed), + "rpc_decode_errors_total": self.server.rpc_decode_errors_total.load(Ordering::Relaxed), + }, + "nfs_ops": self.nfs_ops.to_json(), + "exports": exports, + "admin": self.admin.to_json(), + }) + } +} + +/// Server-wide RPC counters, bumped in [`crate::rpc::server`] for every +/// program the daemon serves. +/// +/// Three counters partition the lifecycle of an inbound RPC: +/// +/// - `rpc_requests_total` — RPCs whose call header *decoded successfully*. +/// This is the denominator for "requests the server understood enough to +/// route", regardless of whether routing or the handler then succeeded. +/// - `rpc_errors_total` — decoded RPCs that were *accepted but failed* +/// downstream (unknown program, malformed auth, handler `Err`). A request +/// counted here was always first counted in `rpc_requests_total`. +/// - `rpc_decode_errors_total` — frames dropped *before* decode, i.e. the +/// call header itself was undecodable. These never reach +/// `rpc_requests_total` (we never learned program/proc) and are therefore +/// tracked separately rather than folded into `rpc_errors_total`. +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct ServerMetrics { + /// Every RPC request whose call header decoded successfully (any + /// program). Malformed frames that fail to decode are NOT counted here; + /// they bump [`rpc_decode_errors_total`](Self::rpc_decode_errors_total) + /// instead. + pub rpc_requests_total: AtomicU64, + /// Every decoded RPC whose routing or handler returned an error to the + /// client. Does NOT include logical NFS errors returned in-band as + /// `nfsstat3` codes (NFS3ERR_PERM, NFS3ERR_NOENT, etc.); only RPC-layer + /// errors (unknown program, malformed auth, handler `Err`). The + /// pre-decode case is covered by + /// [`rpc_decode_errors_total`](Self::rpc_decode_errors_total), which + /// applies before this counter could. + pub rpc_errors_total: AtomicU64, + /// Every inbound frame whose RPC call header failed to deserialize, so + /// the server never learned which program/procedure it targeted (and + /// replied PROG_UNAVAIL). Disjoint from `rpc_requests_total` (which only + /// counts successful decodes) and from `rpc_errors_total` (which counts + /// decoded-but-failed requests). + pub rpc_decode_errors_total: AtomicU64, +} + +/// One [`AtomicU64`] per NFSv3 procedure the dispatcher routes on. +/// +/// A named-field struct rather than a `HashMap`. [`record`](Self::record) +/// dispatches on the NFSv3 procedure number to bump the matching counter. +/// The proc-number → field mapping is convention-enforced via the `match` +/// arm in `record`: adding a new procedure to the dispatcher requires *both* +/// adding a field to this struct *and* adding an arm to `record` (and a key +/// to [`to_json`](Self::to_json)). Adding only one and not the other will +/// silently miscount — there is no compile-time check that the three stay in +/// lockstep. +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct NfsOpsMetrics { + pub null: AtomicU64, + pub getattr: AtomicU64, + pub setattr: AtomicU64, + pub lookup: AtomicU64, + pub access: AtomicU64, + pub readlink: AtomicU64, + pub read: AtomicU64, + pub write: AtomicU64, + pub create: AtomicU64, + pub mkdir: AtomicU64, + pub symlink: AtomicU64, + pub mknod: AtomicU64, + pub remove: AtomicU64, + pub rmdir: AtomicU64, + pub rename: AtomicU64, + pub link: AtomicU64, + pub readdir: AtomicU64, + pub readdirplus: AtomicU64, + pub fsstat: AtomicU64, + pub fsinfo: AtomicU64, + pub pathconf: AtomicU64, + pub commit: AtomicU64, +} + +impl NfsOpsMetrics { + /// Bump the counter for NFSv3 procedure number `proc_`. Procedure + /// numbers outside the dispatched set (RFC 1813 §3) are ignored — the + /// dispatcher answers those with `NFS3ERR_NOTSUPP` and they have no + /// counter of their own. + pub fn record(&self, proc_: u32) { + let counter = match proc_ { + 0 => &self.null, + 1 => &self.getattr, + 2 => &self.setattr, + 3 => &self.lookup, + 4 => &self.access, + 5 => &self.readlink, + 6 => &self.read, + 7 => &self.write, + 8 => &self.create, + 9 => &self.mkdir, + 10 => &self.symlink, + 11 => &self.mknod, + 12 => &self.remove, + 13 => &self.rmdir, + 14 => &self.rename, + 15 => &self.link, + 16 => &self.readdir, + 17 => &self.readdirplus, + 18 => &self.fsstat, + 19 => &self.fsinfo, + 20 => &self.pathconf, + 21 => &self.commit, + _ => return, + }; + counter.fetch_add(1, Ordering::Relaxed); + } + + /// Snapshot every procedure counter into a JSON object keyed by the + /// lowercase procedure name. + fn to_json(&self) -> Value { + json!({ + "null": self.null.load(Ordering::Relaxed), + "getattr": self.getattr.load(Ordering::Relaxed), + "setattr": self.setattr.load(Ordering::Relaxed), + "lookup": self.lookup.load(Ordering::Relaxed), + "access": self.access.load(Ordering::Relaxed), + "readlink": self.readlink.load(Ordering::Relaxed), + "read": self.read.load(Ordering::Relaxed), + "write": self.write.load(Ordering::Relaxed), + "create": self.create.load(Ordering::Relaxed), + "mkdir": self.mkdir.load(Ordering::Relaxed), + "symlink": self.symlink.load(Ordering::Relaxed), + "mknod": self.mknod.load(Ordering::Relaxed), + "remove": self.remove.load(Ordering::Relaxed), + "rmdir": self.rmdir.load(Ordering::Relaxed), + "rename": self.rename.load(Ordering::Relaxed), + "link": self.link.load(Ordering::Relaxed), + "readdir": self.readdir.load(Ordering::Relaxed), + "readdirplus": self.readdirplus.load(Ordering::Relaxed), + "fsstat": self.fsstat.load(Ordering::Relaxed), + "fsinfo": self.fsinfo.load(Ordering::Relaxed), + "pathconf": self.pathconf.load(Ordering::Relaxed), + "commit": self.commit.load(Ordering::Relaxed), + }) + } +} + +/// Per-export counters. Lives behind an `Arc` on each export entry so it +/// survives `update_export` snapshot rebuilds (the same `Arc` is cloned +/// into the new snapshot) and is dropped on `remove_export`. +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct ExportMetrics { + /// Filesystem operations routed to this export (bumped before the call). + pub requests: AtomicU64, + /// Bytes returned by successful `read` calls. + pub bytes_read: AtomicU64, + /// Bytes accepted by successful `write` calls. + pub bytes_written: AtomicU64, + /// Operations that returned an error from the backend. + pub errors: AtomicU64, +} + +impl ExportMetrics { + /// Bump [`errors`](Self::errors) if `result` is `Err`. Convenience for + /// the router's trait-method impls, which all share this tail. + pub fn record_result(&self, result: &Result) { + if result.is_err() { + self.errors.fetch_add(1, Ordering::Relaxed); + } + } +} + +/// Flattened, owned snapshot of one export's counters, tagged with its +/// identity. Built by the filesystem and embedded verbatim in the metrics +/// JSON `exports` array. +#[derive(Debug, Clone, Serialize)] +#[non_exhaustive] +pub struct ExportMetricsSnapshot { + pub uid: u32, + pub name: String, + pub requests: u64, + pub bytes_read: u64, + pub bytes_written: u64, + pub errors: u64, +} + +/// Admin-command counters. +/// +/// `by_command` is a `HashMap` rather than named fields because the tag set +/// includes runtime synthetic tags (``, ``, …) +/// that don't correspond to a typed `AdminRequest` variant. `parking_lot` is +/// not a dependency, so this uses [`std::sync::RwLock`]; the map structure is +/// guarded by the lock, the values are `Arc` bumped lock-free. +/// +/// The map is *bounded and fixed*: it is pre-populated at construction with +/// every entry in [`KNOWN_ADMIN_COMMAND_TAGS`] plus the +/// [`UNKNOWN_ADMIN_COMMAND_TAG`] catch-all bucket, all starting at 0, and no +/// keys are ever inserted afterwards. A client-supplied tag that isn't in the +/// allowlist is counted under `` rather than allocating a +/// fresh entry, so a hostile client can't grow the map unboundedly. A +/// pleasant side effect: the snapshot always lists every known command, so an +/// operator can see at-a-glance which commands have never been used. +#[derive(Debug)] +#[non_exhaustive] +pub struct AdminMetrics { + /// Every admin request the server accepts, including malformed ones. + pub commands_total: AtomicU64, + /// Per-command-tag counters, keyed by the same tag the audit log uses. + /// Pre-populated with all known tags; never gains new keys at runtime. + pub by_command: RwLock>>, +} + +impl Default for AdminMetrics { + fn default() -> Self { + Self::new() + } +} + +impl AdminMetrics { + /// Construct with `commands_total` at 0 and `by_command` pre-populated: + /// one zeroed counter for every [`KNOWN_ADMIN_COMMAND_TAGS`] entry plus + /// the [`UNKNOWN_ADMIN_COMMAND_TAG`] catch-all. After this, the map's key + /// set is fixed for the lifetime of the value. + pub fn new() -> Self { + let mut map: HashMap> = + HashMap::with_capacity(KNOWN_ADMIN_COMMAND_TAGS.len() + 1); + for tag in KNOWN_ADMIN_COMMAND_TAGS { + map.insert((*tag).to_string(), Arc::new(AtomicU64::new(0))); + } + map.insert( + UNKNOWN_ADMIN_COMMAND_TAG.to_string(), + Arc::new(AtomicU64::new(0)), + ); + Self { + commands_total: AtomicU64::new(0), + by_command: RwLock::new(map), + } + } + + /// Record one admin request tagged `tag`. Always bumps + /// [`commands_total`](Self::commands_total); also bumps the matching + /// per-tag counter, or the [`UNKNOWN_ADMIN_COMMAND_TAG`] bucket if `tag` + /// is not a known command. + /// + /// This only ever takes the *read* lock: every counter the map can hold + /// was inserted at construction, so there is no first-observation write + /// path and no way for a client-supplied tag to allocate a new entry. + pub fn record(&self, tag: &str) { + self.commands_total.fetch_add(1, Ordering::Relaxed); + + let map = self + .by_command + .read() + .unwrap_or_else(|poison| poison.into_inner()); + if let Some(counter) = map.get(tag) { + counter.fetch_add(1, Ordering::Relaxed); + } else { + // `UNKNOWN_ADMIN_COMMAND_TAG` is always inserted by `new`, so this + // lookup cannot miss. + map.get(UNKNOWN_ADMIN_COMMAND_TAG) + .expect("unknown-command bucket is pre-populated by AdminMetrics::new") + .fetch_add(1, Ordering::Relaxed); + } + } + + /// Snapshot the admin counters into a JSON object. + fn to_json(&self) -> Value { + let map = self + .by_command + .read() + .unwrap_or_else(|poison| poison.into_inner()); + let by_command: serde_json::Map = map + .iter() + .map(|(tag, counter)| (tag.clone(), Value::from(counter.load(Ordering::Relaxed)))) + .collect(); + json!({ + "commands_total": self.commands_total.load(Ordering::Relaxed), + "by_command": by_command, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_initializes_every_counter_to_zero() { + let m = Metrics::new(); + let snap = m.snapshot_json(0, Vec::new()); + assert_eq!(snap["server"]["rpc_requests_total"], 0); + assert_eq!(snap["server"]["rpc_errors_total"], 0); + assert_eq!(snap["server"]["rpc_decode_errors_total"], 0); + assert_eq!(snap["server"]["uptime_seconds"], 0); + // Every nfs op present and zero. + for op in [ + "null", + "getattr", + "setattr", + "lookup", + "access", + "readlink", + "read", + "write", + "create", + "mkdir", + "symlink", + "mknod", + "remove", + "rmdir", + "rename", + "link", + "readdir", + "readdirplus", + "fsstat", + "fsinfo", + "pathconf", + "commit", + ] { + assert_eq!(snap["nfs_ops"][op], 0, "nfs_ops.{op} must start at 0"); + } + assert_eq!(snap["admin"]["commands_total"], 0); + // `by_command` is pre-populated with every known tag at 0 (finding + // 10): no key is created at runtime, so all of them are present from + // construction. + for tag in KNOWN_ADMIN_COMMAND_TAGS { + assert_eq!( + snap["admin"]["by_command"][tag], 0, + "known tag {tag} must be present at 0", + ); + } + assert_eq!(snap["admin"]["by_command"][UNKNOWN_ADMIN_COMMAND_TAG], 0); + assert_eq!( + snap["admin"]["by_command"] + .as_object() + .expect("by_command is an object") + .len(), + KNOWN_ADMIN_COMMAND_TAGS.len() + 1, + "by_command must hold exactly the known tags plus the catch-all", + ); + assert_eq!(snap["exports"], json!([])); + } + + #[test] + fn bumping_counters_reflects_in_snapshot() { + let m = Metrics::new(); + m.server.rpc_requests_total.fetch_add(10, Ordering::Relaxed); + m.server.rpc_errors_total.fetch_add(2, Ordering::Relaxed); + m.nfs_ops.record(1); // getattr + m.nfs_ops.record(1); + m.nfs_ops.record(6); // read + + let exports = vec![ExportMetricsSnapshot { + uid: 1, + name: "/data".to_string(), + requests: 3, + bytes_read: 100, + bytes_written: 50, + errors: 1, + }]; + let snap = m.snapshot_json(42, exports); + + assert_eq!(snap["server"]["uptime_seconds"], 42); + assert_eq!(snap["server"]["rpc_requests_total"], 10); + assert_eq!(snap["server"]["rpc_errors_total"], 2); + assert_eq!(snap["nfs_ops"]["getattr"], 2); + assert_eq!(snap["nfs_ops"]["read"], 1); + assert_eq!(snap["nfs_ops"]["write"], 0); + assert_eq!(snap["exports"][0]["uid"], 1); + assert_eq!(snap["exports"][0]["name"], "/data"); + assert_eq!(snap["exports"][0]["requests"], 3); + assert_eq!(snap["exports"][0]["bytes_read"], 100); + assert_eq!(snap["exports"][0]["bytes_written"], 50); + assert_eq!(snap["exports"][0]["errors"], 1); + } + + #[test] + fn record_ignores_unknown_procedure_numbers() { + let m = Metrics::new(); + m.nfs_ops.record(999); + let snap = m.nfs_ops.to_json(); + // Nothing bumped; spot-check a couple of fields stay zero. + assert_eq!(snap["getattr"], 0); + assert_eq!(snap["commit"], 0); + } + + #[test] + fn by_command_bumps_known_tags_in_prepopulated_map() { + let admin = AdminMetrics::default(); + admin.record("status"); + admin.record("status"); + admin.record("exports-add"); + admin.record(""); + + assert_eq!(admin.commands_total.load(Ordering::Relaxed), 4); + let snap = admin.to_json(); + assert_eq!(snap["commands_total"], 4); + assert_eq!(snap["by_command"]["status"], 2); + assert_eq!(snap["by_command"]["exports-add"], 1); + assert_eq!(snap["by_command"][""], 1); + // A never-seen but known tag is present at 0 (pre-populated), not + // absent — finding 10 changed this from the old insert-on-first-use + // behavior. + assert_eq!(snap["by_command"]["version"], 0); + } + + #[test] + fn admin_metrics_buckets_unknown_tag_under_unknown_command() { + let admin = AdminMetrics::new(); + admin.record("definitely-not-a-command"); + admin.record("also-bogus"); + + let snap = admin.to_json(); + assert_eq!(snap["commands_total"], 2); + // Both unknown tags collapse into the catch-all bucket... + assert_eq!(snap["by_command"][UNKNOWN_ADMIN_COMMAND_TAG], 2); + // ...and no key was created for either of them. + assert!(snap["by_command"].get("definitely-not-a-command").is_none()); + assert!(snap["by_command"].get("also-bogus").is_none()); + // `` is distinct from ``. + assert_eq!(snap["by_command"][""], 0); + } + + #[test] + fn admin_metrics_record_never_takes_write_lock() { + let admin = AdminMetrics::new(); + let key_count_before = admin.by_command.read().unwrap().len(); + + // The old behavior inserted a fresh key on first observation (a write + // lock). With finding 10's pre-populated, fixed map, recording an + // unknown tag must instead route to the catch-all bucket and leave the + // key set untouched — observable proof the write path is gone. + admin.record("brand-new-unknown-tag"); + admin.record("another-unknown"); + + let map = admin.by_command.read().unwrap(); + assert_eq!( + map.len(), + key_count_before, + "record must not insert new keys (no write-lock path)", + ); + assert!( + map.get("brand-new-unknown-tag").is_none(), + "the unknown tag must not have been inserted as its own key", + ); + assert_eq!( + map.get(UNKNOWN_ADMIN_COMMAND_TAG) + .unwrap() + .load(Ordering::Relaxed), + 2, + "both unknown tags must have advanced the catch-all bucket", + ); + } + + #[test] + fn record_result_only_bumps_errors_on_err() { + let em = ExportMetrics::default(); + em.record_result::<(), ()>(&Ok(())); + assert_eq!(em.errors.load(Ordering::Relaxed), 0); + em.record_result::<(), ()>(&Err(())); + assert_eq!(em.errors.load(Ordering::Relaxed), 1); + } + + #[test] + fn export_metrics_snapshot_serialization_shape() { + let snap = ExportMetricsSnapshot { + uid: 1, + name: "/data".to_string(), + requests: 0, + bytes_read: 0, + bytes_written: 0, + errors: 0, + }; + assert_eq!( + serde_json::to_value(&snap).unwrap(), + json!({ + "uid": 1, + "name": "/data", + "requests": 0, + "bytes_read": 0, + "bytes_written": 0, + "errors": 0 + }) + ); + } + + /// Golden shape for the full snapshot of a fresh registry: every section + /// present, all server / nfs_ops counters at 0, exports empty, and the + /// admin `by_command` map carrying exactly the pre-populated known tags + /// (finding 10) at 0. Pinning the literal locks both the JSON shape and + /// the "all known keys present from construction" guarantee. + #[test] + fn fresh_metrics_snapshot_golden_shape() { + let snap = Metrics::new().snapshot_json(0, Vec::new()); + assert_eq!( + snap, + json!({ + "server": { + "uptime_seconds": 0, + "rpc_requests_total": 0, + "rpc_errors_total": 0, + "rpc_decode_errors_total": 0, + }, + "nfs_ops": { + "null": 0, + "getattr": 0, + "setattr": 0, + "lookup": 0, + "access": 0, + "readlink": 0, + "read": 0, + "write": 0, + "create": 0, + "mkdir": 0, + "symlink": 0, + "mknod": 0, + "remove": 0, + "rmdir": 0, + "rename": 0, + "link": 0, + "readdir": 0, + "readdirplus": 0, + "fsstat": 0, + "fsinfo": 0, + "pathconf": 0, + "commit": 0, + }, + "exports": [], + "admin": { + "commands_total": 0, + "by_command": { + "status": 0, + "version": 0, + "log-level-get": 0, + "log-level-set": 0, + "exports-list": 0, + "exports-add": 0, + "exports-remove": 0, + "exports-update": 0, + "config-show": 0, + "metrics": 0, + "": 0, + "": 0, + "": 0, + "": 0, + }, + }, + }), + ); + } +} diff --git a/src/mount/export.rs b/src/mount/export.rs index cddcaca..5ddedd0 100644 --- a/src/mount/export.rs +++ b/src/mount/export.rs @@ -85,11 +85,13 @@ mod tests { name: "/data".to_string(), uid: 1, read_only: false, + fsal: "local".to_string(), }, ExportInfo { name: "/backup".to_string(), uid: 2, read_only: true, + fsal: "local".to_string(), }, ]); let call = make_export_call(0xdeadbeef); diff --git a/src/nfs/dispatcher.rs b/src/nfs/dispatcher.rs index 9ecd159..8ef2d97 100644 --- a/src/nfs/dispatcher.rs +++ b/src/nfs/dispatcher.rs @@ -12,6 +12,7 @@ use bytes::BytesMut; use tracing::{debug, warn}; use crate::fsal::NfsBackend; +use crate::metrics::Metrics; use crate::protocol::v3::rpc::rpc_call_msg; use super::{ @@ -25,6 +26,10 @@ use super::{ /// * `call` - Parsed RPC call message /// * `args_data` - Procedure arguments data /// * `backend` - Combined filesystem + export registry view +/// * `metrics` - Operational counters; the matching per-op counter is +/// bumped on entry (Phase 7). The server-wide `rpc_errors_total` is bumped +/// centrally in `crate::rpc::server` when this function returns `Err`, so +/// it is not touched here. /// /// # Returns /// Serialized RPC reply message @@ -32,6 +37,7 @@ pub async fn dispatch( call: &rpc_call_msg, args_data: &[u8], backend: &dyn NfsBackend, + metrics: &Metrics, ) -> Result { let procedure = call.proc_; let xid = call.xid; @@ -47,6 +53,11 @@ pub async fn dispatch( return Err(anyhow!("NFS version {} not supported", call.vers)); } + // Count the op on entry, before the handler runs — an op that the + // handler later rejects (bad args, stale handle) still counts as a + // dispatched invocation of that procedure. + metrics.nfs_ops.record(procedure); + // Dispatch based on procedure number match procedure { 0 => { @@ -156,3 +167,100 @@ fn create_notsupp_response(xid: u32) -> Result { let res_data = BytesMut::from(&buf[..]); crate::protocol::v3::rpc::RpcMessage::create_success_reply_with_data(xid, res_data) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BackendConfig, ExportConfig}; + use crate::fsal::MultiExportFilesystem; + use crate::protocol::v3::rpc::RpcMessage; + use std::sync::atomic::Ordering::Relaxed; + use tempfile::TempDir; + + /// Hand-roll an NFSv3 RPC call header for `proc_` and round-trip it + /// through `deserialize_call` so the test stays independent of the + /// xdrgen field naming (mirrors `mount::mnt`'s `make_mnt_call`). + fn make_nfs_call(proc_: u32) -> rpc_call_msg { + let mut buf = Vec::with_capacity(48); + buf.extend_from_slice(&0u32.to_be_bytes()); // xid + buf.extend_from_slice(&0u32.to_be_bytes()); // mtype = CALL + buf.extend_from_slice(&2u32.to_be_bytes()); // rpcvers + buf.extend_from_slice(&100003u32.to_be_bytes()); // NFS program + buf.extend_from_slice(&3u32.to_be_bytes()); // NFS v3 + buf.extend_from_slice(&proc_.to_be_bytes()); + // cred + verf: both AUTH_NONE with empty body. + for _ in 0..4 { + buf.extend_from_slice(&0u32.to_be_bytes()); + } + RpcMessage::deserialize_call(&buf).expect("synthetic call must deserialize") + } + + /// A real single-export `MultiExportFilesystem` rooted at a tempdir. The + /// per-op counter is bumped before the handler runs, so the handlers can + /// fail on the empty arg buffer without affecting what we assert. + fn test_backend() -> (MultiExportFilesystem, TempDir) { + let dir = TempDir::new().expect("tempdir"); + let exports = vec![ExportConfig { + name: "/data".to_string(), + uid: 1, + read_only: false, + backend: BackendConfig::Local { + path: dir.path().to_path_buf(), + }, + }]; + let fs = MultiExportFilesystem::build_from_config(&exports).expect("build backend"); + (fs, dir) + } + + #[tokio::test] + async fn dispatch_bumps_per_op_counters() { + let (backend, _dir) = test_backend(); + let metrics = Metrics::new(); + + // 3 GETATTR (proc 1), 2 READ (proc 6). Empty args make the handlers + // themselves error, but the op counter is bumped on entry — which is + // exactly the contract under test. + for _ in 0..3 { + let call = make_nfs_call(1); + let _ = dispatch(&call, &[], &backend, &metrics).await; + } + for _ in 0..2 { + let call = make_nfs_call(6); + let _ = dispatch(&call, &[], &backend, &metrics).await; + } + + assert_eq!(metrics.nfs_ops.getattr.load(Relaxed), 3); + assert_eq!(metrics.nfs_ops.read.load(Relaxed), 2); + assert_eq!(metrics.nfs_ops.write.load(Relaxed), 0); + + // NULL (proc 0) is the one path that succeeds with empty args. + let call = make_nfs_call(0); + dispatch(&call, &[], &backend, &metrics) + .await + .expect("NULL must succeed"); + assert_eq!(metrics.nfs_ops.null.load(Relaxed), 1); + } + + #[tokio::test] + async fn dispatch_rejects_non_v3_without_counting() { + let (backend, _dir) = test_backend(); + let metrics = Metrics::new(); + // proc 1 (GETATTR) but version is forced to 2 by editing the call — + // simpler to just build a v2 header. + let mut buf = Vec::with_capacity(48); + buf.extend_from_slice(&0u32.to_be_bytes()); // xid + buf.extend_from_slice(&0u32.to_be_bytes()); // mtype + buf.extend_from_slice(&2u32.to_be_bytes()); // rpcvers + buf.extend_from_slice(&100003u32.to_be_bytes()); + buf.extend_from_slice(&2u32.to_be_bytes()); // NFS vers = 2 (unsupported) + buf.extend_from_slice(&1u32.to_be_bytes()); // GETATTR + for _ in 0..4 { + buf.extend_from_slice(&0u32.to_be_bytes()); + } + let call = RpcMessage::deserialize_call(&buf).expect("deserialize"); + let result = dispatch(&call, &[], &backend, &metrics).await; + assert!(result.is_err(), "NFS v2 must be rejected"); + // Version check precedes the counter bump, so nothing is recorded. + assert_eq!(metrics.nfs_ops.getattr.load(Relaxed), 0); + } +} diff --git a/src/protocol/v3/mount.rs b/src/protocol/v3/mount.rs index 1b31d75..6350a0c 100644 --- a/src/protocol/v3/mount.rs +++ b/src/protocol/v3/mount.rs @@ -122,6 +122,7 @@ mod tests { name: "/data".to_string(), uid: 1, read_only: false, + fsal: "local".to_string(), }]; let bytes = MountMessage::serialize_exports(&exports); @@ -144,11 +145,13 @@ mod tests { name: "/data".to_string(), uid: 1, read_only: false, + fsal: "local".to_string(), }, ExportInfo { name: "/backup".to_string(), uid: 2, read_only: true, + fsal: "local".to_string(), }, ]; let bytes = MountMessage::serialize_exports(&exports); diff --git a/src/rpc/server.rs b/src/rpc/server.rs index 7385724..890727f 100644 --- a/src/rpc/server.rs +++ b/src/rpc/server.rs @@ -7,11 +7,15 @@ use bytes::{BufMut, BytesMut}; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; use crate::fsal::{ExportRegistry, NfsBackend}; +use crate::metrics::Metrics; use crate::portmap::Registry; -use crate::protocol::v3::rpc::RpcMessage; +use crate::protocol::v3::rpc::{RpcMessage, rpc_call_msg}; +use crate::shutdown::{InFlight, InFlightGuard}; /// RPC server handling TCP connections with record marking /// @@ -22,6 +26,7 @@ pub struct RpcServer { registry: Registry, filesystem: Arc, allowed_programs: Vec, + metrics: Arc, } impl RpcServer { @@ -34,6 +39,7 @@ impl RpcServer { registry: Registry, filesystem: Arc, allowed_programs: Vec, + metrics: Arc, ) -> Result { let listener = TcpListener::bind(addr).await?; info!("RPC server bound to {}", listener.local_addr()?); @@ -42,6 +48,7 @@ impl RpcServer { registry, filesystem, allowed_programs, + metrics, }) } @@ -52,29 +59,88 @@ impl RpcServer { Ok(self.listener.local_addr()?.port()) } - /// Start accepting connections and serving RPC requests. - pub async fn serve(&self) -> Result<()> { + /// Human-readable label for the single RPC program this instance serves, + /// derived from `allowed_programs`. Used only for shutdown log lines. + fn service_label(&self) -> &'static str { + match self.allowed_programs.first().copied() { + Some(100000) => "portmap", + Some(100005) => "mount", + Some(100003) => "nfs", + _ => "rpc", + } + } + + /// Start accepting connections and serving RPC requests until `shutdown` + /// is cancelled (issue #25 Phase 8). + /// + /// The accept loop races `shutdown.cancelled()` against the next + /// `accept()`: a cancellation breaks the loop so no new connections are + /// taken, while connections already accepted continue to run. Every + /// per-connection handler is tracked in a [`JoinSet`]; after the loop + /// breaks we await the set so in-flight requests drain before returning. + /// The whole-daemon drain timeout lives in `main.rs`, so this await is + /// intentionally unbounded here. + /// + /// Known limitation (follow-up to #25): per-connection handlers do not + /// currently check `shutdown` between requests, so a *connected but idle* + /// client (no traffic, handler parked in `read_exact`) keeps its task + /// alive and blocks the drain until the whole-daemon timeout ceiling fires. + /// Active connections drain promptly; only silent-idle ones hit the + /// ceiling. Nudging idle connections on cancel is tracked as a follow-up. + /// + /// `in_flight` is the process-wide handler counter shared by all four + /// servers; each spawned handler holds an [`InFlightGuard`] so the + /// drain-timeout warn can report the aggregate still-running count. (Fix 9) + pub async fn serve(&self, shutdown: CancellationToken, in_flight: Arc) -> Result<()> { + let label = self.service_label(); info!( "RPC server listening on {} for programs {:?}", self.listener.local_addr()?, self.allowed_programs ); + let mut tasks: JoinSet<()> = JoinSet::new(); loop { - let (socket, peer_addr) = self.listener.accept().await?; - info!("New connection from {}", peer_addr); - - let registry = self.registry.clone(); - let filesystem = self.filesystem.clone(); - let allowed_programs = self.allowed_programs.clone(); - tokio::spawn(async move { - if let Err(e) = - handle_connection(socket, registry, filesystem, &allowed_programs).await - { - error!("Connection error from {}: {}", peer_addr, e); + tokio::select! { + _ = shutdown.cancelled() => break, + accept_result = self.listener.accept() => { + let (socket, peer_addr) = accept_result?; + info!("New connection from {}", peer_addr); + + let registry = self.registry.clone(); + let filesystem = self.filesystem.clone(); + let allowed_programs = self.allowed_programs.clone(); + let metrics = self.metrics.clone(); + // Count this handler in the shared in-flight gauge; the + // guard moves into the task and decrements on completion + // (including panic), so the gauge always settles. (Fix 9) + let guard = InFlightGuard::new(in_flight.clone()); + tasks.spawn(async move { + let _in_flight = guard; + if let Err(e) = handle_connection( + socket, + registry, + filesystem, + &allowed_programs, + metrics, + ) + .await + { + error!("Connection error from {}: {}", peer_addr, e); + } + }); } - }); + } } + + info!( + server = label, + in_flight = tasks.len(), + "shutdown requested, draining in-flight requests" + ); + while tasks.join_next().await.is_some() {} + info!(server = label, "drain complete"); + Ok(()) } } @@ -84,10 +150,15 @@ async fn handle_connection( registry: Registry, filesystem: Arc, allowed_programs: &[u32], + metrics: Arc, ) -> Result<()> { let mut buffer = BytesMut::with_capacity(8192); loop { + // TODO(follow-up to #25): nudge idle connections on cancel. This loop + // blocks in `read_exact` waiting for the next request; it does not race + // the shutdown token, so an idle client holds its handler (and the + // drain) open until the whole-daemon timeout ceiling. // Read record marking fragment header (4 bytes) let mut header = [0u8; 4]; if socket.read_exact(&mut header).await.is_err() { @@ -113,36 +184,40 @@ async fn handle_connection( if is_last { debug!("Complete RPC message received ({} bytes)", buffer.len()); - let response = - match handle_rpc_message(&buffer, ®istry, filesystem.as_ref(), allowed_programs) - .await - { - Ok(response) => response, - Err(e) => { - error!("Failed to handle RPC message: {}", e); - - // Try to parse XID from buffer to send proper error response - if buffer.len() >= 4 { - let xid = - u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]); - - // Send PROG_UNAVAIL error response - match RpcMessage::create_prog_unavail_reply(xid) { - Ok(error_response) => { - warn!("Sending PROG_UNAVAIL error response for xid={}", xid); - error_response - } - Err(serialize_err) => { - error!("Failed to create error response: {}", serialize_err); - continue; // Skip this message and wait for next one - } + let response = match handle_rpc_message( + &buffer, + ®istry, + filesystem.as_ref(), + allowed_programs, + &metrics, + ) + .await + { + Ok(response) => response, + Err(e) => { + error!("Failed to handle RPC message: {}", e); + + // Try to parse XID from buffer to send proper error response + if buffer.len() >= 4 { + let xid = u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]); + + // Send PROG_UNAVAIL error response + match RpcMessage::create_prog_unavail_reply(xid) { + Ok(error_response) => { + warn!("Sending PROG_UNAVAIL error response for xid={}", xid); + error_response + } + Err(serialize_err) => { + error!("Failed to create error response: {}", serialize_err); + continue; // Skip this message and wait for next one } - } else { - error!("Buffer too short to extract XID"); - continue; // Skip this message and wait for next one } + } else { + error!("Buffer too short to extract XID"); + continue; // Skip this message and wait for next one } - }; + } + }; // Send response with record marking // IMPORTANT: Record mark and payload must be sent in a single write() @@ -178,7 +253,10 @@ async fn handle_rpc_message( registry: &Registry, filesystem: &dyn NfsBackend, allowed_programs: &[u32], + metrics: &Metrics, ) -> Result { + use std::sync::atomic::Ordering::Relaxed; + // Debug: dump complete RPC message debug!( "Complete RPC message ({} bytes): {:02x?}", @@ -186,14 +264,50 @@ async fn handle_rpc_message( &data[..data.len().min(100)] ); - // Deserialize RPC call header - let call = RpcMessage::deserialize_call(data)?; + // Deserialize RPC call header. A failure here means the frame's RPC call + // header was undecodable, so we never learned which program/procedure it + // targeted — the caller replies PROG_UNAVAIL. Count it under + // `rpc_decode_errors_total` *before* the early return, so this dropped + // fraction stays visible and disjoint from both `rpc_requests_total` + // (decoded RPCs) and `rpc_errors_total` (decoded-but-failed RPCs). + let call = match RpcMessage::deserialize_call(data) { + Ok(call) => call, + Err(e) => { + metrics.server.rpc_decode_errors_total.fetch_add(1, Relaxed); + return Err(e); + } + }; debug!( "RPC call: xid={}, prog={}, vers={}, proc={}", call.xid, call.prog, call.vers, call.proc_ ); + // A decoded RPC call is a request the server has accepted, regardless of + // which program it targets. Count it here, once, for portmap/mount/NFS + // alike; the per-program error counter is bumped below if routing fails. + metrics.server.rpc_requests_total.fetch_add(1, Relaxed); + + let result = route_rpc_call(&call, data, registry, filesystem, allowed_programs, metrics).await; + if result.is_err() { + metrics.server.rpc_errors_total.fetch_add(1, Relaxed); + } + result +} + +/// Route an already-decoded RPC call to its program handler. +/// +/// Split out of [`handle_rpc_message`] so the request/error counters wrap a +/// single call site: every `Err` this returns (unknown program, malformed +/// auth, downstream handler failure) is counted once as an RPC error. +async fn route_rpc_call( + call: &rpc_call_msg, + data: &[u8], + registry: &Registry, + filesystem: &dyn NfsBackend, + allowed_programs: &[u32], + metrics: &Metrics, +) -> Result { // Check if this program is allowed on this server instance if !allowed_programs.contains(&call.prog) { warn!( @@ -259,18 +373,18 @@ async fn handle_rpc_message( 100000 => { // Portmapper protocol (program 100000) debug!("Routing to PORTMAP protocol handler"); - crate::portmap::handle_portmap_call(&call, args_data, registry) + crate::portmap::handle_portmap_call(call, args_data, registry) } 100005 => { // MOUNT protocol (program 100005) debug!("Routing to MOUNT protocol handler"); - crate::mount::handle_mount_call(&call, args_data, filesystem as &dyn ExportRegistry) + crate::mount::handle_mount_call(call, args_data, filesystem as &dyn ExportRegistry) .await } 100003 => { // NFS protocol (program 100003) debug!("Routing to NFS protocol handler"); - crate::nfs::dispatch(&call, args_data, filesystem).await + crate::nfs::dispatch(call, args_data, filesystem, metrics).await } _ => { warn!("Unknown program number: {}", call.prog); @@ -278,3 +392,50 @@ async fn handle_rpc_message( } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BackendConfig, ExportConfig}; + use crate::fsal::MultiExportFilesystem; + use std::sync::atomic::Ordering::Relaxed; + + /// A frame whose RPC call header can't be decoded must bump + /// `rpc_decode_errors_total` while leaving `rpc_requests_total` (decoded + /// RPCs) and `rpc_errors_total` (decoded-but-failed RPCs) untouched — + /// finding 3. + #[tokio::test] + async fn malformed_rpc_header_bumps_decode_errors_only() { + let tmp = tempfile::tempdir().expect("tempdir"); + let exports = vec![ExportConfig { + name: "/data".to_string(), + uid: 1, + read_only: false, + backend: BackendConfig::Local { + path: tmp.path().to_path_buf(), + }, + }]; + let filesystem: Arc = Arc::new( + MultiExportFilesystem::build_from_config(&exports).expect("build_from_config"), + ); + let registry = Registry::new(); + let metrics = Metrics::new(); + + // Three bytes — far too short to be a valid RPC call header, so + // `deserialize_call` fails before any routing happens. + let truncated = [0x00u8, 0x01, 0x02]; + let result = handle_rpc_message( + &truncated, + ®istry, + filesystem.as_ref(), + &[100003], + &metrics, + ) + .await; + + assert!(result.is_err(), "a truncated header must fail to decode"); + assert_eq!(metrics.server.rpc_decode_errors_total.load(Relaxed), 1); + assert_eq!(metrics.server.rpc_requests_total.load(Relaxed), 0); + assert_eq!(metrics.server.rpc_errors_total.load(Relaxed), 0); + } +} diff --git a/src/shutdown.rs b/src/shutdown.rs new file mode 100644 index 0000000..1d87315 --- /dev/null +++ b/src/shutdown.rs @@ -0,0 +1,232 @@ +//! Graceful-shutdown primitives shared by the daemon entry point (`main.rs`) +//! and the integration tests (issue #25 Phase 8). +//! +//! The daemon coordinates shutdown with a single +//! [`tokio_util::sync::CancellationToken`] threaded into every accept loop +//! (the three RPC servers and the admin server). The token is cancelled +//! once, on the first `SIGTERM`/`SIGINT`; each loop stops accepting new +//! connections and drains its in-flight tasks. This module holds the two +//! pieces of that flow that are worth unit-testing in isolation: +//! +//! - [`drain_with_timeout`] wraps the whole drain in a ceiling so a stuck +//! handler can't keep the process alive forever. +//! - [`Signal::escalation_exit_code`] maps a *second* signal (the operator +//! pressing Ctrl-C again, or a second `SIGTERM` from an impatient init) +//! to the conventional `128 + signum` exit code used when the daemon +//! aborts the drain and exits immediately. + +use std::future::Future; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; +use std::time::{Duration, Instant}; + +/// Process-wide count of in-flight per-connection handler tasks across every +/// accept loop (the three RPC servers and the admin server). +/// +/// A single `Arc` is shared by all four servers (constructed in +/// `main.rs`): each bumps the counter just before spawning a connection +/// handler and decrements it when that handler finishes (via [`InFlightGuard`], +/// which decrements on drop so a panicking handler still settles the count). +/// At drain-timeout time [`drain_with_timeout`] reads the aggregate so the +/// `warn!` can report how many handlers were still running when the ceiling +/// fired — a bare "drain timed out" is far less actionable than "drain timed +/// out with 4 requests still in flight". (Fix 9) +#[derive(Debug, Default)] +pub struct InFlight { + count: AtomicUsize, +} + +impl InFlight { + pub fn new() -> Self { + Self::default() + } + + /// Bump the count for a newly spawned handler. Prefer [`InFlightGuard`] + /// over calling this directly so the matching [`InFlight::dec`] can't be + /// missed on an early return or panic. + pub fn inc(&self) { + self.count.fetch_add(1, Relaxed); + } + + /// Drop the count for a finished handler. + pub fn dec(&self) { + self.count.fetch_sub(1, Relaxed); + } + + /// Current number of in-flight handlers. + pub fn load(&self) -> usize { + self.count.load(Relaxed) + } +} + +/// RAII guard: increments the [`InFlight`] count on construction and +/// decrements it on drop. Move one into each per-connection handler task so +/// the count settles whether the handler returns normally, returns early via +/// `?`, or panics (drop runs on unwind). +pub struct InFlightGuard(Arc); + +impl InFlightGuard { + pub fn new(counter: Arc) -> Self { + counter.inc(); + Self(counter) + } +} + +impl Drop for InFlightGuard { + fn drop(&mut self) { + self.0.dec(); + } +} + +/// Which Unix signal triggered (or escalated) the shutdown. +/// +/// Kept deliberately tiny — it exists so the exit-code mapping and the +/// second-signal escalation policy can be unit-tested without installing a +/// real signal handler (which is process-global and racy under `cargo +/// test`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Signal { + /// `SIGINT` — typically Ctrl-C at an interactive terminal. + Interrupt, + /// `SIGTERM` — typically `kill` / an init system asking us to stop. + Terminate, +} + +impl Signal { + /// Conventional process exit code for a *second-signal escalation*: + /// `128 + signum`. A daemon that receives a second `SIGINT`/`SIGTERM` + /// while already draining abandons the drain and exits immediately with + /// this code, mirroring what the shell reports for a process killed by + /// the signal: + /// + /// - `SIGINT` (signum 2) → `130` + /// - `SIGTERM` (signum 15) → `143` + pub fn escalation_exit_code(self) -> i32 { + match self { + Signal::Interrupt => 130, + Signal::Terminate => 143, + } + } +} + +/// Outcome of [`drain_with_timeout`]: either every server task finished +/// within the ceiling, or the ceiling fired first. Both carry the wall-clock +/// time actually spent so the caller can log a single "shutdown complete in +/// Xms" line. +#[derive(Debug)] +pub enum DrainOutcome { + /// All in-flight work drained before the timeout. Carries elapsed time. + Completed(Duration), + /// The timeout fired with work still in flight. Carries elapsed time + /// (≈ the timeout). The daemon exits anyway, with status 0 — a slow + /// drain is not a failure. + TimedOut(Duration), +} + +/// Await `drain` (the combined join of every server's accept-loop task), +/// bounded by `timeout`. +/// +/// `timeout` is a *ceiling*, not a target: if `drain` completes in 5ms this +/// returns in 5ms. Only a handler that refuses to finish makes the daemon +/// wait the full window. On completion this logs at `info`; on timeout it +/// logs at `warn` (and the caller still exits 0). The boolean-ish +/// [`DrainOutcome`] is returned rather than relying on the log so callers and +/// tests have a structured result to branch on. +pub async fn drain_with_timeout( + drain: impl Future, + timeout: Duration, + in_flight: &InFlight, +) -> DrainOutcome { + let started = Instant::now(); + match tokio::time::timeout(timeout, drain).await { + Ok(()) => { + let elapsed = started.elapsed(); + tracing::info!(ms = elapsed.as_millis(), "drain complete"); + DrainOutcome::Completed(elapsed) + } + Err(_) => { + let elapsed = started.elapsed(); + // Report the aggregate in-flight handler count across all four + // servers so the operator sees *how much* work was abandoned, not + // just that the ceiling fired. (Fix 9) + tracing::warn!( + ms = elapsed.as_millis(), + timeout_secs = timeout.as_secs(), + in_flight = in_flight.load(), + "drain timeout exceeded, exiting with in-flight requests still pending", + ); + DrainOutcome::TimedOut(elapsed) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escalation_exit_codes_follow_128_plus_signum() { + // The full second-signal path calls `std::process::exit`, which we + // can't exercise in-process without forking. Instead we unit-test + // the pure decision the handler makes: which exit code a second + // signal maps to. SIGINT=2 → 130, SIGTERM=15 → 143. + assert_eq!(Signal::Interrupt.escalation_exit_code(), 130); + assert_eq!(Signal::Terminate.escalation_exit_code(), 143); + } + + #[test] + fn in_flight_guard_increments_then_decrements_on_drop() { + // The guard is what keeps the aggregate count honest across early + // returns and panics: construction bumps, drop settles. (Fix 9) + let counter = Arc::new(InFlight::new()); + assert_eq!(counter.load(), 0); + { + let _g1 = InFlightGuard::new(counter.clone()); + let _g2 = InFlightGuard::new(counter.clone()); + assert_eq!(counter.load(), 2, "two live guards → count 2"); + } + assert_eq!(counter.load(), 0, "dropping both guards settles the count"); + } + + #[tokio::test] + async fn drain_with_timeout_completes_fast_when_work_finishes() { + // A drain that finishes immediately must return `Completed` well + // under the (generous) ceiling, proving the timeout is a ceiling and + // not a fixed wait. + let in_flight = InFlight::new(); + let outcome = + drain_with_timeout(std::future::ready(()), Duration::from_secs(30), &in_flight).await; + match outcome { + DrainOutcome::Completed(elapsed) => { + assert!( + elapsed < Duration::from_secs(1), + "ready future must drain near-instantly; took {elapsed:?}", + ); + } + DrainOutcome::TimedOut(_) => panic!("a ready drain must not time out"), + } + } + + #[tokio::test] + async fn drain_with_timeout_fires_when_handler_is_stuck() { + // Model a handler that sleeps far longer than the ceiling. The drain + // must give up at ~the timeout, not wait for the handler. + let in_flight = InFlight::new(); + let start = Instant::now(); + let outcome = drain_with_timeout( + tokio::time::sleep(Duration::from_secs(60)), + Duration::from_secs(1), + &in_flight, + ) + .await; + let elapsed = start.elapsed(); + match outcome { + DrainOutcome::TimedOut(_) => {} + DrainOutcome::Completed(_) => panic!("a 60s handler must trip the 1s ceiling"), + } + assert!( + elapsed < Duration::from_millis(1500), + "drain must abort at ~the 1s ceiling, not wait for the 60s handler; took {elapsed:?}", + ); + } +} diff --git a/tests/test_admin_audit.rs b/tests/test_admin_audit.rs new file mode 100644 index 0000000..1e69625 --- /dev/null +++ b/tests/test_admin_audit.rs @@ -0,0 +1,311 @@ +//! Integration tests for the Phase 6 admin audit log. +//! +//! Spins up an in-process admin server bound to a tempdir socket, points +//! its [`AdminContext`] at a `FileAuditWriter` over a tempfile, sends a +//! batch of admin requests, and asserts that each one produced exactly +//! one well-formed JSONL audit record. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use arcticwolf::admin::{self, AdminContext, AdminRequest, AdminResponse, FileAuditWriter}; +use arcticwolf::config::{BackendConfig, ExportConfig}; +use arcticwolf::fsal::multi_export::ExportSelector; +use arcticwolf::shutdown::InFlight; +use bytes::Bytes; +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value; +use tokio::net::UnixStream; +use tokio_util::codec::{Framed, LengthDelimitedCodec}; +use tokio_util::sync::CancellationToken; + +fn cfg(name: &str, uid: u32, path: PathBuf, read_only: bool) -> ExportConfig { + ExportConfig { + name: name.to_string(), + uid, + read_only, + backend: BackendConfig::Local { path }, + } +} + +/// Mirrors the daemon's `LengthDelimitedCodec` configuration so the test's +/// raw-frame path (used for the malformed-JSON case) writes bytes the +/// server actually decodes. Inlined here rather than reaching into a +/// crate-private helper so a wire-format drift fails the test loudly. +fn client_codec() -> LengthDelimitedCodec { + LengthDelimitedCodec::builder() + .length_field_length(4) + .max_frame_length(1024 * 1024) + .new_codec() +} + +/// Set up an in-process admin server backed by a `FileAuditWriter` over a +/// tempfile. The returned tuple keeps every borrowed resource alive for +/// the duration of the test: +/// - `socket_path` — what the client connects to +/// - `audit_path` — what the test reads back to assert events +/// - `_socket_dir` — owns the socket tempdir +/// - `_audit_dir` — owns the audit-log tempdir +/// - `_export_dir` — owns the for_test() export tempdir +/// - `_log_guard` — serializes shared tracing reload handle +/// - `token` — cancels the detached serve() task on teardown +async fn spawn_with_audit() -> ( + PathBuf, + PathBuf, + tempfile::TempDir, + tempfile::TempDir, + tempfile::TempDir, + admin::context::TestLogReloadGuard, + CancellationToken, +) { + let socket_dir = tempfile::tempdir().expect("socket tempdir"); + let socket_path = socket_dir.path().join("admin.sock"); + + let audit_dir = tempfile::tempdir().expect("audit tempdir"); + let audit_path = audit_dir.path().join("audit.jsonl"); + let audit_writer = Arc::new(FileAuditWriter::open(&audit_path).expect("open audit writer")); + + let (context, export_dir, log_guard) = AdminContext::for_test_with_audit(audit_writer); + let listener = + admin::server::bind_admin_socket(&socket_path, 0o600).expect("bind admin socket"); + // Detach the serve task; the returned token drives shutdown via + // `token.cancel()` — the same path production uses (Fix 18). + let token = CancellationToken::new(); + let _server = tokio::spawn(admin::serve_with_shutdown( + listener, + socket_path.clone(), + context, + token.clone(), + Arc::new(InFlight::new()), + )); + ( + socket_path, + audit_path, + socket_dir, + audit_dir, + export_dir, + log_guard, + token, + ) +} + +/// Read the audit file repeatedly until it has at least `min_lines` +/// non-empty lines or the 2s deadline expires. The writer task lives on a +/// `tokio::spawn` we can't join from here, so we cross the boundary by +/// observing the on-disk file. +async fn wait_for_audit_lines(path: &std::path::Path, min_lines: usize) -> Vec { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + loop { + if let Ok(contents) = tokio::fs::read_to_string(path).await { + let lines: Vec = contents + .lines() + .filter(|l| !l.is_empty()) + .map(|l| serde_json::from_str(l).expect("each audit line must be valid JSON")) + .collect(); + if lines.len() >= min_lines { + return lines; + } + } + if std::time::Instant::now() >= deadline { + panic!( + "audit log at {} never reached {min_lines} lines", + path.display(), + ); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +/// Open a raw framed connection and send a single user-supplied byte +/// payload as the body of one length-prefixed frame. Used to drive the +/// malformed-JSON path that the typed client helpers can't exercise. +async fn send_raw_frame(socket_path: &std::path::Path, body: &[u8]) -> AdminResponse { + let stream = UnixStream::connect(socket_path) + .await + .expect("connect to admin socket"); + let mut framed: Framed = Framed::new(stream, client_codec()); + framed + .send(Bytes::copy_from_slice(body)) + .await + .expect("send malformed frame"); + let frame = tokio::time::timeout(Duration::from_secs(2), framed.next()) + .await + .expect("server replied in time") + .expect("server produced a frame") + .expect("frame well-formed"); + serde_json::from_slice(&frame).expect("response decodes") +} + +#[tokio::test] +async fn audit_records_status_request_with_peer_uid() { + let (socket_path, audit_path, _sd, _ad, _xd, _g, token) = spawn_with_audit().await; + + admin::client::fetch_status(&socket_path) + .await + .expect("status succeeds"); + + let lines = wait_for_audit_lines(&audit_path, 1).await; + assert_eq!(lines.len(), 1); + let event = &lines[0]; + assert_eq!(event["command"], "status"); + assert_eq!(event["result"], "ok"); + assert!( + event.get("error").is_none(), + "ok result must not carry an error field: {event}", + ); + assert!( + event["request"].is_object(), + "status request body must be an empty object (the command tag is stripped): {event}", + ); + // The for_test context wires no extra fields into Status; with the + // discriminator removed the request body is `{}`. + assert_eq!(event["request"], serde_json::json!({})); + + // On Linux the test process and the server share an address space, so + // SO_PEERCRED reports our own uid/pid. The audit file is the cleanest + // place to assert that wiring without exposing internals. + #[cfg(target_os = "linux")] + { + assert_eq!( + event["peer"]["uid"].as_u64().expect("peer.uid"), + u64::from(nix::unistd::geteuid().as_raw()), + ); + assert_eq!( + event["peer"]["pid"].as_u64().expect("peer.pid"), + u64::from(std::process::id()), + ); + } + token.cancel(); +} + +#[tokio::test] +async fn audit_records_ok_and_err_outcomes_with_correct_fields() { + let (socket_path, audit_path, _sd, _ad, _xd, _g, token) = spawn_with_audit().await; + + // OK: add a new export. + let new_dir = tempfile::tempdir().expect("new export tempdir"); + admin::client::exports_add( + &socket_path, + cfg("/extra", 7, new_dir.path().to_path_buf(), false), + false, + ) + .await + .expect("add ok"); + + // ERR: try to add the same uid again — duplicate. + let dup_resp = admin::client::send_request( + &socket_path, + &AdminRequest::ExportsAdd { + config: cfg("/extra2", 7, new_dir.path().to_path_buf(), false), + dry_run: false, + }, + ) + .await + .expect("request completes"); + match dup_resp { + AdminResponse::Err { .. } => {} + AdminResponse::Ok { .. } => panic!("duplicate uid must produce err"), + } + + // OK: remove the export so the next assertion has clean state. + admin::client::exports_remove(&socket_path, ExportSelector::Uid(7), false) + .await + .expect("remove ok"); + + let lines = wait_for_audit_lines(&audit_path, 3).await; + assert_eq!( + lines.len(), + 3, + "three commands → three audit lines: {lines:?}" + ); + + // First line: exports-add OK. + assert_eq!(lines[0]["command"], "exports-add"); + assert_eq!(lines[0]["result"], "ok"); + assert!(lines[0].get("error").is_none()); + assert_eq!(lines[0]["request"]["config"]["name"], "/extra"); + assert_eq!(lines[0]["request"]["config"]["uid"], 7); + assert_eq!(lines[0]["request"]["dry_run"], false); + + // Second line: exports-add ERR (duplicate uid). + assert_eq!(lines[1]["command"], "exports-add"); + assert_eq!(lines[1]["result"], "err"); + let err = lines[1]["error"] + .as_str() + .expect("err must carry error string"); + assert!(err.contains("already in use"), "got: {err}"); + assert_eq!(lines[1]["request"]["config"]["uid"], 7); + + // Third line: exports-remove OK. + assert_eq!(lines[2]["command"], "exports-remove"); + assert_eq!(lines[2]["result"], "ok"); + assert!(lines[2].get("error").is_none()); + assert_eq!(lines[2]["request"]["selector"]["uid"], 7); + + token.cancel(); +} + +#[tokio::test] +async fn audit_records_malformed_frame_with_undecodable_marker() { + let (socket_path, audit_path, _sd, _ad, _xd, _g, token) = spawn_with_audit().await; + + // Bytes that aren't valid JSON at all — the audit line must still get + // emitted, with the documented `` command tag and a + // `null` request body. + let resp = send_raw_frame(&socket_path, b"this is not json at all").await; + match resp { + AdminResponse::Err { .. } => {} + AdminResponse::Ok { .. } => panic!("garbage payload must produce err"), + } + + let lines = wait_for_audit_lines(&audit_path, 1).await; + assert_eq!(lines.len(), 1); + let event = &lines[0]; + assert_eq!(event["command"], ""); + assert!( + event["request"].is_null(), + "undecodable frame must record null request: {event}", + ); + assert_eq!(event["result"], "err"); + assert!( + event["error"].is_string(), + "undecodable frame must carry the decode error: {event}", + ); + + token.cancel(); +} + +#[tokio::test] +async fn audit_records_every_request_in_order_including_repeats() { + // Pin the "every request gets one line" invariant across a mixed + // batch — operators rely on a 1:1 ratio so log volume matches + // request count exactly. + let (socket_path, audit_path, _sd, _ad, _xd, _g, token) = spawn_with_audit().await; + + let _ = admin::client::fetch_status(&socket_path).await.unwrap(); + let _ = admin::client::fetch_version(&socket_path).await.unwrap(); + let _ = admin::client::fetch_exports_list(&socket_path) + .await + .unwrap(); + let _ = admin::client::fetch_status(&socket_path).await.unwrap(); + + let lines = wait_for_audit_lines(&audit_path, 4).await; + assert_eq!(lines.len(), 4); + let tags: Vec<&str> = lines + .iter() + .map(|l| l["command"].as_str().unwrap()) + .collect(); + assert_eq!(tags, vec!["status", "version", "exports-list", "status"]); + + // Every line must carry a non-empty RFC 3339 timestamp. + for (i, line) in lines.iter().enumerate() { + let ts = line["ts"].as_str().unwrap_or_default(); + assert!( + ts.ends_with('Z') && ts.contains('T'), + "line {i} has malformed ts: {ts}", + ); + } + + token.cancel(); +} diff --git a/tests/test_admin_exports.rs b/tests/test_admin_exports.rs new file mode 100644 index 0000000..642014e --- /dev/null +++ b/tests/test_admin_exports.rs @@ -0,0 +1,328 @@ +//! Integration tests for the admin `exports` and `config-show` commands. +//! +//! Spins up the admin server in-process over a tempdir socket (same code +//! path `arcticwolfctl` uses) and asserts the wire round-trip and live +//! mutations. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use arcticwolf::admin::{self, AdminContext, AdminRequest, AdminResponse}; +use arcticwolf::config::{BackendConfig, ExportConfig}; +use arcticwolf::fsal::multi_export::ExportSelector; +use arcticwolf::shutdown::InFlight; +use tokio_util::sync::CancellationToken; + +fn cfg(name: &str, uid: u32, path: PathBuf, read_only: bool) -> ExportConfig { + ExportConfig { + name: name.to_string(), + uid, + read_only, + backend: BackendConfig::Local { path }, + } +} + +async fn spawn() -> ( + PathBuf, + tempfile::TempDir, + tempfile::TempDir, + admin::context::TestLogReloadGuard, + CancellationToken, +) { + let socket_dir = tempfile::tempdir().expect("socket tempdir"); + let socket_path = socket_dir.path().join("admin.sock"); + + let (context, export_dir, log_guard) = AdminContext::for_test(); + let listener = + admin::server::bind_admin_socket(&socket_path, 0o600).expect("bind admin socket"); + // Detach the serve task; the returned token drives shutdown via + // `token.cancel()` — the same path production uses (Fix 18). + let token = CancellationToken::new(); + let _server = tokio::spawn(admin::serve_with_shutdown( + listener, + socket_path.clone(), + context, + token.clone(), + Arc::new(InFlight::new()), + )); + (socket_path, socket_dir, export_dir, log_guard, token) +} + +#[tokio::test] +async fn exports_list_returns_seeded_exports_and_no_retired() { + let (socket_path, _sd, _xd, _g, token) = spawn().await; + + let data = tokio::time::timeout( + Duration::from_secs(5), + admin::client::fetch_exports_list(&socket_path), + ) + .await + .expect("list did not time out") + .expect("list succeeds"); + + let exports = data["exports"].as_array().expect("exports array"); + assert_eq!(exports.len(), 1); + assert_eq!(exports[0]["name"], "/data"); + assert_eq!(exports[0]["uid"], 1); + assert_eq!(exports[0]["fsal"], "local"); + assert_eq!(exports[0]["read_only"], false); + let retired = data["retired_uids"].as_array().expect("retired_uids array"); + assert!(retired.is_empty()); + token.cancel(); +} + +#[tokio::test] +async fn exports_add_happy_path_appears_in_list() { + let (socket_path, _sd, _xd, _g, token) = spawn().await; + let new_dir = tempfile::tempdir().expect("new export dir"); + + let add = admin::client::exports_add( + &socket_path, + cfg("/extra", 7, new_dir.path().to_path_buf(), false), + false, + ) + .await + .expect("add succeeds"); + assert_eq!(add["name"], "/extra"); + assert_eq!(add["uid"], 7); + + let list = admin::client::fetch_exports_list(&socket_path) + .await + .expect("list succeeds"); + let names: Vec<&str> = list["exports"] + .as_array() + .unwrap() + .iter() + .map(|e| e["name"].as_str().unwrap()) + .collect(); + assert!(names.contains(&"/data"), "list missing /data: {list}"); + assert!(names.contains(&"/extra"), "list missing /extra: {list}"); + token.cancel(); +} + +#[tokio::test] +async fn exports_add_rejects_duplicate_uid_name_and_retired() { + let (socket_path, _sd, _xd, _g, token) = spawn().await; + let dup_dir = tempfile::tempdir().expect("dup tempdir"); + + // Duplicate uid (1 is taken by /data). + let resp = admin::client::send_request( + &socket_path, + &AdminRequest::ExportsAdd { + config: cfg("/other", 1, dup_dir.path().to_path_buf(), false), + dry_run: false, + }, + ) + .await + .expect("request completes"); + match resp { + AdminResponse::Err { error } => assert!(error.contains("already in use"), "got: {error}"), + AdminResponse::Ok { .. } => panic!("duplicate uid must error"), + } + + // Duplicate name (/data is taken). + let resp = admin::client::send_request( + &socket_path, + &AdminRequest::ExportsAdd { + config: cfg("/data", 99, dup_dir.path().to_path_buf(), false), + dry_run: false, + }, + ) + .await + .expect("request completes"); + match resp { + AdminResponse::Err { error } => assert!(error.contains("already in use"), "got: {error}"), + AdminResponse::Ok { .. } => panic!("duplicate name must error"), + } + + // Retire uid 1, then try to readd it (under a fresh name). + admin::client::exports_remove(&socket_path, ExportSelector::Uid(1), false) + .await + .expect("remove succeeds"); + let resp = admin::client::send_request( + &socket_path, + &AdminRequest::ExportsAdd { + config: cfg("/new", 1, dup_dir.path().to_path_buf(), false), + dry_run: false, + }, + ) + .await + .expect("request completes"); + match resp { + AdminResponse::Err { error } => assert!(error.contains("retired"), "got: {error}"), + AdminResponse::Ok { .. } => panic!("retired uid must error"), + } + token.cancel(); +} + +#[tokio::test] +async fn exports_remove_by_name_disappears_and_uid_is_retired() { + let (socket_path, _sd, _xd, _g, token) = spawn().await; + let resp = admin::client::exports_remove( + &socket_path, + ExportSelector::Name("/data".to_string()), + false, + ) + .await + .expect("remove succeeds"); + assert_eq!(resp["uid"], 1); + assert_eq!(resp["name"], "/data"); + + let list = admin::client::fetch_exports_list(&socket_path) + .await + .expect("list succeeds"); + assert!(list["exports"].as_array().unwrap().is_empty()); + let retired: Vec = list["retired_uids"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_u64().unwrap()) + .collect(); + assert_eq!(retired, vec![1]); + token.cancel(); +} + +#[tokio::test] +async fn exports_remove_by_uid_succeeds() { + let (socket_path, _sd, _xd, _g, token) = spawn().await; + let resp = admin::client::exports_remove(&socket_path, ExportSelector::Uid(1), false) + .await + .expect("remove by uid succeeds"); + assert_eq!(resp["uid"], 1); + assert_eq!(resp["name"], "/data"); + token.cancel(); +} + +#[tokio::test] +async fn exports_update_flips_read_only() { + let (socket_path, _sd, _xd, _g, token) = spawn().await; + admin::client::exports_update( + &socket_path, + ExportSelector::Name("/data".to_string()), + true, + false, + ) + .await + .expect("update succeeds"); + + let list = admin::client::fetch_exports_list(&socket_path) + .await + .expect("list succeeds"); + let entry = &list["exports"][0]; + assert_eq!(entry["read_only"], true); + token.cancel(); +} + +#[tokio::test] +async fn exports_update_missing_export_errors() { + let (socket_path, _sd, _xd, _g, token) = spawn().await; + let resp = admin::client::send_request( + &socket_path, + &AdminRequest::ExportsUpdate { + selector: ExportSelector::Uid(999), + read_only: true, + dry_run: false, + }, + ) + .await + .expect("request completes"); + match resp { + AdminResponse::Err { error } => { + assert!(error.contains("No export with uid"), "got: {error}"); + } + AdminResponse::Ok { .. } => panic!("missing uid must error"), + } + token.cancel(); +} + +#[tokio::test] +async fn dry_run_does_not_mutate_state_across_add_remove_update() { + let (socket_path, _sd, _xd, _g, token) = spawn().await; + + let before = admin::client::fetch_exports_list(&socket_path) + .await + .expect("list before"); + + // dry-run add + let new_dir = tempfile::tempdir().expect("new tempdir"); + let resp = admin::client::exports_add( + &socket_path, + cfg("/extra", 7, new_dir.path().to_path_buf(), false), + true, + ) + .await + .expect("dry-run add succeeds"); + assert_eq!(resp["dry_run"], true); + assert_eq!(resp["would_succeed"], true); + // Symmetric with would_remove/would_update — the dry-run echoes the + // requested name/uid back to the caller. + assert_eq!(resp["would_add"]["name"], "/extra"); + assert_eq!(resp["would_add"]["uid"], 7); + + // dry-run remove + let resp = admin::client::exports_remove( + &socket_path, + ExportSelector::Name("/data".to_string()), + true, + ) + .await + .expect("dry-run remove succeeds"); + assert_eq!(resp["dry_run"], true); + assert_eq!(resp["would_remove"]["uid"], 1); + assert_eq!(resp["would_remove"]["name"], "/data"); + + // dry-run update + let resp = admin::client::exports_update( + &socket_path, + ExportSelector::Name("/data".to_string()), + true, + true, + ) + .await + .expect("dry-run update succeeds"); + assert_eq!(resp["dry_run"], true); + assert_eq!(resp["would_update"]["from"]["read_only"], false); + assert_eq!(resp["would_update"]["to"]["read_only"], true); + + // State is unchanged across all three dry-runs. + let after = admin::client::fetch_exports_list(&socket_path) + .await + .expect("list after"); + assert_eq!(before, after, "state must be unchanged after dry-runs"); + token.cancel(); +} + +#[tokio::test] +async fn config_show_returns_startup_config() { + let (socket_path, _sd, _xd, _g, token) = spawn().await; + let data = admin::client::fetch_config_show(&socket_path) + .await + .expect("config-show succeeds"); + + // The `for_test` context seeds one export at /data, uid 1. + assert!(data["server"].is_object()); + assert_eq!(data["exports"][0]["name"], "/data"); + assert_eq!(data["exports"][0]["uid"], 1); + assert_eq!(data["exports"][0]["backend"], "local"); + + // Even after a runtime mutation, config-show keeps returning the + // startup view — that's the load-bearing contract of the command. + admin::client::exports_update( + &socket_path, + ExportSelector::Name("/data".to_string()), + true, + false, + ) + .await + .expect("update succeeds"); + + let after = admin::client::fetch_config_show(&socket_path) + .await + .expect("config-show after update"); + assert_eq!( + after["exports"][0]["read_only"], false, + "config-show must reflect the startup config, not live state", + ); + token.cancel(); +} diff --git a/tests/test_admin_log_level.rs b/tests/test_admin_log_level.rs new file mode 100644 index 0000000..3762098 --- /dev/null +++ b/tests/test_admin_log_level.rs @@ -0,0 +1,105 @@ +//! Integration test for the admin `log-level` commands. +//! +//! Drives the admin server in-process over a tempdir socket (the same code +//! path `arcticwolfctl` uses) and exercises the `log-level get`/`set` round +//! trip plus the rejected-input contract from issue #25: an unrecognized +//! level must error and leave the active filter unchanged. + +use std::sync::Arc; +use std::time::Duration; + +use arcticwolf::admin::{self, AdminContext, AdminRequest, AdminResponse}; +use arcticwolf::shutdown::InFlight; +use tokio_util::sync::CancellationToken; + +#[tokio::test] +async fn log_level_set_then_get_round_trips_over_the_socket() { + let socket_dir = tempfile::tempdir().expect("socket tempdir"); + let socket_path = socket_dir.path().join("admin.sock"); + + let (context, _export_dir, _log_guard) = AdminContext::for_test(); + let listener = + admin::server::bind_admin_socket(&socket_path, 0o600).expect("bind admin socket"); + let token = CancellationToken::new(); + let _server = tokio::spawn(admin::serve_with_shutdown( + listener, + socket_path.clone(), + context, + token.clone(), + Arc::new(InFlight::new()), + )); + + // The `for_test` context starts at `info`. + let initial = tokio::time::timeout( + Duration::from_secs(5), + admin::client::fetch_log_level(&socket_path), + ) + .await + .expect("get did not time out") + .expect("log-level get succeeds"); + assert_eq!(initial["level"], "info"); + + // `set debug` echoes the new directive... + let set = tokio::time::timeout( + Duration::from_secs(5), + admin::client::set_log_level(&socket_path, "debug"), + ) + .await + .expect("set did not time out") + .expect("log-level set succeeds"); + assert_eq!(set["level"], "debug"); + + // ...and a subsequent `get` observes it: the live filter was swapped. + let after = admin::client::fetch_log_level(&socket_path) + .await + .expect("log-level get after set succeeds"); + assert_eq!(after["level"], "debug"); + + token.cancel(); +} + +#[tokio::test] +async fn log_level_set_rejects_invalid_level_and_leaves_filter_unchanged() { + let socket_dir = tempfile::tempdir().expect("socket tempdir"); + let socket_path = socket_dir.path().join("admin.sock"); + + let (context, _export_dir, _log_guard) = AdminContext::for_test(); + let listener = + admin::server::bind_admin_socket(&socket_path, 0o600).expect("bind admin socket"); + let token = CancellationToken::new(); + let _server = tokio::spawn(admin::serve_with_shutdown( + listener, + socket_path.clone(), + context, + token.clone(), + Arc::new(InFlight::new()), + )); + + // An unrecognized level must come back as an error response... + let response = admin::client::send_request( + &socket_path, + &AdminRequest::LogLevelSet { + level: "tomato".to_string(), + }, + ) + .await + .expect("the request itself completes"); + match response { + AdminResponse::Err { error } => assert!( + error.contains("tomato"), + "the error should name the rejected input; got: {error}", + ), + AdminResponse::Ok { .. } => panic!("`log-level set tomato` must be rejected"), + } + + // ...and must not have disturbed the active filter, which stays `info`. + let still = admin::client::fetch_log_level(&socket_path) + .await + .expect("log-level get after rejected set succeeds"); + assert_eq!( + still["level"], "info", + "a rejected `set` must leave the filter unchanged", + ); + + token.cancel(); +} diff --git a/tests/test_admin_metrics.rs b/tests/test_admin_metrics.rs new file mode 100644 index 0000000..72852e3 --- /dev/null +++ b/tests/test_admin_metrics.rs @@ -0,0 +1,120 @@ +//! Integration tests for the Phase 7 admin `metrics` command. +//! +//! Spins up an in-process admin server backed by `AdminContext::for_test`, +//! sends a batch of admin requests (including `metrics` itself, which +//! counts), exercises the shared `MultiExportFilesystem` directly to move +//! the per-export I/O counters, then asserts the second `metrics` snapshot +//! reflects both surfaces. + +use std::path::PathBuf; +use std::sync::Arc; + +use arcticwolf::admin::{self, AdminContext}; +use arcticwolf::fsal::{ExportRegistry, Filesystem, MultiExportFilesystem}; +use arcticwolf::shutdown::InFlight; +use tokio_util::sync::CancellationToken; + +/// Bring up an admin server on a tempdir socket and hand back a clone of +/// the shared filesystem so the test can drive per-export counters. Every +/// returned resource must be kept alive for the test's duration. +async fn spawn() -> ( + PathBuf, + Arc, + tempfile::TempDir, + tempfile::TempDir, + admin::context::TestLogReloadGuard, + CancellationToken, +) { + let socket_dir = tempfile::tempdir().expect("socket tempdir"); + let socket_path = socket_dir.path().join("admin.sock"); + + let (context, export_dir, log_guard) = AdminContext::for_test(); + let fs = context.filesystem.clone(); + + let listener = + admin::server::bind_admin_socket(&socket_path, 0o600).expect("bind admin socket"); + // Detach the serve task; the returned token drives shutdown via + // `token.cancel()` — the same path production uses (Fix 18). + let token = CancellationToken::new(); + let _server = tokio::spawn(admin::serve_with_shutdown( + listener, + socket_path.clone(), + context, + token.clone(), + Arc::new(InFlight::new()), + )); + (socket_path, fs, socket_dir, export_dir, log_guard, token) +} + +#[tokio::test] +async fn metrics_counts_admin_commands_and_per_export_io() { + let (socket_path, fs, _socket_dir, _export_dir, _log_guard, token) = spawn().await; + + // A handful of admin commands. `metrics` itself is counted. + admin::client::fetch_status(&socket_path) + .await + .expect("status succeeds"); + admin::client::fetch_exports_list(&socket_path) + .await + .expect("exports-list succeeds"); + let _first = admin::client::fetch_metrics(&socket_path) + .await + .expect("metrics succeeds"); + + // Exercise per-export counters directly against the shared filesystem: + // create + write + read on /data (uid 1) → 3 requests, 11 bytes each way. + let root = fs.root_handle_for("/data").expect("/data root handle"); + let file = fs + .create(&root, "probe.bin", 0o644) + .await + .expect("create probe file"); + let written = fs.write(&file, 0, b"hello world").await.expect("write"); + assert_eq!(written, 11, "write must report the byte count"); + let read_back = fs.read(&file, 0, 11).await.expect("read"); + assert_eq!(read_back, b"hello world"); + + // Second snapshot, taken after the I/O. + let data = admin::client::fetch_metrics(&socket_path) + .await + .expect("metrics succeeds"); + + // Admin counters: status, exports-list, metrics, metrics → >= 4. + let total = data["admin"]["commands_total"] + .as_u64() + .expect("commands_total is a number"); + assert!( + total >= 4, + "expected at least 4 admin commands, got {total}: {data}", + ); + assert_eq!(data["admin"]["by_command"]["status"], 1); + assert_eq!(data["admin"]["by_command"]["exports-list"], 1); + assert!( + data["admin"]["by_command"]["metrics"] + .as_u64() + .expect("metrics command count") + >= 1, + "metrics command must be counted: {data}", + ); + + // Per-export I/O: /data is uid 1. + let exports = data["exports"].as_array().expect("exports array"); + let data_export = exports + .iter() + .find(|e| e["uid"] == 1) + .expect("uid 1 export present"); + assert_eq!(data_export["name"], "/data"); + assert!( + data_export["requests"].as_u64().unwrap() >= 3, + "create + write + read must each bump requests: {data_export}", + ); + assert_eq!(data_export["bytes_written"], 11); + assert_eq!(data_export["bytes_read"], 11); + assert_eq!(data_export["errors"], 0); + + // Server-wide RPC counters are present and zero on this admin-only path + // (no NFS/portmap/mount RPCs were issued through the RPC server). + assert_eq!(data["server"]["rpc_requests_total"], 0); + assert_eq!(data["server"]["rpc_errors_total"], 0); + + token.cancel(); +} diff --git a/tests/test_admin_status_version.rs b/tests/test_admin_status_version.rs new file mode 100644 index 0000000..1b8192a --- /dev/null +++ b/tests/test_admin_status_version.rs @@ -0,0 +1,115 @@ +//! Integration test for the admin `status` and `version` commands. +//! +//! Spins up the admin server in-process over a tempdir socket, drives it +//! with the in-process client (the same code path `arcticwolfctl` uses), +//! and asserts the response JSON shape for both commands as well as the +//! `--json` / human render paths. + +use std::sync::Arc; +use std::time::Duration; + +use arcticwolf::admin::{self, AdminContext}; +use arcticwolf::shutdown::InFlight; +use tokio_util::sync::CancellationToken; + +#[tokio::test] +async fn status_and_version_round_trip_over_the_socket() { + let socket_dir = tempfile::tempdir().expect("socket tempdir"); + let socket_path = socket_dir.path().join("admin.sock"); + + let (context, _export_dir, _log_guard) = AdminContext::for_test(); + let listener = + admin::server::bind_admin_socket(&socket_path, 0o600).expect("bind admin socket"); + let token = CancellationToken::new(); + let _server = tokio::spawn(admin::serve_with_shutdown( + listener, + socket_path.clone(), + context, + token.clone(), + Arc::new(InFlight::new()), + )); + + // --- status --- + let status = tokio::time::timeout( + Duration::from_secs(5), + admin::client::fetch_status(&socket_path), + ) + .await + .expect("status did not time out") + .expect("status command succeeds"); + + for field in [ + "daemon_version", + "uptime_seconds", + "bind_address", + "nfs_port", + "mount_port", + "portmap_port", + "log_level", + "export_count", + ] { + assert!( + status.get(field).is_some(), + "status response missing `{field}`: {status}", + ); + } + assert_eq!(status["daemon_version"], env!("CARGO_PKG_VERSION")); + assert_eq!(status["export_count"], 1); + assert_eq!(status["nfs_port"], 2049); + assert_eq!(status["mount_port"], 20048); + assert_eq!(status["portmap_port"], 111); + assert_eq!(status["log_level"], "info"); + assert!(status["uptime_seconds"].is_u64()); + + // --- version --- + let version = tokio::time::timeout( + Duration::from_secs(5), + admin::client::fetch_version(&socket_path), + ) + .await + .expect("version did not time out") + .expect("version command succeeds"); + + for field in [ + "daemon_version", + "build_commit", + "rustc_version", + "build_profile", + ] { + assert!( + version[field].is_string(), + "version response missing string `{field}`: {version}", + ); + } + assert_eq!(version["daemon_version"], env!("CARGO_PKG_VERSION")); + + // --- both render modes --- + let human_status = admin::client::render_status(&status, false).expect("render human status"); + assert!(human_status.contains("Daemon version:")); + assert!(human_status.contains("Exports:")); + // Human output must not leak JSON quoting around string values. + assert!(!human_status.contains("\"daemon_version\"")); + + let json_status = admin::client::render_status(&status, true).expect("render json status"); + let reparsed: serde_json::Value = + serde_json::from_str(&json_status).expect("json status reparses"); + assert_eq!(reparsed, status); + + let human_version = + admin::client::render_version(&version, false).expect("render human version"); + assert!(human_version.contains("Build commit:")); + let json_version = admin::client::render_version(&version, true).expect("render json version"); + assert!(json_version.contains("\"rustc_version\"")); + + token.cancel(); +} + +#[tokio::test] +async fn fetch_status_errors_when_socket_is_absent() { + // The client must surface a connection failure as an error rather than + // hang — `arcticwolfctl status` relies on this to exit non-zero. + let dir = tempfile::tempdir().expect("tempdir"); + let missing = dir.path().join("nope.sock"); + let result = admin::client::fetch_status(&missing).await; + assert!(result.is_err(), "connecting to a missing socket must error"); +} diff --git a/tests/test_graceful_shutdown.rs b/tests/test_graceful_shutdown.rs new file mode 100644 index 0000000..c26199a --- /dev/null +++ b/tests/test_graceful_shutdown.rs @@ -0,0 +1,234 @@ +//! Integration tests for Phase 8 graceful shutdown (issue #25). +//! +//! These exercise the admin server's cancellation + drain path end to end +//! over a real Unix socket, plus the whole-daemon drain-timeout helper: +//! +//! - `graceful_drain_completes_inflight_and_removes_socket` proves that a +//! request in flight when the shutdown token is cancelled still completes, +//! that new connections after shutdown are refused, that the socket file +//! is unlinked once the drain finishes, and that the in-flight requests +//! reached the audit log. +//! - `drain_timeout_fires_and_warns_when_handler_is_stuck` proves the +//! whole-daemon drain ceiling fires (and logs a `warn` carrying the +//! aggregate in-flight count) when a handler refuses to finish, rather than +//! hanging forever. +//! +//! Coverage gap (Fix 3): the daemon installs its signal handlers immediately +//! after tracing init — before the FSAL build, the port binds, and the +//! admin-socket bind — so a SIGTERM that lands *during* init triggers a clean +//! shutdown rather than the default kill. That early-init path is not +//! exercised here: it would require spawning the real `main()` (which reads +//! `/etc/arcticwolf/config.toml` and binds privileged ports 111/2049) under a +//! test harness and racing a SIGTERM into the init window. It is left to the +//! VM-based `make nfstest` suite rather than these in-process unit tests. + +use std::sync::Arc; +use std::time::Duration; + +use arcticwolf::admin::{ + self, AdminContext, AdminRequest, AdminResponse, AuditWriter, FileAuditWriter, +}; +use arcticwolf::shutdown::{DrainOutcome, InFlight, InFlightGuard, drain_with_timeout}; +use bytes::Bytes; +use futures_util::{SinkExt, StreamExt}; +use tokio::net::UnixStream; +use tokio_util::codec::{Framed, LengthDelimitedCodec}; +use tokio_util::sync::CancellationToken; + +/// Mirror the daemon's `LengthDelimitedCodec` configuration so the client's +/// frames are exactly what the server decodes. +fn client_codec() -> LengthDelimitedCodec { + LengthDelimitedCodec::builder() + .length_field_length(4) + .max_frame_length(1024 * 1024) + .new_codec() +} + +/// Open a persistent framed admin connection. Persistence matters: the +/// graceful-drain test reuses the *same* connection across the cancel so it +/// can prove an accepted connection keeps being serviced after shutdown +/// begins (the per-call `admin::client` helpers open a fresh socket each +/// time, which wouldn't exercise the in-flight path). +async fn connect(path: &std::path::Path) -> Framed { + let stream = UnixStream::connect(path) + .await + .expect("connect to admin socket"); + Framed::new(stream, client_codec()) +} + +/// Send one request on an existing framed connection and await the reply. +async fn round_trip( + framed: &mut Framed, + request: &AdminRequest, +) -> AdminResponse { + let payload = serde_json::to_vec(request).expect("serialize request"); + framed + .send(Bytes::from(payload)) + .await + .expect("send request"); + let frame = tokio::time::timeout(Duration::from_secs(2), framed.next()) + .await + .expect("server replied within 2s") + .expect("server produced a frame") + .expect("frame is well-formed"); + serde_json::from_slice(&frame).expect("response decodes") +} + +#[tokio::test] +async fn graceful_drain_completes_inflight_and_removes_socket() { + let socket_dir = tempfile::tempdir().expect("socket tempdir"); + let socket_path = socket_dir.path().join("admin.sock"); + let audit_dir = tempfile::tempdir().expect("audit tempdir"); + let audit_path = audit_dir.path().join("audit.jsonl"); + + // Concrete handle kept for the post-drain audit flush; the context gets + // a `dyn` clone. + let audit = Arc::new(FileAuditWriter::open(&audit_path).expect("open audit writer")); + let audit_dyn: Arc = audit.clone(); + let (context, _export_dir, _log_guard) = AdminContext::for_test_with_audit(audit_dyn); + + let listener = + admin::server::bind_admin_socket(&socket_path, 0o600).expect("bind admin socket"); + let token = CancellationToken::new(); + let server = tokio::spawn(admin::serve_with_shutdown( + listener, + socket_path.clone(), + context, + token.clone(), + Arc::new(InFlight::new()), + )); + + // Open a connection and complete a request. Once the reply lands, the + // connection has been accepted and its handler task is tracked in the + // server's JoinSet — so the cancel below cannot abort it. + let mut client = connect(&socket_path).await; + match round_trip(&mut client, &AdminRequest::Status).await { + AdminResponse::Ok { .. } => {} + AdminResponse::Err { error } => panic!("first status must succeed; got: {error}"), + } + + // Begin shutdown: the accept loop stops taking new connections. + token.cancel(); + + // The already-accepted connection must STILL be serviced after cancel — + // this is the core "drain in-flight, don't abort" guarantee. + match round_trip(&mut client, &AdminRequest::Status).await { + AdminResponse::Ok { .. } => {} + AdminResponse::Err { error } => { + panic!("in-flight connection must complete after cancel; got: {error}") + } + } + + // Close the connection so its handler task ends and the drain finishes. + drop(client); + + // The serve task drains and returns within the bound; assert it returned + // Ok rather than panicking or hanging. + tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("serve must drain and return within 5s") + .expect("serve task joins cleanly") + .expect("serve returns Ok"); + + // The socket file is unlinked once the drain completes. + assert!( + !socket_path.exists(), + "admin socket file must be removed after the drain completes", + ); + + // New connections after shutdown are refused (the path is gone). + assert!( + UnixStream::connect(&socket_path).await.is_err(), + "a connection attempt after shutdown must be refused", + ); + + // Flush the audit writer and confirm both in-flight requests were logged. + audit.shutdown().await; + let contents = std::fs::read_to_string(&audit_path).expect("read audit log"); + let status_lines = contents + .lines() + .filter(|l| !l.is_empty()) + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|v| v["command"] == "status") + .count(); + assert!( + status_lines >= 2, + "audit log must contain the in-flight status requests; found {status_lines} in:\n{contents}", + ); +} + +#[tokio::test(start_paused = true)] +async fn drain_timeout_fires_and_warns_when_handler_is_stuck() { + use std::io::Write; + use std::sync::Mutex; + use tracing_subscriber::fmt::MakeWriter; + + // A `MakeWriter` that appends every formatted log line into a shared + // buffer, so the test can assert the drain-timeout `warn!` was emitted. + #[derive(Clone)] + struct CaptureWriter(Arc>>); + impl Write for CaptureWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 + .lock() + .unwrap_or_else(|p| p.into_inner()) + .extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + impl<'a> MakeWriter<'a> for CaptureWriter { + type Writer = CaptureWriter; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } + } + + let buf = Arc::new(Mutex::new(Vec::::new())); + let subscriber = tracing_subscriber::fmt() + .with_writer(CaptureWriter(buf.clone())) + .with_ansi(false) + .finish(); + // `#[tokio::test]` runs on a current-thread runtime, so the drain future + // is polled on this very thread and inherits the thread-local default + // subscriber set here. + let _guard = tracing::subscriber::set_default(subscriber); + + // Model one stuck in-flight handler: hold an `InFlightGuard` so the shared + // counter reads 1, and stand in for the wedged handler with a 60s sleep + // bounded by a 1s ceiling. `#[tokio::test(start_paused = true)]` runs the + // clock in virtual time and auto-advances to the next timer whenever the + // runtime is otherwise idle, so the 1s ceiling fires deterministically with + // no real wall-clock wait — removing the slow-CI flake risk the old + // `elapsed < 1500ms` assertion guarded against. (Fix 23) + let in_flight = Arc::new(InFlight::new()); + let _guard = InFlightGuard::new(in_flight.clone()); + assert_eq!(in_flight.load(), 1, "one live guard → in-flight count 1"); + + let outcome = drain_with_timeout( + tokio::time::sleep(Duration::from_secs(60)), + Duration::from_secs(1), + &in_flight, + ) + .await; + + assert!( + matches!(outcome, DrainOutcome::TimedOut(_)), + "a 60s handler under a 1s ceiling must time out", + ); + + let logs = String::from_utf8(buf.lock().unwrap_or_else(|p| p.into_inner()).clone()) + .expect("captured logs are UTF-8"); + assert!( + logs.contains("drain timeout exceeded"), + "the drain-timeout warn must be emitted; captured logs:\n{logs}", + ); + // The warn must carry the aggregate in-flight count so operators see how + // much work was abandoned, not just that the ceiling fired. (Fix 9) + assert!( + logs.contains("in_flight=1"), + "the drain-timeout warn must report the non-zero in-flight count; captured logs:\n{logs}", + ); +}