From 6f8c94f022677cb2b90e1db12d5bea2a6d779706 Mon Sep 17 00:00:00 2001 From: amarok-bot Date: Tue, 19 May 2026 20:25:50 +0800 Subject: [PATCH 1/9] admin: introduce Unix socket scaffolding with length-prefixed JSON Phase 1 of #25. Adds the admin server skeleton that the rest of the admin CLI work hangs off: - New src/admin/ module with protocol.rs (length-prefixed JSON codec), request/response enums, server.rs accepting Unix socket connections, and an AdminContext placeholder. - New [admin] config section with enabled=false default so the scaffolding is inert unless explicitly turned on. - main.rs binds the admin Unix socket when [admin] enabled=true and spawns the listener alongside the portmap/mount/nfs RPC servers; missing socket parent directory or insufficient write permission causes fail-fast startup error. - Adds tokio-util (codec) and nix (will be used for SO_PEERCRED in the audit phase) to Cargo.toml. - Dispatcher currently returns "Command not implemented in phase 1" for every request; phase 2 wires up Status. Tests cover the codec round-trip and the unknown-command error shape; existing multi-export tests are untouched. Signed-off-by: amarok-bot --- Cargo.lock | 517 +++++++++++++++++++++++++++++++----------- Cargo.toml | 11 + Earthfile | 13 ++ Makefile | 7 +- src/admin/context.rs | 35 +++ src/admin/mod.rs | 18 ++ src/admin/protocol.rs | 244 ++++++++++++++++++++ src/admin/request.rs | 25 ++ src/admin/response.rs | 26 +++ src/admin/server.rs | 466 +++++++++++++++++++++++++++++++++++++ src/config.rs | 181 +++++++++++++++ src/lib.rs | 1 + src/main.rs | 43 +++- 13 files changed, 1447 insertions(+), 140 deletions(-) create mode 100644 src/admin/context.rs create mode 100644 src/admin/mod.rs create mode 100644 src/admin/protocol.rs create mode 100644 src/admin/request.rs create mode 100644 src/admin/response.rs create mode 100644 src/admin/server.rs diff --git a/Cargo.lock b/Cargo.lock index b3a3dd6..9bd58b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[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 +34,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 +53,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,14 +64,14 @@ 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 = "arcticwolf" @@ -81,13 +81,17 @@ dependencies = [ "async-trait", "bytes", "clap", - "getrandom", + "futures-util", + "getrandom 0.3.4", "libc", + "nix", "serde", "serde_ignored", + "serde_json", "tempfile", "thiserror", "tokio", + "tokio-util", "toml", "tracing", "tracing-subscriber", @@ -105,6 +109,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 +132,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 +144,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cfg-if" @@ -144,11 +154,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 +172,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 +184,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 +196,15 @@ 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 = "equivalent" @@ -203,7 +219,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 +233,46 @@ 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 = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +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,8 +282,21 @@ 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]] @@ -241,9 +307,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 +326,22 @@ 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 = "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 +350,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 +391,24 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] [[package]] name = "miniz_oxide" @@ -317,13 +421,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 +449,7 @@ 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]] @@ -346,9 +463,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 +498,34 @@ 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 = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[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 +536,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" @@ -420,21 +553,21 @@ dependencies = [ [[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 = "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]] @@ -443,6 +576,12 @@ 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" + [[package]] name = "serde" version = "1.0.228" @@ -483,6 +622,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 +655,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 +677,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 +693,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 +704,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]] @@ -587,9 +746,9 @@ dependencies = [ [[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 +758,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 +828,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 +850,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,9 +871,9 @@ 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 = [ "nu-ansi-term", "sharded-slab", @@ -713,9 +885,15 @@ dependencies = [ [[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" @@ -741,112 +919,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 +1092,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..73dac75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,9 +19,20 @@ 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"] } # XDR serialization (runtime) xdr-codec = "0.4" diff --git a/Earthfile b/Earthfile index 8f92c28..227aa84 100644 --- a/Earthfile +++ b/Earthfile @@ -43,6 +43,19 @@ lint: RUN cargo fmt -- --check RUN cargo clippy -- -D warnings +# earthly +lockfile +# Regenerate Cargo.lock from Cargo.toml and copy it back to the host. +# Use after editing [dependencies] in Cargo.toml. +lockfile: + FROM rust:1.91 + WORKDIR /src + COPY Cargo.toml ./ + COPY build.rs ./ + COPY xdr ./xdr + COPY src ./src + RUN cargo generate-lockfile + 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/src/admin/context.rs b/src/admin/context.rs new file mode 100644 index 0000000..101d707 --- /dev/null +++ b/src/admin/context.rs @@ -0,0 +1,35 @@ +//! Shared state for admin request handlers. +//! +//! Phase 1 has no real handlers, so the context is intentionally empty. The +//! struct still exists (and is threaded through `serve()` and the +//! dispatcher) so later phases can add fields — the filesystem handle for +//! exports mutation (Phase 4/5), the tracing reload handle (Phase 3), the +//! audit log writer (Phase 6), etc. — without touching the call sites. +//! +//! `Clone` and `Default` are deliberately *not* derived: Phase 3's +//! `tracing_subscriber::reload::Handle` does not +//! implement `Clone`, and Phase 6's audit-log writer does not implement +//! `Default`. Dropping those derives now means later phases can add the +//! fields without noisy `derive`-list changes. Consumers share the context +//! via `Arc` (see [`AdminContext::shared`]). + +use std::sync::Arc; + +#[non_exhaustive] +pub struct AdminContext {} + +impl AdminContext { + // `Default` is intentionally not implemented (see module doc), so silence + // the lint that asks for one. + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self {} + } + + /// Convenience for the call site in `main.rs`, which always wraps the + /// context in an `Arc` so it can be cloned cheaply into per-connection + /// tasks. + pub fn shared() -> Arc { + Arc::new(Self::new()) + } +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs new file mode 100644 index 0000000..cd1f81a --- /dev/null +++ b/src/admin/mod.rs @@ -0,0 +1,18 @@ +//! Admin server (issue #25). +//! +//! Phase 1 introduces the transport scaffolding only: a length-prefixed +//! JSON codec over a Unix domain socket, a placeholder request/response +//! schema, and a dispatcher that responds `not_implemented` to every +//! command. The actual admin commands (`status`, `exports list`, ...) are +//! wired up in later phases on top of this skeleton. + +pub mod context; +pub mod protocol; +pub mod request; +pub mod response; +pub mod server; + +pub use context::AdminContext; +pub use request::AdminRequest; +pub use response::AdminResponse; +pub use server::serve; diff --git a/src/admin/protocol.rs b/src/admin/protocol.rs new file mode 100644 index 0000000..e6711c6 --- /dev/null +++ b/src/admin/protocol.rs @@ -0,0 +1,244 @@ +//! 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 two helpers: +//! - [`framed_request`] / [`framed_response`] wrap a Tokio `AsyncRead+Write` +//! stream in a typed framed sink/stream of `AdminRequest`/`AdminResponse`. +//! - [`decode_request`] / [`encode_response`] are the raw 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..ffd8b9d --- /dev/null +++ b/src/admin/request.rs @@ -0,0 +1,25 @@ +//! Admin request schema. +//! +//! Phase 1 only declares the variants the rest of the v1 CLI will use; their +//! handlers all return `"Command not implemented in phase 1"` today and are +//! filled in by later phases (Phase 2 wires `Status` and `Version`, +//! Phase 3 the log-level commands, Phase 5 the exports/config commands, +//! Phase 7 metrics, Phase 8 shutdown). The variant set is kept in sync with +//! the v1 surface in issue #25 so phase 2+ can land handlers without +//! touching the protocol again. + +use serde::{Deserialize, Serialize}; + +/// 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. Phase 2 will implement. + Status, +} 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..9315b5a --- /dev/null +++ b/src/admin/server.rs @@ -0,0 +1,466 @@ +//! 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; + +use anyhow::{Context, Result}; +use bytes::Bytes; +use futures_util::{SinkExt, StreamExt}; +use tokio::net::{UnixListener, UnixStream}; +use tracing::{debug, info, warn}; + +use super::context::AdminContext; +use super::protocol::{decode_request, encode_response, framed}; +use super::response::AdminResponse; + +/// 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) +} + +/// Run the admin server until the listener returns a fatal error. +/// +/// `socket_path` is held only so the file can be unlinked on a clean +/// shutdown — graceful shutdown lands in Phase 8, so today we just log +/// the socket path on accept loop termination. +pub async fn serve( + listener: UnixListener, + socket_path: PathBuf, + context: Arc, +) -> Result<()> { + info!("admin server accepting on {}", socket_path.display()); + loop { + let (socket, _) = listener + .accept() + .await + .with_context(|| format!("admin accept failed on {}", socket_path.display()))?; + debug!("admin connection accepted"); + + let context = context.clone(); + tokio::spawn(async move { + if let Err(err) = handle_connection(socket, context).await { + warn!("admin connection error: {err:#}"); + } + }); + } +} + +async fn handle_connection(socket: UnixStream, context: Arc) -> Result<()> { + let mut framed = framed(socket); + + while let Some(frame) = framed.next().await { + let bytes = match frame { + Ok(bytes) => bytes, + Err(err) => { + // Codec-level errors (oversize frame, eof mid-frame). 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()); + } + }; + + let response = dispatch(&context, &bytes); + send_response(&mut framed, &response).await?; + } + Ok(()) +} + +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") +} + +/// Phase 1 dispatcher: parse the request to validate the wire format, but +/// answer everything with a not-implemented error. Phase 2+ replaces this +/// with per-command handlers. +fn dispatch(_context: &AdminContext, frame: &[u8]) -> AdminResponse { + match decode_request(frame) { + Ok(req) => { + debug!("admin: received {:?}; not implemented in phase 1", req); + AdminResponse::error("Command not implemented in phase 1") + } + Err(err) => { + // Includes the "Unknown command: ..." prefix that the CLI keys + // on for its user-facing error. + AdminResponse::error(err) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::admin::request::AdminRequest; + use tokio_util::codec::{Framed, LengthDelimitedCodec}; + + fn check_dispatch(frame: &[u8]) -> AdminResponse { + let ctx = AdminContext::new(); + dispatch(&ctx, 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_known_request_returns_not_implemented() { + // The dispatcher must still decode the frame even though it has no + // handler — that catches mismatched serialization in tests as the + // protocol grows. The actual response stays generic until Phase 2. + let payload = serde_json::to_vec(&AdminRequest::Status).unwrap(); + let response = check_dispatch(&payload); + match response { + AdminResponse::Err { error } => { + assert!( + error.contains("not implemented"), + "phase 1 dispatcher must reply with not-implemented; got: {error}", + ); + } + AdminResponse::Ok { .. } => panic!("phase 1 dispatcher must not return Ok"), + } + } + + #[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 the Phase 1 "not implemented" `AdminResponse`. + /// 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 = AdminContext::shared(); + let socket_path_for_serve = socket_path.clone(); + let server = + tokio::spawn(async move { serve(listener, socket_path_for_serve, context).await }); + + 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::Err { error } => { + assert!( + error.contains("not implemented"), + "phase 1 server must reply with not-implemented; got: {error}", + ); + } + AdminResponse::Ok { .. } => panic!("phase 1 server must not return Ok"), + } + + server.abort(); + } +} diff --git a/src/config.rs b/src/config.rs index 9203a3e..6800a1b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,6 +27,7 @@ pub struct Config { pub server: ServerConfig, pub exports: Vec, pub logging: LoggingConfig, + pub admin: AdminConfig, } #[derive(Debug, Clone, Deserialize)] @@ -89,6 +90,39 @@ pub struct LoggingConfig { 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)] +#[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("/run/arcticwolf/admin.sock"), + socket_mode: 0o600, + } + } +} + impl Default for ServerConfig { fn default() -> Self { Self { @@ -112,6 +146,7 @@ impl Default for Config { }, }], logging: LoggingConfig::default(), + admin: AdminConfig::default(), } } } @@ -181,6 +216,18 @@ 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, + ); + } + let mut seen_uids: HashMap = HashMap::new(); let mut seen_names: HashSet<&str> = HashSet::new(); @@ -419,6 +466,7 @@ mod tests { server: ServerConfig::default(), exports: vec![], logging: LoggingConfig::default(), + admin: AdminConfig::default(), }; let err = config.validate().expect_err("empty exports must fail"); let msg = err.to_string(); @@ -434,6 +482,7 @@ mod tests { local_export("/b", 5, "/srv/b"), ], logging: LoggingConfig::default(), + admin: AdminConfig::default(), }; let err = config.validate().expect_err("duplicate uid must fail"); let msg = err.to_string(); @@ -457,6 +506,7 @@ mod tests { local_export("/data", 2, "/srv/b"), ], logging: LoggingConfig::default(), + admin: AdminConfig::default(), }; let err = config.validate().expect_err("duplicate name must fail"); let msg = err.to_string(); @@ -472,6 +522,7 @@ mod tests { server: ServerConfig::default(), exports: vec![local_export("/data", 0, "/srv/a")], logging: LoggingConfig::default(), + admin: AdminConfig::default(), }; let err = config.validate().expect_err("uid 0 must fail"); let msg = err.to_string(); @@ -484,6 +535,7 @@ mod tests { server: ServerConfig::default(), exports: vec![local_export("data", 1, "/srv/a")], logging: LoggingConfig::default(), + admin: AdminConfig::default(), }; let err = config.validate().expect_err("relative name must fail"); let msg = err.to_string(); @@ -518,6 +570,7 @@ mod tests { local_export("/c", 3, "/srv/c"), ], logging: LoggingConfig::default(), + admin: AdminConfig::default(), }; config.validate().expect("distinct exports must validate"); } @@ -628,4 +681,132 @@ 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" + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 0cbf7bf..5b9fc9d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ 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 mount; diff --git a/src/main.rs b/src/main.rs index 261d118..a16efd2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,10 @@ mod portmap; mod protocol; mod rpc; +// `admin` (issue #25) is consumed from the library crate the same way +// `config` is — there's no reason to have a binary-local copy and doing +// so would re-trigger the dual-compilation problem documented above. +use arcticwolf::admin; use arcticwolf::config::{self, Config}; use protocol::v3::portmap::mapping; @@ -74,6 +78,31 @@ fn register_services( println!(); } +/// Build the admin server future based on `[admin]` config. +/// +/// When `enabled = false`, returns a `pending` future — the caller drops +/// it into `tokio::select!` alongside the RPC servers, and that branch +/// never fires. 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. +fn build_admin_future( + admin: &config::AdminConfig, +) -> Result>>>> { + if !admin.enabled { + return Ok(Box::pin(std::future::pending::>())); + } + + 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 context = admin::AdminContext::shared(); + let socket_path = admin.socket_path.clone(); + Ok(Box::pin(admin::serve(listener, socket_path, context))) +} + #[tokio::main] async fn main() -> Result<()> { // Load configuration first (before tracing init) @@ -186,7 +215,16 @@ async fn main() -> Result<()> { config.server.nfs_port as u32, ); - // Run all three servers concurrently + // Admin Unix-domain-socket server (issue #25 phase 1). + // + // 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!. + let admin_future = build_admin_future(&config.admin)?; + + // Run all servers concurrently. The admin branch is a `pending` future + // when admin is disabled, so its presence in `select!` is free. tokio::select! { result = portmap_server.serve() => { result?; @@ -197,6 +235,9 @@ async fn main() -> Result<()> { result = nfs_server.serve() => { result?; } + result = admin_future => { + result?; + } } Ok(()) From 03c60dbe817900997c89f03d58539ad8125fa744 Mon Sep 17 00:00:00 2001 From: amarok-bot Date: Wed, 20 May 2026 13:13:05 +0800 Subject: [PATCH 2/9] admin: implement status and version commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of #25. The dispatcher now handles two read-only commands end-to-end: - status — daemon uptime, actual listening ports (after bind so port=0 dynamic allocation is resolved), bind address, current log level, and the number of configured exports. - version — daemon CARGO_PKG_VERSION plus vergen-derived build_commit, rustc_version, and build_profile. AdminContext gains the fields these commands read: start_time, ServerMetadata (actual ports), startup_log_level (Phase 3 will replace this with a reload handle), filesystem (for export_count), and config (for bind_address). The new `arcticwolfctl` binary is the operator-facing client. It speaks the Phase 1 wire protocol and supports `status`, `version`, and a global `--socket` flag. Tests cover the in-process client/daemon round trip for both commands and assert the response JSON shape. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: amarok-bot --- Cargo.lock | 296 ++++++++++++++++++++++++++++- Cargo.toml | 39 +++- Earthfile | 4 + build.rs | 36 +++- src/admin/client.rs | 159 ++++++++++++++++ src/admin/commands/mod.rs | 8 + src/admin/commands/status.rs | 60 ++++++ src/admin/commands/version.rs | 84 ++++++++ src/admin/context.rs | 125 ++++++++++-- src/admin/mod.rs | 22 ++- src/admin/protocol.rs | 10 +- src/admin/request.rs | 15 +- src/admin/server.rs | 81 ++++---- src/bin/arcticwolfctl.rs | 249 ++++++++++++++++++++++++ src/config.rs | 2 +- src/main.rs | 51 ++++- tests/test_admin_status_version.rs | 105 ++++++++++ 17 files changed, 1261 insertions(+), 85 deletions(-) create mode 100644 src/admin/client.rs create mode 100644 src/admin/commands/mod.rs create mode 100644 src/admin/commands/status.rs create mode 100644 src/admin/commands/version.rs create mode 100644 src/bin/arcticwolfctl.rs create mode 100644 tests/test_admin_status_version.rs diff --git a/Cargo.lock b/Cargo.lock index 9bd58b1..c9ec3c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ 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 = "1.0.0" @@ -78,6 +87,7 @@ name = "arcticwolf" version = "0.1.0" dependencies = [ "anyhow", + "arcticwolf", "async-trait", "bytes", "clap", @@ -89,12 +99,13 @@ dependencies = [ "serde_ignored", "serde_json", "tempfile", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-util", "toml", "tracing", "tracing-subscriber", + "vergen-gitcl", "xdr-codec", ] @@ -148,6 +159,38 @@ 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 = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -206,6 +249,81 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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" version = "1.0.2" @@ -237,6 +355,12 @@ 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" @@ -332,6 +456,12 @@ 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.14.0" @@ -452,6 +582,21 @@ dependencies = [ "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]] name = "object" version = "0.37.3" @@ -502,6 +647,12 @@ 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 = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "prettyplease" version = "0.2.37" @@ -551,12 +702,50 @@ 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.27" source = "registry+https://github.com/rust-lang/crates.io-index" 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.4" @@ -570,6 +759,12 @@ dependencies = [ "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" @@ -581,6 +776,10 @@ 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" @@ -721,7 +920,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]] @@ -735,6 +943,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" @@ -744,6 +963,39 @@ 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.52.3" @@ -907,6 +1159,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" diff --git a/Cargo.toml b/Cargo.toml index 73dac75..7711587 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,12 +2,23 @@ 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" @@ -37,8 +48,32 @@ nix = { version = "0.29", features = ["socket", "user"] } # 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"] } [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 227aa84..dd3ff4d 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 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/client.rs b/src/admin/client.rs new file mode 100644 index 0000000..207f149 --- /dev/null +++ b/src/admin/client.rs @@ -0,0 +1,159 @@ +//! 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; + +/// 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 +} + +/// 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 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"); + } +} diff --git a/src/admin/commands/mod.rs b/src/admin/commands/mod.rs new file mode 100644 index 0000000..ac17545 --- /dev/null +++ b/src/admin/commands/mod.rs @@ -0,0 +1,8 @@ +//! Daemon-side admin command handlers. +//! +//! Each handler maps one [`AdminRequest`](super::AdminRequest) variant to an +//! [`AdminResponse`](super::AdminResponse). Phase 2 ships the two read-only +//! commands; later phases add their handlers alongside these. + +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..c971c37 --- /dev/null +++ b/src/admin/commands/status.rs @@ -0,0 +1,60 @@ +//! `status` command — a daemon health snapshot. + +use serde_json::json; + +use crate::admin::{AdminContext, AdminResponse}; + +/// 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. +pub fn handle(context: &AdminContext) -> AdminResponse { + 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": context.startup_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) = 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 index 101d707..148adda 100644 --- a/src/admin/context.rs +++ b/src/admin/context.rs @@ -1,35 +1,118 @@ //! Shared state for admin request handlers. //! -//! Phase 1 has no real handlers, so the context is intentionally empty. The -//! struct still exists (and is threaded through `serve()` and the -//! dispatcher) so later phases can add fields — the filesystem handle for -//! exports mutation (Phase 4/5), the tracing reload handle (Phase 3), the -//! audit log writer (Phase 6), etc. — without touching the call sites. +//! Phase 2 is the first real consumer: `status` and `version` read the +//! daemon start instant, the resolved listening ports, the startup log +//! level, the filesystem backend (for the export count), and the original +//! configuration. Later phases extend this struct further — Phase 3 swaps +//! `startup_log_level` for a `tracing_subscriber` reload handle, Phase 6 +//! adds the audit-log writer. //! //! `Clone` and `Default` are deliberately *not* derived: Phase 3's -//! `tracing_subscriber::reload::Handle` does not -//! implement `Clone`, and Phase 6's audit-log writer does not implement -//! `Default`. Dropping those derives now means later phases can add the -//! fields without noisy `derive`-list changes. Consumers share the context -//! via `Arc` (see [`AdminContext::shared`]). +//! `tracing_subscriber::reload::Handle` does not implement `Clone`, and +//! Phase 6's audit-log writer does not implement `Default`. Consumers share +//! the context via `Arc` (see [`AdminContext::shared`]). use std::sync::Arc; +use std::time::Instant; +use crate::config::Config; +use crate::fsal::NfsBackend; + +/// 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 {} +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, + /// Configured log level at startup. Phase 3 replaces this with a + /// `tracing_subscriber` reload handle. + pub startup_log_level: String, + /// Filesystem backend. Phase 2 uses it for `export_count`. + pub filesystem: Arc, + /// Original daemon configuration (e.g. for `bind_address`). + pub config: Arc, +} impl AdminContext { - // `Default` is intentionally not implemented (see module doc), so silence - // the lint that asks for one. - #[allow(clippy::new_without_default)] - pub fn new() -> Self { - Self {} + /// 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). + pub fn shared( + start_time: Instant, + server_metadata: Arc, + startup_log_level: String, + filesystem: Arc, + config: Arc, + ) -> Arc { + Arc::new(Self { + start_time, + server_metadata, + startup_log_level, + filesystem, + config, + }) } +} + +#[cfg(any(test, feature = "test-util"))] +impl AdminContext { + /// Construct a minimal `AdminContext` suitable for tests: one + /// tempdir-backed export, fixed ports, `info` log level. The returned + /// [`tempfile::TempDir`] must be kept alive for as long as the context + /// is used. + /// + /// 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. + pub fn for_test() -> (Arc, tempfile::TempDir) { + use crate::config::{BackendConfig, Config, ExportConfig}; + use crate::fsal::MultiExportFilesystem; - /// Convenience for the call site in `main.rs`, which always wraps the - /// context in an `Arc` so it can be cloned cheaply into per-connection - /// tasks. - pub fn shared() -> Arc { - Arc::new(Self::new()) + 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, + }); + let context = Self::shared( + Instant::now(), + metadata, + "info".to_string(), + filesystem, + Arc::new(config), + ); + (context, tmp) } } diff --git a/src/admin/mod.rs b/src/admin/mod.rs index cd1f81a..33ab86e 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -1,18 +1,28 @@ //! Admin server (issue #25). //! -//! Phase 1 introduces the transport scaffolding only: a length-prefixed -//! JSON codec over a Unix domain socket, a placeholder request/response -//! schema, and a dispatcher that responds `not_implemented` to every -//! command. The actual admin commands (`status`, `exports list`, ...) are -//! wired up in later phases on top of this skeleton. +//! 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 client; +pub mod commands; pub mod context; pub mod protocol; pub mod request; pub mod response; pub mod server; -pub use context::AdminContext; +pub use context::{AdminContext, ServerMetadata}; pub use request::AdminRequest; pub use response::AdminResponse; pub use server::serve; + +/// 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 index e6711c6..4b6269d 100644 --- a/src/admin/protocol.rs +++ b/src/admin/protocol.rs @@ -8,10 +8,12 @@ //! `arcticwolfctl raw ''` debug command requires the wire format to //! be human-readable. //! -//! This module exposes two helpers: -//! - [`framed_request`] / [`framed_response`] wrap a Tokio `AsyncRead+Write` -//! stream in a typed framed sink/stream of `AdminRequest`/`AdminResponse`. -//! - [`decode_request`] / [`encode_response`] are the raw conversion +//! 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. diff --git a/src/admin/request.rs b/src/admin/request.rs index ffd8b9d..779ed66 100644 --- a/src/admin/request.rs +++ b/src/admin/request.rs @@ -1,11 +1,10 @@ //! Admin request schema. //! -//! Phase 1 only declares the variants the rest of the v1 CLI will use; their -//! handlers all return `"Command not implemented in phase 1"` today and are -//! filled in by later phases (Phase 2 wires `Status` and `Version`, -//! Phase 3 the log-level commands, Phase 5 the exports/config commands, +//! Phase 2 wires the two read-only commands (`Status`, `Version`); the +//! remaining variants are added by later phases as their handlers land +//! (Phase 3 the log-level commands, Phase 5 the exports/config commands, //! Phase 7 metrics, Phase 8 shutdown). The variant set is kept in sync with -//! the v1 surface in issue #25 so phase 2+ can land handlers without +//! the v1 surface in issue #25 so later phases can land handlers without //! touching the protocol again. use serde::{Deserialize, Serialize}; @@ -20,6 +19,10 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "command", rename_all = "kebab-case")] pub enum AdminRequest { - /// `arcticwolfctl status` — daemon health snapshot. Phase 2 will implement. + /// `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, } diff --git a/src/admin/server.rs b/src/admin/server.rs index 9315b5a..53371e0 100644 --- a/src/admin/server.rs +++ b/src/admin/server.rs @@ -16,8 +16,10 @@ use futures_util::{SinkExt, StreamExt}; use tokio::net::{UnixListener, UnixStream}; use tracing::{debug, info, warn}; +use super::commands; use super::context::AdminContext; use super::protocol::{decode_request, encode_response, framed}; +use super::request::AdminRequest; use super::response::AdminResponse; /// Upper bound for the post-codec-error courtesy response. After a frame @@ -215,32 +217,32 @@ where framed.send(encoded).await.context("sending admin response") } -/// Phase 1 dispatcher: parse the request to validate the wire format, but -/// answer everything with a not-implemented error. Phase 2+ replaces this -/// with per-command handlers. -fn dispatch(_context: &AdminContext, frame: &[u8]) -> AdminResponse { - match decode_request(frame) { - Ok(req) => { - debug!("admin: received {:?}; not implemented in phase 1", req); - AdminResponse::error("Command not implemented in phase 1") - } - Err(err) => { - // Includes the "Unknown command: ..." prefix that the CLI keys - // on for its user-facing error. - AdminResponse::error(err) - } +/// 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(), } } #[cfg(test)] mod tests { use super::*; - use crate::admin::request::AdminRequest; use tokio_util::codec::{Framed, LengthDelimitedCodec}; fn check_dispatch(frame: &[u8]) -> AdminResponse { - let ctx = AdminContext::new(); - dispatch(&ctx, frame) + let (context, _tmp) = AdminContext::for_test(); + dispatch(&context, frame) } fn client_codec() -> LengthDelimitedCodec { @@ -254,20 +256,28 @@ mod tests { } #[test] - fn dispatch_known_request_returns_not_implemented() { - // The dispatcher must still decode the frame even though it has no - // handler — that catches mismatched serialization in tests as the - // protocol grows. The actual response stays generic until Phase 2. + 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(); - let response = check_dispatch(&payload); - match response { - AdminResponse::Err { error } => { - assert!( - error.contains("not implemented"), - "phase 1 dispatcher must reply with not-implemented; got: {error}", - ); + match check_dispatch(&payload) { + AdminResponse::Ok { data } => { + assert_eq!(data["daemon_version"], env!("CARGO_PKG_VERSION")); + assert!(data.get("export_count").is_some()); } - AdminResponse::Ok { .. } => panic!("phase 1 dispatcher must not return Ok"), + 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}"), } } @@ -419,7 +429,7 @@ mod tests { /// 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 the Phase 1 "not implemented" `AdminResponse`. + /// 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] @@ -428,7 +438,7 @@ mod tests { let socket_path = dir.path().join("admin.sock"); let listener = bind_admin_socket(&socket_path, 0o600).expect("bind admin sock"); - let context = AdminContext::shared(); + let (context, _export_tmp) = AdminContext::for_test(); let socket_path_for_serve = socket_path.clone(); let server = tokio::spawn(async move { serve(listener, socket_path_for_serve, context).await }); @@ -452,13 +462,12 @@ mod tests { .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 } => { - assert!( - error.contains("not implemented"), - "phase 1 server must reply with not-implemented; got: {error}", - ); + panic!("phase 2 server must answer status with Ok; got: {error}") } - AdminResponse::Ok { .. } => panic!("phase 1 server must not return Ok"), } server.abort(); diff --git a/src/bin/arcticwolfctl.rs b/src/bin/arcticwolfctl.rs new file mode 100644 index 0000000..9946ff5 --- /dev/null +++ b/src/bin/arcticwolfctl.rs @@ -0,0 +1,249 @@ +//! `arcticwolfctl` — operator CLI for the Arctic Wolf admin socket. +//! +//! Speaks the length-prefixed JSON admin protocol (issue #25). Phase 2 +//! ships two read-only subcommands, `status` and `version`. 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 clap::{Parser, Subcommand}; + +#[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, +} + +#[tokio::main] +async fn main() -> ExitCode { + let cli = Cli::parse(); + match cli.command { + Command::Status => run_status(&cli).await, + Command::Version => run_version(&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 + } + } +} + +#[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(); + } +} diff --git a/src/config.rs b/src/config.rs index 6800a1b..3575d3d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -117,7 +117,7 @@ impl Default for AdminConfig { fn default() -> Self { Self { enabled: false, - socket_path: PathBuf::from("/run/arcticwolf/admin.sock"), + socket_path: PathBuf::from(crate::admin::DEFAULT_ADMIN_SOCKET_PATH), socket_mode: 0o600, } } diff --git a/src/main.rs b/src/main.rs index a16efd2..1e27a69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -87,6 +87,7 @@ fn register_services( /// returns from `main()` with a clear error instead of being deferred. fn build_admin_future( admin: &config::AdminConfig, + context: Arc, ) -> Result>>>> { if !admin.enabled { return Ok(Box::pin(std::future::pending::>())); @@ -98,15 +99,19 @@ fn build_admin_future( println!(); let listener = admin::server::bind_admin_socket(&admin.socket_path, admin.socket_mode)?; - let context = admin::AdminContext::shared(); let socket_path = admin.socket_path.clone(); Ok(Box::pin(admin::serve(listener, socket_path, context))) } #[tokio::main] async fn main() -> Result<()> { - // Load configuration first (before tracing init) - let config = Config::load()?; + // Capture the process start instant up front so the admin `status` + // command can report an accurate uptime. + let start_time = 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 configured log level // Priority: config file -> RUST_LOG env -> "info" @@ -123,6 +128,12 @@ async fn main() -> Result<()> { }; tracing_subscriber::fmt().with_max_level(log_level).init(); + // The log level that actually took effect. When `log_level_str` fails + // to parse we fall back to INFO above, so this must report `info` + // rather than the rejected configuration string — the startup banner + // and the admin `status` command both surface it to operators. + let startup_log_level = log_level.to_string().to_lowercase(); + println!("Arctic Wolf NFS Server"); println!("======================"); println!("Configuration:"); @@ -137,7 +148,7 @@ async fn main() -> Result<()> { } ); println!(" NFS port: {}", config.server.nfs_port); - println!(" Log level: {}", log_level_str); + println!(" Log level: {}", startup_log_level); println!(); // Initialize FSAL (File System Abstraction Layer). @@ -215,13 +226,41 @@ async fn main() -> Result<()> { config.server.nfs_port as u32, ); - // Admin Unix-domain-socket server (issue #25 phase 1). + // 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!. - let admin_future = build_admin_future(&config.admin)?; + // + // 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()?, + }); + + // `AdminContext` lives in the library crate, so it needs the library's + // `NfsBackend` view — a distinct type from the binary-local `fsal` + // module compiled into this binary (see the module comment at the top + // of this file). Until that duplication is cleaned up, build a second + // router from the same export config purely so the admin `status` + // command can report `export_count`; both routers observe identical + // configuration. + let admin_filesystem: Arc = Arc::new( + arcticwolf::fsal::MultiExportFilesystem::build_from_config(&config.exports)?, + ); + let admin_context = admin::AdminContext::shared( + start_time, + server_metadata, + startup_log_level, + admin_filesystem, + config.clone(), + ); + + let admin_future = build_admin_future(&config.admin, admin_context)?; // Run all servers concurrently. The admin branch is a `pending` future // when admin is disabled, so its presence in `select!` is free. diff --git a/tests/test_admin_status_version.rs b/tests/test_admin_status_version.rs new file mode 100644 index 0000000..4cc13e1 --- /dev/null +++ b/tests/test_admin_status_version.rs @@ -0,0 +1,105 @@ +//! 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::time::Duration; + +use arcticwolf::admin::{self, AdminContext}; + +#[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) = AdminContext::for_test(); + let listener = + admin::server::bind_admin_socket(&socket_path, 0o600).expect("bind admin socket"); + let server = tokio::spawn(admin::serve(listener, socket_path.clone(), context)); + + // --- 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\"")); + + server.abort(); +} + +#[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"); +} From 2afb449e1a7c869555e62fde9cb2d6a0e5c820c4 Mon Sep 17 00:00:00 2001 From: amarok-bot Date: Wed, 20 May 2026 16:56:38 +0800 Subject: [PATCH 3/9] refactor: drop dual lib/bin module compilation (phase 2.a of #25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2.a of #25 — a cross-cutting cleanup required before Phase 5 needs admin export mutations to be observable by the NFS path. Until now the daemon binary re-declared every module (mod config; mod fsal; ...) instead of importing them from the library crate, causing every type to be compiled twice and creating two distinct Type identities the borrow checker couldn't unify. Phase 2 of #25 worked around this by building a second MultiExportFilesystem instance just so the AdminContext (which lives in the lib crate) could hold an Arc of the right type. main.rs now imports modules from the library (use arcticwolf::{config, fsal, ...}) and the daemon owns a single MultiExportFilesystem instance that is shared between the three RPC servers and the admin server. Phase 5 will need this single source of truth so exports add/remove/update is observable by both the NFS path and the admin path. No NFS or admin behavior change; pure structural cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: amarok-bot --- src/main.rs | 41 ++++++++--------------------------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1e27a69..64aa310 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,30 +4,12 @@ compile_error!("Arctic Wolf NFS server only supports Linux"); use anyhow::Result; use std::sync::Arc; -// `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; - -// `admin` (issue #25) is consumed from the library crate the same way -// `config` is — there's no reason to have a binary-local copy and doing -// so would re-trigger the dual-compilation problem documented above. use arcticwolf::admin; use arcticwolf::config::{self, Config}; -use protocol::v3::portmap::mapping; +use arcticwolf::fsal; +use arcticwolf::portmap; +use arcticwolf::protocol::v3::portmap::mapping; +use arcticwolf::rpc; /// Portmapper port is fixed at 111 per RFC 1833 const PORTMAP_PORT: u16 = 111; @@ -242,21 +224,14 @@ async fn main() -> Result<()> { portmap_port: portmap_server.local_port()?, }); - // `AdminContext` lives in the library crate, so it needs the library's - // `NfsBackend` view — a distinct type from the binary-local `fsal` - // module compiled into this binary (see the module comment at the top - // of this file). Until that duplication is cleaned up, build a second - // router from the same export config purely so the admin `status` - // command can report `export_count`; both routers observe identical - // configuration. - let admin_filesystem: Arc = Arc::new( - arcticwolf::fsal::MultiExportFilesystem::build_from_config(&config.exports)?, - ); + // 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( start_time, server_metadata, startup_log_level, - admin_filesystem, + filesystem.clone(), config.clone(), ); From e66d44c6ad25238d74287f671587d8a42ec04a46 Mon Sep 17 00:00:00 2001 From: amarok-bot Date: Thu, 21 May 2026 18:41:07 +0800 Subject: [PATCH 4/9] admin: implement log-level get/set with tracing reload handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of #25. Migrates the daemon's tracing setup from `tracing_subscriber::fmt().init()` to `Registry` + `EnvFilter` + `reload::Layer`. The reload handle is threaded through `AdminContext`, replacing the static `startup_log_level: String` field added in Phase 2 — `status` now reports the live filter directive, and the new commands let operators tune verbosity at runtime without a daemon restart. AdminRequest gains LogLevelGet and LogLevelSet { level } variants; the dispatcher routes them to handlers in src/admin/commands/log_level.rs. Set rejects unparseable filter strings (e.g. `tomato`) with an error and leaves the previous filter intact, matching the AC. arcticwolfctl learns nested `log-level get` and `log-level set ` subcommands; both honour `--socket` and `--json`. Tests cover the set→get round-trip, the rejected-input path where filter must not change, and the arcticwolfctl exit-code contract for both success and failure. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: amarok-bot --- Cargo.lock | 13 + Cargo.toml | 6 +- Earthfile | 19 +- src/admin/client.rs | 59 +++++ src/admin/commands/log_level.rs | 412 +++++++++++++++++++++++++++++ src/admin/commands/mod.rs | 6 +- src/admin/commands/status.rs | 12 +- src/admin/context.rs | 120 +++++++-- src/admin/request.rs | 13 +- src/admin/server.rs | 6 +- src/bin/arcticwolfctl.rs | 149 ++++++++++- src/main.rs | 44 +-- tests/test_admin_log_level.rs | 88 ++++++ tests/test_admin_status_version.rs | 2 +- 14 files changed, 893 insertions(+), 56 deletions(-) create mode 100644 src/admin/commands/log_level.rs create mode 100644 tests/test_admin_log_level.rs diff --git a/Cargo.lock b/Cargo.lock index c9ec3c4..19dc2e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -525,6 +525,15 @@ 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 = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1127,10 +1136,14 @@ version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] diff --git a/Cargo.toml b/Cargo.toml index 7711587..f640766 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,11 @@ 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"] } diff --git a/Earthfile b/Earthfile index dd3ff4d..844ce9b 100644 --- a/Earthfile +++ b/Earthfile @@ -48,16 +48,27 @@ lint: RUN cargo clippy -- -D warnings # earthly +lockfile -# Regenerate Cargo.lock from Cargo.toml and copy it back to the host. -# Use after editing [dependencies] in Cargo.toml. +# 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 Cargo.toml ./ + # 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 generate-lockfile + RUN cargo fetch SAVE ARTIFACT Cargo.lock AS LOCAL Cargo.lock # earthly +image diff --git a/src/admin/client.rs b/src/admin/client.rs index 207f149..0b090d7 100644 --- a/src/admin/client.rs +++ b/src/admin/client.rs @@ -62,6 +62,25 @@ 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 +} + /// Render a `status` payload either as pretty JSON or a human summary. pub fn render_status(data: &Value, json: bool) -> Result { if json { @@ -93,6 +112,24 @@ pub fn render_version(data: &Value, json: bool) -> Result { 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 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. @@ -156,4 +193,26 @@ mod tests { 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); + } } diff --git a/src/admin/commands/log_level.rs b/src/admin/commands/log_level.rs new file mode 100644 index 0000000..253cfad --- /dev/null +++ b/src/admin/commands/log_level.rs @@ -0,0 +1,412 @@ +//! `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, NfsBackend}; + + #[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), + ); + + // 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/mod.rs b/src/admin/commands/mod.rs index ac17545..9cf1abc 100644 --- a/src/admin/commands/mod.rs +++ b/src/admin/commands/mod.rs @@ -1,8 +1,10 @@ //! Daemon-side admin command handlers. //! //! Each handler maps one [`AdminRequest`](super::AdminRequest) variant to an -//! [`AdminResponse`](super::AdminResponse). Phase 2 ships the two read-only -//! commands; later phases add their handlers alongside these. +//! [`AdminResponse`](super::AdminResponse). Phase 2 shipped the two +//! read-only commands; Phase 3 adds the log-level commands. Later phases +//! add their handlers alongside these. +pub mod log_level; pub mod status; pub mod version; diff --git a/src/admin/commands/status.rs b/src/admin/commands/status.rs index c971c37..6e502c6 100644 --- a/src/admin/commands/status.rs +++ b/src/admin/commands/status.rs @@ -8,8 +8,14 @@ use crate::admin::{AdminContext, AdminResponse}; /// /// 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. +/// 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(), @@ -17,7 +23,7 @@ pub fn handle(context: &AdminContext) -> AdminResponse { "nfs_port": context.server_metadata.nfs_port, "mount_port": context.server_metadata.mount_port, "portmap_port": context.server_metadata.portmap_port, - "log_level": context.startup_log_level, + "log_level": log_level, "export_count": context.filesystem.list_exports().len(), }); AdminResponse::Ok { data } @@ -29,7 +35,7 @@ mod tests { #[test] fn status_returns_ok_with_all_documented_fields() { - let (context, _tmp) = AdminContext::for_test(); + 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}"), diff --git a/src/admin/context.rs b/src/admin/context.rs index 148adda..fd912f1 100644 --- a/src/admin/context.rs +++ b/src/admin/context.rs @@ -1,23 +1,33 @@ //! Shared state for admin request handlers. //! -//! Phase 2 is the first real consumer: `status` and `version` read the -//! daemon start instant, the resolved listening ports, the startup log -//! level, the filesystem backend (for the export count), and the original -//! configuration. Later phases extend this struct further — Phase 3 swaps -//! `startup_log_level` for a `tracing_subscriber` reload handle, Phase 6 -//! adds the audit-log writer. +//! 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: Phase 3's -//! `tracing_subscriber::reload::Handle` does not implement `Clone`, and -//! Phase 6's audit-log writer does not implement `Default`. Consumers share -//! the context via `Arc` (see [`AdminContext::shared`]). +//! `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::config::Config; use crate::fsal::NfsBackend; +/// 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 @@ -40,9 +50,11 @@ pub struct AdminContext { pub start_time: Instant, /// Actual listening ports, resolved after bind. pub server_metadata: Arc, - /// Configured log level at startup. Phase 3 replaces this with a - /// `tracing_subscriber` reload handle. - pub startup_log_level: String, + /// 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`. pub filesystem: Arc, /// Original daemon configuration (e.g. for `bind_address`). @@ -57,31 +69,65 @@ impl AdminContext { pub fn shared( start_time: Instant, server_metadata: Arc, - startup_log_level: String, + log_reload: LogReloadHandle, filesystem: Arc, config: Arc, ) -> Arc { Arc::new(Self { start_time, server_metadata, - startup_log_level, + log_reload, filesystem, config, }) } } +/// 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. The returned - /// [`tempfile::TempDir`] must be kept alive for as long as the context - /// is used. + /// [`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. - pub fn for_test() -> (Arc, tempfile::TempDir) { + pub fn for_test() -> (Arc, tempfile::TempDir, TestLogReloadGuard) { use crate::config::{BackendConfig, Config, ExportConfig}; use crate::fsal::MultiExportFilesystem; @@ -106,13 +152,47 @@ impl AdminContext { 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, - "info".to_string(), + log_reload, filesystem, Arc::new(config), ); - (context, tmp) + (context, tmp, guard) } } diff --git a/src/admin/request.rs b/src/admin/request.rs index 779ed66..fd63f0b 100644 --- a/src/admin/request.rs +++ b/src/admin/request.rs @@ -1,8 +1,8 @@ //! Admin request schema. //! -//! Phase 2 wires the two read-only commands (`Status`, `Version`); the -//! remaining variants are added by later phases as their handlers land -//! (Phase 3 the log-level commands, Phase 5 the exports/config commands, +//! Phase 2 wired the two read-only commands (`Status`, `Version`); Phase 3 +//! adds the log-level commands. The remaining variants are added by later +//! phases as their handlers land (Phase 5 the exports/config commands, //! 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. @@ -25,4 +25,11 @@ pub enum AdminRequest { /// `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 }, } diff --git a/src/admin/server.rs b/src/admin/server.rs index 53371e0..7ff74bb 100644 --- a/src/admin/server.rs +++ b/src/admin/server.rs @@ -232,6 +232,8 @@ fn dispatch(context: &AdminContext, frame: &[u8]) -> AdminResponse { 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), } } @@ -241,7 +243,7 @@ mod tests { use tokio_util::codec::{Framed, LengthDelimitedCodec}; fn check_dispatch(frame: &[u8]) -> AdminResponse { - let (context, _tmp) = AdminContext::for_test(); + let (context, _tmp, _log_guard) = AdminContext::for_test(); dispatch(&context, frame) } @@ -438,7 +440,7 @@ mod tests { let socket_path = dir.path().join("admin.sock"); let listener = bind_admin_socket(&socket_path, 0o600).expect("bind admin sock"); - let (context, _export_tmp) = AdminContext::for_test(); + let (context, _export_tmp, _log_guard) = AdminContext::for_test(); let socket_path_for_serve = socket_path.clone(); let server = tokio::spawn(async move { serve(listener, socket_path_for_serve, context).await }); diff --git a/src/bin/arcticwolfctl.rs b/src/bin/arcticwolfctl.rs index 9946ff5..6632635 100644 --- a/src/bin/arcticwolfctl.rs +++ b/src/bin/arcticwolfctl.rs @@ -1,7 +1,8 @@ //! `arcticwolfctl` — operator CLI for the Arctic Wolf admin socket. //! //! Speaks the length-prefixed JSON admin protocol (issue #25). Phase 2 -//! ships two read-only subcommands, `status` and `version`. A global +//! shipped two read-only subcommands, `status` and `version`; Phase 3 adds +//! the nested `log-level get` / `log-level set ` commands. A global //! `--socket` flag overrides the socket path and `--json` swaps the //! human-readable summary for the raw response payload. @@ -32,14 +33,37 @@ enum Command { Status, /// Show client and daemon version information. Version, + /// Inspect or change the daemon's live log level. + LogLevel { + #[command(subcommand)] + action: LogLevelAction, + }, +} + +/// 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, + }, } #[tokio::main] async fn main() -> ExitCode { let cli = Cli::parse(); - match cli.command { + // 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, + }, } } @@ -129,6 +153,51 @@ async fn run_version(cli: &Cli) -> ExitCode { } } +/// `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 + } + } +} + #[cfg(test)] mod tests { use std::path::Path; @@ -246,4 +315,80 @@ mod tests { 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(); + } } diff --git a/src/main.rs b/src/main.rs index 64aa310..a367a28 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ compile_error!("Arctic Wolf NFS server only supports Linux"); use anyhow::Result; use std::sync::Arc; +use tracing_subscriber::{EnvFilter, layer::SubscriberExt, reload, util::SubscriberInitExt}; use arcticwolf::admin; use arcticwolf::config::{self, Config}; @@ -95,26 +96,33 @@ async fn main() -> Result<()> { // so it can be shared with the admin context without a deep copy. let config = Arc::new(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(_) => { + // 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 log level that actually took effect. When `log_level_str` fails - // to parse we fall back to INFO above, so this must report `info` - // rather than the rejected configuration string — the startup banner - // and the admin `status` command both surface it to operators. - let startup_log_level = log_level.to_string().to_lowercase(); + // 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(); println!("Arctic Wolf NFS Server"); println!("======================"); @@ -130,7 +138,7 @@ async fn main() -> Result<()> { } ); println!(" NFS port: {}", config.server.nfs_port); - println!(" Log level: {}", startup_log_level); + println!(" Log level: {}", effective_log_filter); println!(); // Initialize FSAL (File System Abstraction Layer). @@ -230,7 +238,7 @@ async fn main() -> Result<()> { let admin_context = admin::AdminContext::shared( start_time, server_metadata, - startup_log_level, + log_reload, filesystem.clone(), config.clone(), ); diff --git a/tests/test_admin_log_level.rs b/tests/test_admin_log_level.rs new file mode 100644 index 0000000..36be0a5 --- /dev/null +++ b/tests/test_admin_log_level.rs @@ -0,0 +1,88 @@ +//! 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::time::Duration; + +use arcticwolf::admin::{self, AdminContext, AdminRequest, AdminResponse}; + +#[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 server = tokio::spawn(admin::serve(listener, socket_path.clone(), context)); + + // 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"); + + server.abort(); +} + +#[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 server = tokio::spawn(admin::serve(listener, socket_path.clone(), context)); + + // 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", + ); + + server.abort(); +} diff --git a/tests/test_admin_status_version.rs b/tests/test_admin_status_version.rs index 4cc13e1..d117244 100644 --- a/tests/test_admin_status_version.rs +++ b/tests/test_admin_status_version.rs @@ -14,7 +14,7 @@ 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) = AdminContext::for_test(); + let (context, _export_dir, _log_guard) = AdminContext::for_test(); let listener = admin::server::bind_admin_socket(&socket_path, 0o600).expect("bind admin socket"); let server = tokio::spawn(admin::serve(listener, socket_path.clone(), context)); From b7bfc06f701b7fa1411379d2cc2ec6618bb35601 Mon Sep 17 00:00:00 2001 From: amarok-bot Date: Mon, 1 Jun 2026 01:49:29 +0800 Subject: [PATCH 5/9] fsal: refactor MultiExportFilesystem to ArcSwap snapshot Phase 4 of #25. Wraps the export map in arc_swap::ArcSwap so admin commands (Phase 5) can mutate it concurrently with live NFS traffic, without holding a guard across any .await. The snapshot type is ExportsSnapshot { by_uid, by_name, retired_uids }. retired_uids tracks export uids that were live and then removed during this daemon's lifetime, preventing reuse via add_export until the next restart (where the config file is re-read fresh). All Filesystem and ExportRegistry trait methods load the snapshot, clone the inner Arc out, drop the snapshot guard, and only then .await. This pattern is the reason the wrapper exists at all - the dual-compilation cleanup in Phase 2.a already ensured the snapshot is shared between the NFS dispatchers and admin commands. Single-handle ops funnel through `inner_for_handle`; the two cross-export-pair validators (rename, link) share `inner_for_same_export_pair` so the "load -> clone Arc -> drop guard" shape exists in exactly two places and rustfmt has no excuse to wrap either one weirdly. New inherent methods on MultiExportFilesystem: - add_export(&ExportConfig) - rebuilds snapshot, swaps atomically. Rejects uid in use, retired uids, name in use, and invalid config (uid 0, name not starting with `/`, non-absolute local path). - remove_export(&ExportSelector) - moves uid to retired_uids. Returns the removed uid. - update_export(&ExportSelector, Option read_only) - v1 only mutates read_only. Larger mutations (name, uid, path) are deferred per the #25 plan. ExportSelector lives in fsal::multi_export so the storage layer owns its own selector type; Phase 5 wires the admin command on top. This phase does not add any admin command yet. NFS client behaviour is unchanged. Tests cover add/remove/update happy paths, all rejection modes (duplicate uid/name, retired uid reuse, no-op update, not-found, invalid config), and a read-only flip observable via is_read_only(handle) round-trip. Signed-off-by: amarok-bot --- Cargo.lock | 10 + Cargo.toml | 9 + src/config.rs | 7 +- src/fsal/multi_export.rs | 907 ++++++++++++++++++++++++++++++++------- 4 files changed, 773 insertions(+), 160 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 19dc2e3..531d315 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,11 +82,21 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" 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", diff --git a/Cargo.toml b/Cargo.toml index f640766..74c7048 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,15 @@ futures-util = { version = "0.3", default-features = false, features = ["sink", # 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" diff --git a/src/config.rs b/src/config.rs index 3575d3d..a21c00a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -247,12 +247,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); } diff --git a/src/fsal/multi_export.rs b/src/fsal/multi_export.rs index 290849e..3fbf86b 100644 --- a/src/fsal/multi_export.rs +++ b/src/fsal/multi_export.rs @@ -7,11 +7,19 @@ // 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 std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; use crate::config::{BackendConfig as ConfigBackend, ExportConfig}; @@ -19,7 +27,50 @@ 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(()) +} + /// 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` (cheap refcount bump), it does not duplicate the +/// backend's `HandleManager` state. +#[derive(Clone)] struct ExportEntry { name: String, read_only: bool, @@ -29,16 +80,89 @@ struct ExportEntry { fs: 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)) + } +} + +/// 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. +#[derive(Debug, Clone, PartialEq, Eq)] +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 +179,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 { @@ -82,14 +206,14 @@ impl MultiExportFilesystem { fs: Arc::new(fs), }; - 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 +221,266 @@ 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 a clone of the + /// owning backend's `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. + fn inner_for_handle(&self, handle: &FileHandle) -> Result> { + let snapshot = self.state.load(); + Ok(snapshot.entry_for_handle(handle)?.fs.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> { + 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 + ) + })?; + Ok(entry.fs.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_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() + ); + } + } + } + + 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 = 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 + ) + })? + } + }; + + let entry = ExportEntry { + name: config.name.clone(), + read_only: config.read_only, + fs: Arc::new(fs), + }; + + 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 that was removed + /// (now in `retired_uids`). Errors if no live export matches. + /// + /// 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(uid) + } + + /// 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. + /// + /// 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<()> { + if new_read_only.is_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)?; + + let mut by_uid = current.by_uid.clone(); + if let Some(ro) = new_read_only { + // `unwrap` safe: `resolve_selector` proved the key exists, and + // we just cloned the map so it still does. + by_uid.get_mut(&uid).unwrap().read_only = 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(()) } } 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,11 +488,12 @@ 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, @@ -150,8 +504,9 @@ impl ExportRegistry for MultiExportFilesystem { } 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, } } @@ -164,21 +519,18 @@ impl ExportRegistry for MultiExportFilesystem { #[async_trait] impl Filesystem for MultiExportFilesystem { async fn lookup(&self, dir_handle: &FileHandle, name: &str) -> Result { - self.entry_for_handle(dir_handle)? - .fs - .lookup(dir_handle, name) - .await + let inner = self.inner_for_handle(dir_handle)?; + inner.lookup(dir_handle, name).await } async fn getattr(&self, handle: &FileHandle) -> Result { - self.entry_for_handle(handle)?.fs.getattr(handle).await + let inner = self.inner_for_handle(handle)?; + inner.getattr(handle).await } async fn read(&self, handle: &FileHandle, offset: u64, count: u32) -> Result> { - self.entry_for_handle(handle)? - .fs - .read(handle, offset, count) - .await + let inner = self.inner_for_handle(handle)?; + inner.read(handle, offset, count).await } async fn readdir( @@ -187,31 +539,23 @@ 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 = self.inner_for_handle(dir_handle)?; + inner.readdir(dir_handle, cookie, count).await } async fn write(&self, handle: &FileHandle, offset: u64, data: &[u8]) -> Result { - self.entry_for_handle(handle)? - .fs - .write(handle, offset, data) - .await + let inner = self.inner_for_handle(handle)?; + inner.write(handle, offset, data).await } async fn setattr_size(&self, handle: &FileHandle, size: u64) -> Result<()> { - self.entry_for_handle(handle)? - .fs - .setattr_size(handle, size) - .await + let inner = self.inner_for_handle(handle)?; + inner.setattr_size(handle, size).await } async fn setattr_mode(&self, handle: &FileHandle, mode: u32) -> Result<()> { - self.entry_for_handle(handle)? - .fs - .setattr_mode(handle, mode) - .await + let inner = self.inner_for_handle(handle)?; + inner.setattr_mode(handle, mode).await } async fn setattr_owner( @@ -220,38 +564,28 @@ impl Filesystem for MultiExportFilesystem { uid: Option, gid: Option, ) -> Result<()> { - self.entry_for_handle(handle)? - .fs - .setattr_owner(handle, uid, gid) - .await + let inner = self.inner_for_handle(handle)?; + inner.setattr_owner(handle, uid, gid).await } 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 = self.inner_for_handle(dir_handle)?; + inner.create(dir_handle, name, mode).await } async fn remove(&self, dir_handle: &FileHandle, name: &str) -> Result<()> { - self.entry_for_handle(dir_handle)? - .fs - .remove(dir_handle, name) - .await + let inner = self.inner_for_handle(dir_handle)?; + inner.remove(dir_handle, name).await } 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 = self.inner_for_handle(dir_handle)?; + inner.mkdir(dir_handle, name, mode).await } async fn rmdir(&self, dir_handle: &FileHandle, name: &str) -> Result<()> { - self.entry_for_handle(dir_handle)? - .fs - .rmdir(dir_handle, name) - .await + let inner = self.inner_for_handle(dir_handle)?; + inner.rmdir(dir_handle, name).await } async fn rename( @@ -261,34 +595,14 @@ 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 = self.inner_for_same_export_pair( + from_dir_handle, + to_dir_handle, + "rename", + "source", + "target", + )?; + inner .rename(from_dir_handle, from_name, to_dir_handle, to_name) .await } @@ -299,14 +613,13 @@ impl Filesystem for MultiExportFilesystem { name: &str, target: &str, ) -> Result { - self.entry_for_handle(dir_handle)? - .fs - .symlink(dir_handle, name, target) - .await + let inner = self.inner_for_handle(dir_handle)?; + inner.symlink(dir_handle, name, target).await } async fn readlink(&self, handle: &FileHandle) -> Result { - self.entry_for_handle(handle)?.fs.readlink(handle).await + let inner = self.inner_for_handle(handle)?; + inner.readlink(handle).await } async fn link( @@ -315,38 +628,14 @@ 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 = + self.inner_for_same_export_pair(file_handle, dir_handle, "link", "file", "dir")?; + inner.link(file_handle, dir_handle, name).await } async fn commit(&self, handle: &FileHandle, offset: u64, count: u32) -> Result<()> { - self.entry_for_handle(handle)? - .fs - .commit(handle, offset, count) - .await + let inner = self.inner_for_handle(handle)?; + inner.commit(handle, offset, count).await } async fn mknod( @@ -357,14 +646,13 @@ 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 = self.inner_for_handle(dir_handle)?; + inner.mknod(dir_handle, name, file_type, mode, rdev).await } async fn fs_stats(&self, handle: &FileHandle) -> Result { - self.entry_for_handle(handle)?.fs.fs_stats(handle).await + let inner = self.inner_for_handle(handle)?; + inner.fs_stats(handle).await } } @@ -402,9 +690,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] @@ -621,4 +911,313 @@ 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, 1); + + 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, 2); + 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. + router + .update_export(&ExportSelector::Name("/data".to_string()), Some(true)) + .expect("update must succeed"); + 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. + router + .update_export(&ExportSelector::Uid(2), Some(false)) + .expect("update must succeed"); + 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"); + } } From e238897b1b0c5365452aef3729933ae1ad12dec6 Mon Sep 17 00:00:00 2001 From: amarok-bot Date: Mon, 1 Jun 2026 21:45:40 +0800 Subject: [PATCH 6/9] admin: wire exports list/add/remove/update and config show Implements Phase 5 of #25. New AdminRequest variants exports-list, exports-add, exports-remove, exports-update, config-show; handlers delegate to MultiExportFilesystem inherent methods from Phase 4. arcticwolfctl gains the matching subcommand tree. --dry-run on mutating commands validates without taking the write lock. config show returns the startup config (sensitive-field redaction is a follow-up when non-local fsals land). Signed-off-by: amarok-bot --- src/admin/client.rs | 278 +++++++++++ src/admin/commands/config_show.rs | 46 ++ src/admin/commands/exports_add.rs | 111 +++++ src/admin/commands/exports_list.rs | 48 ++ src/admin/commands/exports_remove.rs | 88 ++++ src/admin/commands/exports_update.rs | 102 ++++ src/admin/commands/log_level.rs | 4 +- src/admin/commands/mod.rs | 10 +- src/admin/commands/status.rs | 1 + src/admin/context.rs | 15 +- src/admin/request.rs | 122 ++++- src/admin/server.rs | 13 + src/bin/arcticwolfctl.rs | 700 ++++++++++++++++++++++++++- src/config.rs | 14 +- src/fsal/mod.rs | 18 +- src/fsal/multi_export.rs | 303 ++++++++++-- src/main.rs | 12 +- src/mount/export.rs | 2 + src/protocol/v3/mount.rs | 3 + tests/test_admin_exports.rs | 316 ++++++++++++ 20 files changed, 2126 insertions(+), 80 deletions(-) create mode 100644 src/admin/commands/config_show.rs create mode 100644 src/admin/commands/exports_add.rs create mode 100644 src/admin/commands/exports_list.rs create mode 100644 src/admin/commands/exports_remove.rs create mode 100644 src/admin/commands/exports_update.rs create mode 100644 tests/test_admin_exports.rs diff --git a/src/admin/client.rs b/src/admin/client.rs index 0b090d7..5b8e616 100644 --- a/src/admin/client.rs +++ b/src/admin/client.rs @@ -17,6 +17,8 @@ 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. @@ -81,6 +83,52 @@ pub async fn set_log_level(socket_path: &Path, level: &str) -> Result { .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 +} + /// Render a `status` payload either as pretty JSON or a human summary. pub fn render_status(data: &Value, json: bool) -> Result { if json { @@ -130,6 +178,168 @@ pub fn render_log_level_set(data: &Value, json: bool) -> Result { 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 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. @@ -215,4 +425,72 @@ mod tests { 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 index 253cfad..a3ed3d9 100644 --- a/src/admin/commands/log_level.rs +++ b/src/admin/commands/log_level.rs @@ -301,7 +301,7 @@ mod tests { use crate::admin::context::{LogReloadHandle, ServerMetadata}; use crate::config::{BackendConfig, Config, ExportConfig}; - use crate::fsal::{MultiExportFilesystem, NfsBackend}; + use crate::fsal::MultiExportFilesystem; #[derive(Clone, Default)] struct CaptureLayer(Arc>>); @@ -361,7 +361,7 @@ mod tests { }], ..Config::default() }; - let filesystem: Arc = Arc::new( + let filesystem: Arc = Arc::new( MultiExportFilesystem::build_from_config(&config.exports) .expect("build test filesystem"), ); diff --git a/src/admin/commands/mod.rs b/src/admin/commands/mod.rs index 9cf1abc..a26284f 100644 --- a/src/admin/commands/mod.rs +++ b/src/admin/commands/mod.rs @@ -2,9 +2,15 @@ //! //! Each handler maps one [`AdminRequest`](super::AdminRequest) variant to an //! [`AdminResponse`](super::AdminResponse). Phase 2 shipped the two -//! read-only commands; Phase 3 adds the log-level commands. Later phases -//! add their handlers alongside these. +//! 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 status; pub mod version; diff --git a/src/admin/commands/status.rs b/src/admin/commands/status.rs index 6e502c6..485c4e5 100644 --- a/src/admin/commands/status.rs +++ b/src/admin/commands/status.rs @@ -3,6 +3,7 @@ use serde_json::json; use crate::admin::{AdminContext, AdminResponse}; +use crate::fsal::ExportRegistry; /// Build the `status` response from the live daemon context. /// diff --git a/src/admin/context.rs b/src/admin/context.rs index fd912f1..d822791 100644 --- a/src/admin/context.rs +++ b/src/admin/context.rs @@ -17,7 +17,7 @@ use std::time::Instant; use tracing_subscriber::{EnvFilter, Registry, reload}; use crate::config::Config; -use crate::fsal::NfsBackend; +use crate::fsal::MultiExportFilesystem; /// Live reload handle for the daemon's tracing `EnvFilter`. /// @@ -55,8 +55,12 @@ pub struct AdminContext { /// 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`. - pub filesystem: Arc, + /// 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, } @@ -70,7 +74,7 @@ impl AdminContext { start_time: Instant, server_metadata: Arc, log_reload: LogReloadHandle, - filesystem: Arc, + filesystem: Arc, config: Arc, ) -> Arc { Arc::new(Self { @@ -129,7 +133,6 @@ impl AdminContext { /// shipped in release builds. pub fn for_test() -> (Arc, tempfile::TempDir, TestLogReloadGuard) { use crate::config::{BackendConfig, Config, ExportConfig}; - use crate::fsal::MultiExportFilesystem; let tmp = tempfile::tempdir().expect("create tempdir for admin test context"); let config = Config { @@ -143,7 +146,7 @@ impl AdminContext { }], ..Config::default() }; - let filesystem: Arc = Arc::new( + let filesystem: Arc = Arc::new( MultiExportFilesystem::build_from_config(&config.exports) .expect("build test filesystem"), ); diff --git a/src/admin/request.rs b/src/admin/request.rs index fd63f0b..44b91dc 100644 --- a/src/admin/request.rs +++ b/src/admin/request.rs @@ -1,14 +1,16 @@ //! Admin request schema. //! //! Phase 2 wired the two read-only commands (`Status`, `Version`); Phase 3 -//! adds the log-level commands. The remaining variants are added by later -//! phases as their handlers land (Phase 5 the exports/config commands, -//! 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. +//! 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 @@ -32,4 +34,114 @@ pub enum AdminRequest { /// 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, +} + +#[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"); + } + + /// `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/server.rs b/src/admin/server.rs index 7ff74bb..8408297 100644 --- a/src/admin/server.rs +++ b/src/admin/server.rs @@ -234,6 +234,19 @@ fn dispatch(context: &AdminContext, frame: &[u8]) -> AdminResponse { 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), } } diff --git a/src/bin/arcticwolfctl.rs b/src/bin/arcticwolfctl.rs index 6632635..6706a10 100644 --- a/src/bin/arcticwolfctl.rs +++ b/src/bin/arcticwolfctl.rs @@ -1,16 +1,19 @@ //! `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` and `version`; Phase 3 adds -//! the nested `log-level get` / `log-level set ` commands. A global -//! `--socket` flag overrides the socket path and `--json` swaps the -//! human-readable summary for the raw response payload. +//! 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 clap::{Parser, Subcommand}; +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)] @@ -38,6 +41,16 @@ enum Command { #[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, + }, } /// Sub-actions of the `log-level` command. @@ -52,6 +65,103 @@ enum LogLevelAction { }, } +/// 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(); @@ -64,6 +174,15 @@ async fn main() -> ExitCode { 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, + }, } } @@ -198,6 +317,157 @@ async fn run_log_level_set(cli: &Cli, level: &str) -> ExitCode { } } +/// 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 + } + } +} + #[cfg(test)] mod tests { use std::path::Path; @@ -391,4 +661,424 @@ mod tests { 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(); + } + + /// 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 a21c00a..f3dbe74 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,7 +21,7 @@ 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, @@ -30,7 +30,7 @@ pub struct Config { pub admin: AdminConfig, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct ServerConfig { pub bind_address: String, @@ -51,7 +51,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, @@ -69,7 +69,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 }, @@ -84,7 +84,7 @@ 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, @@ -96,7 +96,7 @@ pub struct LoggingConfig { /// 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)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default, deny_unknown_fields)] pub struct AdminConfig { /// Whether the admin server is started at all. Defaults to `false`. 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 3fbf86b..3df38d9 100644 --- a/src/fsal/multi_export.rs +++ b/src/fsal/multi_export.rs @@ -18,6 +18,7 @@ use anyhow::{Context, Result, anyhow, bail}; use arc_swap::ArcSwap; use async_trait::async_trait; +use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; @@ -63,6 +64,54 @@ pub(crate) fn validate_export_name(name: &str) -> Result<()> { 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 @@ -74,6 +123,10 @@ pub(crate) fn validate_export_name(name: &str) -> Result<()> { 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. @@ -122,13 +175,49 @@ impl ExportsSnapshot { } } +/// 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. -#[derive(Debug, Clone, PartialEq, Eq)] +/// +/// 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), @@ -203,6 +292,7 @@ impl MultiExportFilesystem { let entry = ExportEntry { name: export.name.clone(), read_only: export.read_only, + fsal: export.backend.name(), fs: Arc::new(fs), }; @@ -330,25 +420,7 @@ impl MultiExportFilesystem { 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_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() - ); - } - } - } + validate_input_invariants(config)?; let _guard = self.write_lock.lock().expect("write_lock poisoned"); let current = self.state.load(); @@ -369,20 +441,12 @@ impl MultiExportFilesystem { // 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 = 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 - ) - })? - } - }; + 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), }; @@ -403,12 +467,17 @@ impl MultiExportFilesystem { Ok(()) } - /// Remove an export by name or uid. Returns the uid that was removed - /// (now in `retired_uids`). Errors if no live export matches. + /// 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 { + 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)?; @@ -431,7 +500,64 @@ impl MultiExportFilesystem { }; drop(current); self.state.store(Arc::new(new_snapshot)); - Ok(uid) + 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. @@ -442,27 +568,33 @@ impl MultiExportFilesystem { /// 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<()> { - if new_read_only.is_none() { - bail!("update_export called with no fields to mutate"); - } + ) -> 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(); - if let Some(ro) = new_read_only { - // `unwrap` safe: `resolve_selector` proved the key exists, and - // we just cloned the map so it still does. - by_uid.get_mut(&uid).unwrap().read_only = ro; - } + // `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, @@ -471,7 +603,12 @@ impl MultiExportFilesystem { }; drop(current); self.state.store(Arc::new(new_snapshot)); - Ok(()) + Ok(UpdatedExport { + uid, + name, + prev_read_only: prev_ro, + new_read_only: new_ro, + }) } } @@ -498,11 +635,19 @@ impl ExportRegistry for MultiExportFilesystem { 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() { @@ -736,11 +881,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(), }, ] ); @@ -1009,7 +1156,8 @@ mod tests { let removed = router .remove_export(&ExportSelector::Name("/data".to_string())) .expect("remove must succeed"); - assert_eq!(removed, 1); + 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:?}"); @@ -1023,7 +1171,8 @@ mod tests { let removed = router .remove_export(&ExportSelector::Uid(2)) .expect("remove must succeed"); - assert_eq!(removed, 2); + assert_eq!(removed.uid, 2); + assert_eq!(removed.name, "/backup"); assert!(router.root_handle_for("/backup").is_none()); } @@ -1077,17 +1226,25 @@ mod tests { assert!(router.is_read_only(&backup_root)); // Flip /data to ro. - router + 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. - router + 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. @@ -1220,4 +1377,54 @@ mod tests { 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))); + } } diff --git a/src/main.rs b/src/main.rs index a367a28..b7792cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -149,9 +149,13 @@ 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(); let exports = filesystem.list_exports(); for export in &exports { @@ -239,7 +243,7 @@ async fn main() -> Result<()> { start_time, server_metadata, log_reload, - filesystem.clone(), + multi_export.clone(), config.clone(), ); 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/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/tests/test_admin_exports.rs b/tests/test_admin_exports.rs new file mode 100644 index 0000000..44b1e17 --- /dev/null +++ b/tests/test_admin_exports.rs @@ -0,0 +1,316 @@ +//! 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::time::Duration; + +use arcticwolf::admin::{self, AdminContext, AdminRequest, AdminResponse}; +use arcticwolf::config::{BackendConfig, ExportConfig}; +use arcticwolf::fsal::multi_export::ExportSelector; + +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, + tokio::task::JoinHandle>, +) { + 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 server = tokio::spawn(admin::serve(listener, socket_path.clone(), context)); + (socket_path, socket_dir, export_dir, log_guard, server) +} + +#[tokio::test] +async fn exports_list_returns_seeded_exports_and_no_retired() { + let (socket_path, _sd, _xd, _g, server) = 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()); + server.abort(); +} + +#[tokio::test] +async fn exports_add_happy_path_appears_in_list() { + let (socket_path, _sd, _xd, _g, server) = 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}"); + server.abort(); +} + +#[tokio::test] +async fn exports_add_rejects_duplicate_uid_name_and_retired() { + let (socket_path, _sd, _xd, _g, server) = 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"), + } + server.abort(); +} + +#[tokio::test] +async fn exports_remove_by_name_disappears_and_uid_is_retired() { + let (socket_path, _sd, _xd, _g, server) = 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]); + server.abort(); +} + +#[tokio::test] +async fn exports_remove_by_uid_succeeds() { + let (socket_path, _sd, _xd, _g, server) = 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"); + server.abort(); +} + +#[tokio::test] +async fn exports_update_flips_read_only() { + let (socket_path, _sd, _xd, _g, server) = 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); + server.abort(); +} + +#[tokio::test] +async fn exports_update_missing_export_errors() { + let (socket_path, _sd, _xd, _g, server) = 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"), + } + server.abort(); +} + +#[tokio::test] +async fn dry_run_does_not_mutate_state_across_add_remove_update() { + let (socket_path, _sd, _xd, _g, server) = 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"); + server.abort(); +} + +#[tokio::test] +async fn config_show_returns_startup_config() { + let (socket_path, _sd, _xd, _g, server) = 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", + ); + server.abort(); +} From 0b43f34d9be66ef6d4b8322e1410d21eec343cc8 Mon Sep 17 00:00:00 2001 From: amarok-bot Date: Tue, 2 Jun 2026 10:04:15 +0800 Subject: [PATCH 7/9] admin: structured audit log with SO_PEERCRED capture Implements Phase 6 of #25. Every admin request emits one JSONL audit event including peer credentials (uid/gid/pid via SO_PEERCRED), the wire command tag, the request payload, the result (ok/err with error message), and duration in ms. Configurable via [audit] enabled/path in the daemon config; disabled by default. NoopAuditWriter is used when disabled. Sensitive-field redaction in the request payload is deferred to the S3 backend follow-up. Signed-off-by: amarok-bot --- src/admin/audit.rs | 562 ++++++++++++++++++++++++++++++++ src/admin/commands/log_level.rs | 1 + src/admin/context.rs | 38 ++- src/admin/mod.rs | 2 + src/admin/server.rs | 173 +++++++++- src/config.rs | 132 ++++++++ src/main.rs | 20 ++ tests/test_admin_audit.rs | 300 +++++++++++++++++ 8 files changed, 1218 insertions(+), 10 deletions(-) create mode 100644 src/admin/audit.rs create mode 100644 tests/test_admin_audit.rs diff --git a/src/admin/audit.rs b/src/admin/audit.rs new file mode 100644 index 0000000..51fc47a --- /dev/null +++ b/src/admin/audit.rs @@ -0,0 +1,562 @@ +//! 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. +//! +//! 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::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use serde::Serialize; +use tokio::fs::File; +use tokio::io::{AsyncWriteExt, BufWriter}; +use tokio::net::UnixStream; +use tokio::sync::mpsc; +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. +pub trait AuditWriter: Send + Sync { + /// Hand `event` to the writer for asynchronous recording. + fn record(&self, event: AuditEvent); +} + +/// 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; + +impl AuditWriter for NoopAuditWriter { + fn record(&self, _event: AuditEvent) {} +} + +/// 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. +#[derive(Debug)] +pub struct FileAuditWriter { + tx: mpsc::UnboundedSender, +} + +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::(); + tokio::spawn(writer_task(file, rx)); + Ok(Self { tx }) + } +} + +#[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 } + } +} + +impl AuditWriter for FileAuditWriter { + fn record(&self, event: AuditEvent) { + // The receiver lives in the spawned writer task. If that task has + // exited (panic, runtime shutdown, channel close on drop), there + // is nothing to do with the event — losing an audit record is + // strictly preferable to stalling or failing the admin request. + if let Err(err) = self.tx.send(event) { + warn!("audit: dropping event, writer channel closed: {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_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/commands/log_level.rs b/src/admin/commands/log_level.rs index a3ed3d9..084a714 100644 --- a/src/admin/commands/log_level.rs +++ b/src/admin/commands/log_level.rs @@ -376,6 +376,7 @@ mod tests { handle, filesystem, Arc::new(config), + Arc::new(crate::admin::NoopAuditWriter), ); // Filter starts at `warn`: debug is suppressed, warn passes. diff --git a/src/admin/context.rs b/src/admin/context.rs index d822791..b90fea8 100644 --- a/src/admin/context.rs +++ b/src/admin/context.rs @@ -16,6 +16,9 @@ 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; @@ -63,6 +66,12 @@ pub struct AdminContext { 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, } impl AdminContext { @@ -76,6 +85,7 @@ impl AdminContext { log_reload: LogReloadHandle, filesystem: Arc, config: Arc, + audit: Arc, ) -> Arc { Arc::new(Self { start_time, @@ -83,6 +93,7 @@ impl AdminContext { log_reload, filesystem, config, + audit, }) } } @@ -117,11 +128,12 @@ 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. 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`). + /// 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 @@ -131,7 +143,22 @@ impl AdminContext { /// 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"); @@ -195,6 +222,7 @@ impl AdminContext { log_reload, filesystem, Arc::new(config), + audit, ); (context, tmp, guard) } diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 33ab86e..526d2d4 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -7,6 +7,7 @@ //! `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; @@ -15,6 +16,7 @@ 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; diff --git a/src/admin/server.rs b/src/admin/server.rs index 8408297..3e741ec 100644 --- a/src/admin/server.rs +++ b/src/admin/server.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use bytes::Bytes; @@ -16,6 +16,7 @@ use futures_util::{SinkExt, StreamExt}; use tokio::net::{UnixListener, UnixStream}; 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}; @@ -156,24 +157,48 @@ pub async fn serve( .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(); tokio::spawn(async move { - if let Err(err) = handle_connection(socket, context).await { + if let Err(err) = handle_connection(socket, context, peer).await { warn!("admin connection error: {err:#}"); } }); } } -async fn handle_connection(socket: UnixStream, context: Arc) -> Result<()> { +async fn handle_connection( + socket: UnixStream, + context: Arc, + peer: Option, +) -> Result<()> { let mut framed = framed(socket); while let Some(frame) = framed.next().await { let bytes = match frame { Ok(bytes) => bytes, Err(err) => { - // Codec-level errors (oversize frame, eof mid-frame). Try - // to send a structured error back, then close. The write + // 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, + }); + + // 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 @@ -200,12 +225,74 @@ async fn handle_connection(socket: UnixStream, context: Arc) -> Re } }; + // 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); 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, @@ -487,4 +574,80 @@ mod tests { server.abort(); } + + /// 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 server = + tokio::spawn(async move { serve(listener, socket_path_for_serve, context).await }); + + 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}", + ); + + server.abort(); + } + + /// 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/config.rs b/src/config.rs index f3dbe74..5c0c9c0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,7 @@ pub struct Config { pub exports: Vec, pub logging: LoggingConfig, pub admin: AdminConfig, + pub audit: AuditConfig, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -123,6 +124,27 @@ impl Default for AdminConfig { } } +/// 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, +} + impl Default for ServerConfig { fn default() -> Self { Self { @@ -147,6 +169,7 @@ impl Default for Config { }], logging: LoggingConfig::default(), admin: AdminConfig::default(), + audit: AuditConfig::default(), } } } @@ -228,6 +251,25 @@ impl Config { ); } + // 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(); @@ -462,6 +504,7 @@ mod tests { exports: vec![], logging: LoggingConfig::default(), admin: AdminConfig::default(), + audit: AuditConfig::default(), }; let err = config.validate().expect_err("empty exports must fail"); let msg = err.to_string(); @@ -478,6 +521,7 @@ mod tests { ], logging: LoggingConfig::default(), admin: AdminConfig::default(), + audit: AuditConfig::default(), }; let err = config.validate().expect_err("duplicate uid must fail"); let msg = err.to_string(); @@ -502,6 +546,7 @@ mod tests { ], logging: LoggingConfig::default(), admin: AdminConfig::default(), + audit: AuditConfig::default(), }; let err = config.validate().expect_err("duplicate name must fail"); let msg = err.to_string(); @@ -518,6 +563,7 @@ mod tests { exports: vec![local_export("/data", 0, "/srv/a")], logging: LoggingConfig::default(), admin: AdminConfig::default(), + audit: AuditConfig::default(), }; let err = config.validate().expect_err("uid 0 must fail"); let msg = err.to_string(); @@ -531,6 +577,7 @@ mod tests { exports: vec![local_export("data", 1, "/srv/a")], logging: LoggingConfig::default(), admin: AdminConfig::default(), + audit: AuditConfig::default(), }; let err = config.validate().expect_err("relative name must fail"); let msg = err.to_string(); @@ -566,6 +613,7 @@ mod tests { ], logging: LoggingConfig::default(), admin: AdminConfig::default(), + audit: AuditConfig::default(), }; config.validate().expect("distinct exports must validate"); } @@ -804,4 +852,88 @@ path = "/srv/data" "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_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/main.rs b/src/main.rs index b7792cd..cbb8028 100644 --- a/src/main.rs +++ b/src/main.rs @@ -236,6 +236,25 @@ async fn main() -> Result<()> { 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) + }; + // 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. @@ -245,6 +264,7 @@ async fn main() -> Result<()> { log_reload, multi_export.clone(), config.clone(), + audit_writer, ); let admin_future = build_admin_future(&config.admin, admin_context)?; diff --git a/tests/test_admin_audit.rs b/tests/test_admin_audit.rs new file mode 100644 index 0000000..f46970c --- /dev/null +++ b/tests/test_admin_audit.rs @@ -0,0 +1,300 @@ +//! 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 bytes::Bytes; +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value; +use tokio::net::UnixStream; +use tokio_util::codec::{Framed, LengthDelimitedCodec}; + +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 +/// - `server` — the spawned serve() task; aborted by caller +async fn spawn_with_audit() -> ( + PathBuf, + PathBuf, + tempfile::TempDir, + tempfile::TempDir, + tempfile::TempDir, + admin::context::TestLogReloadGuard, + tokio::task::JoinHandle>, +) { + 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"); + let server = tokio::spawn(admin::serve(listener, socket_path.clone(), context)); + ( + socket_path, + audit_path, + socket_dir, + audit_dir, + export_dir, + log_guard, + server, + ) +} + +/// 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, server) = 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()), + ); + } + server.abort(); +} + +#[tokio::test] +async fn audit_records_ok_and_err_outcomes_with_correct_fields() { + let (socket_path, audit_path, _sd, _ad, _xd, _g, server) = 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); + + server.abort(); +} + +#[tokio::test] +async fn audit_records_malformed_frame_with_undecodable_marker() { + let (socket_path, audit_path, _sd, _ad, _xd, _g, server) = 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}", + ); + + server.abort(); +} + +#[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, server) = 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}", + ); + } + + server.abort(); +} From 667ea984304b27cc7f8fd0bd536ab80367949e15 Mon Sep 17 00:00:00 2001 From: amarok-bot Date: Sun, 14 Jun 2026 10:08:47 +0800 Subject: [PATCH 8/9] admin: metrics command with per-export and per-NFS-op counters Implements Phase 7 of #25. New AdminRequest::Metrics variant returns a JSON snapshot of server-wide, per-NFS-op, per-export, and per-admin- command counters. Counters are AtomicU64-backed, monotonically increasing within a daemon lifetime, lock-free reads, no external metrics crate. Per-export counters live on ExportEntry and survive update_export across snapshot rebuilds; they are dropped on remove_export. No HTTP endpoint (deferred per spec). arcticwolfctl gains a metrics subcommand printing the JSON pretty-formatted. Signed-off-by: amarok-bot --- src/admin/audit.rs | 10 + src/admin/client.rs | 14 + src/admin/commands/log_level.rs | 1 + src/admin/commands/metrics.rs | 75 ++++ src/admin/commands/mod.rs | 1 + src/admin/context.rs | 10 + src/admin/request.rs | 132 +++++++ src/admin/server.rs | 9 + src/bin/arcticwolfctl.rs | 61 +++ src/fsal/multi_export.rs | 293 ++++++++++++--- src/lib.rs | 1 + src/main.rs | 11 + src/metrics.rs | 640 ++++++++++++++++++++++++++++++++ src/nfs/dispatcher.rs | 108 ++++++ src/rpc/server.rs | 165 ++++++-- tests/test_admin_metrics.rs | 109 ++++++ 16 files changed, 1559 insertions(+), 81 deletions(-) create mode 100644 src/admin/commands/metrics.rs create mode 100644 src/metrics.rs create mode 100644 tests/test_admin_metrics.rs diff --git a/src/admin/audit.rs b/src/admin/audit.rs index 51fc47a..03817c3 100644 --- a/src/admin/audit.rs +++ b/src/admin/audit.rs @@ -34,6 +34,16 @@ //! 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 diff --git a/src/admin/client.rs b/src/admin/client.rs index 5b8e616..9b7f0c4 100644 --- a/src/admin/client.rs +++ b/src/admin/client.rs @@ -129,6 +129,11 @@ 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 { @@ -340,6 +345,15 @@ 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. diff --git a/src/admin/commands/log_level.rs b/src/admin/commands/log_level.rs index 084a714..51a3dec 100644 --- a/src/admin/commands/log_level.rs +++ b/src/admin/commands/log_level.rs @@ -377,6 +377,7 @@ mod tests { filesystem, Arc::new(config), Arc::new(crate::admin::NoopAuditWriter), + Arc::new(crate::metrics::Metrics::new()), ); // Filter starts at `warn`: debug is suppressed, warn passes. 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 index a26284f..d8bf94f 100644 --- a/src/admin/commands/mod.rs +++ b/src/admin/commands/mod.rs @@ -12,5 +12,6 @@ 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/context.rs b/src/admin/context.rs index b90fea8..9d7bdcd 100644 --- a/src/admin/context.rs +++ b/src/admin/context.rs @@ -21,6 +21,7 @@ use crate::admin::audit::AuditWriter; 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`. /// @@ -72,6 +73,11 @@ pub struct AdminContext { /// 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 { @@ -79,6 +85,7 @@ impl AdminContext { /// 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, @@ -86,6 +93,7 @@ impl AdminContext { filesystem: Arc, config: Arc, audit: Arc, + metrics: Arc, ) -> Arc { Arc::new(Self { start_time, @@ -94,6 +102,7 @@ impl AdminContext { filesystem, config, audit, + metrics, }) } } @@ -223,6 +232,7 @@ impl AdminContext { filesystem, Arc::new(config), audit, + Arc::new(Metrics::new()), ); (context, tmp, guard) } diff --git a/src/admin/request.rs b/src/admin/request.rs index 44b91dc..91df531 100644 --- a/src/admin/request.rs +++ b/src/admin/request.rs @@ -66,8 +66,54 @@ pub enum AdminRequest { /// 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::*; @@ -128,6 +174,92 @@ mod tests { "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 diff --git a/src/admin/server.rs b/src/admin/server.rs index 3e741ec..c2fb282 100644 --- a/src/admin/server.rs +++ b/src/admin/server.rs @@ -198,6 +198,11 @@ async fn handle_connection( 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 @@ -230,6 +235,9 @@ async fn handle_connection( // 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( @@ -334,6 +342,7 @@ fn dispatch(context: &AdminContext, frame: &[u8]) -> AdminResponse { 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), } } diff --git a/src/bin/arcticwolfctl.rs b/src/bin/arcticwolfctl.rs index 6706a10..a421b61 100644 --- a/src/bin/arcticwolfctl.rs +++ b/src/bin/arcticwolfctl.rs @@ -51,6 +51,8 @@ enum Command { #[command(subcommand)] action: ConfigAction, }, + /// Show a JSON snapshot of runtime metrics (counters reset on restart). + Metrics, } /// Sub-actions of the `log-level` command. @@ -183,6 +185,7 @@ async fn main() -> ExitCode { Command::Config { action } => match action { ConfigAction::Show => run_config_show(&cli).await, }, + Command::Metrics => run_metrics(&cli).await, } } @@ -468,6 +471,28 @@ async fn run_config_show(cli: &Cli) -> ExitCode { } } +/// `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; @@ -994,6 +1019,42 @@ mod tests { 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`. diff --git a/src/fsal/multi_export.rs b/src/fsal/multi_export.rs index 3df38d9..c6d0662 100644 --- a/src/fsal/multi_export.rs +++ b/src/fsal/multi_export.rs @@ -23,6 +23,7 @@ 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; @@ -117,8 +118,12 @@ fn build_backend(config: &ExportConfig) -> Result { /// `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` (cheap refcount bump), it does not duplicate the -/// backend's `HandleManager` state. +/// `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, @@ -131,6 +136,10 @@ struct ExportEntry { /// `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. @@ -294,6 +303,7 @@ impl MultiExportFilesystem { read_only: export.read_only, fsal: export.backend.name(), fs: Arc::new(fs), + metrics: Arc::new(ExportMetrics::default()), }; if by_uid.insert(export.uid, entry).is_some() { @@ -322,16 +332,29 @@ impl MultiExportFilesystem { }) } - /// Resolve `handle` against the live snapshot and return a clone of the - /// owning backend's `Arc`. + /// Resolve `handle` against the live snapshot and return clones of the + /// owning backend's `Arc` and its `Arc`. /// /// 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. - fn inner_for_handle(&self, handle: &FileHandle) -> Result> { + /// + /// 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(); - Ok(snapshot.entry_for_handle(handle)?.fs.clone()) + 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 @@ -350,7 +373,7 @@ impl MultiExportFilesystem { op: &str, a_label: &str, b_label: &str, - ) -> Result> { + ) -> Result<(Arc, Arc)> { let a_uid = a.as_slice().export_uid().ok_or_else(|| { anyhow!( "Invalid handle: {} too short to carry an export uid", @@ -380,7 +403,11 @@ impl MultiExportFilesystem { a_uid ) })?; - Ok(entry.fs.clone()) + 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. @@ -448,6 +475,10 @@ impl MultiExportFilesystem { 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(); @@ -610,6 +641,35 @@ impl MultiExportFilesystem { 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 { @@ -663,19 +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 { - let inner = self.inner_for_handle(dir_handle)?; - inner.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 { - let inner = self.inner_for_handle(handle)?; - inner.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> { - let inner = self.inner_for_handle(handle)?; - inner.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( @@ -684,23 +764,39 @@ impl Filesystem for MultiExportFilesystem { cookie: u64, count: u32, ) -> Result<(Vec, bool)> { - let inner = self.inner_for_handle(dir_handle)?; - inner.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 { - let inner = self.inner_for_handle(handle)?; - inner.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<()> { - let inner = self.inner_for_handle(handle)?; - inner.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<()> { - let inner = self.inner_for_handle(handle)?; - inner.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( @@ -709,28 +805,38 @@ impl Filesystem for MultiExportFilesystem { uid: Option, gid: Option, ) -> Result<()> { - let inner = self.inner_for_handle(handle)?; - inner.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 { - let inner = self.inner_for_handle(dir_handle)?; - inner.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<()> { - let inner = self.inner_for_handle(dir_handle)?; - inner.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 { - let inner = self.inner_for_handle(dir_handle)?; - inner.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<()> { - let inner = self.inner_for_handle(dir_handle)?; - inner.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( @@ -740,16 +846,18 @@ impl Filesystem for MultiExportFilesystem { to_dir_handle: &FileHandle, to_name: &str, ) -> Result<()> { - let inner = self.inner_for_same_export_pair( + let (inner, metrics) = self.inner_for_same_export_pair( from_dir_handle, to_dir_handle, "rename", "source", "target", )?; - inner + let r = inner .rename(from_dir_handle, from_name, to_dir_handle, to_name) - .await + .await; + metrics.record_result(&r); + r } async fn symlink( @@ -758,13 +866,17 @@ impl Filesystem for MultiExportFilesystem { name: &str, target: &str, ) -> Result { - let inner = self.inner_for_handle(dir_handle)?; - inner.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 { - let inner = self.inner_for_handle(handle)?; - inner.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( @@ -773,14 +885,18 @@ impl Filesystem for MultiExportFilesystem { dir_handle: &FileHandle, name: &str, ) -> Result { - let inner = + let (inner, metrics) = self.inner_for_same_export_pair(file_handle, dir_handle, "link", "file", "dir")?; - inner.link(file_handle, dir_handle, name).await + 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<()> { - let inner = self.inner_for_handle(handle)?; - inner.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( @@ -791,13 +907,17 @@ impl Filesystem for MultiExportFilesystem { mode: u32, rdev: (u32, u32), ) -> Result { - let inner = self.inner_for_handle(dir_handle)?; - inner.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 { - let inner = self.inner_for_handle(handle)?; - inner.fs_stats(handle).await + let (inner, metrics) = self.inner_for_handle(handle)?; + let r = inner.fs_stats(handle).await; + metrics.record_result(&r); + r } } @@ -1427,4 +1547,83 @@ mod tests { 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 5b9fc9d..4578649 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ compile_error!("Arctic Wolf NFS server only supports Linux"); pub mod admin; pub mod config; pub mod fsal; +pub mod metrics; pub mod mount; pub mod nfs; pub mod portmap; diff --git a/src/main.rs b/src/main.rs index cbb8028..7caebf0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, reload, util::Subscrib use arcticwolf::admin; use arcticwolf::config::{self, Config}; use arcticwolf::fsal; +use arcticwolf::metrics::Metrics; use arcticwolf::portmap; use arcticwolf::protocol::v3::portmap::mapping; use arcticwolf::rpc; @@ -157,6 +158,12 @@ async fn main() -> Result<()> { )?); 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 { // Backend type/path live in `config.exports` (the FSAL view via @@ -190,6 +197,7 @@ async fn main() -> Result<()> { registry.clone(), filesystem.clone(), vec![100000], // PORTMAP only + metrics.clone(), ) .await?; @@ -198,6 +206,7 @@ async fn main() -> Result<()> { registry.clone(), filesystem.clone(), vec![100005], // MOUNT only + metrics.clone(), ) .await?; @@ -209,6 +218,7 @@ async fn main() -> Result<()> { registry.clone(), filesystem.clone(), vec![100003], // NFS only + metrics.clone(), ) .await?; @@ -265,6 +275,7 @@ async fn main() -> Result<()> { multi_export.clone(), config.clone(), audit_writer, + metrics.clone(), ); let admin_future = build_admin_future(&config.admin, admin_context)?; 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/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/rpc/server.rs b/src/rpc/server.rs index 7385724..a0ad68f 100644 --- a/src/rpc/server.rs +++ b/src/rpc/server.rs @@ -10,8 +10,9 @@ use tokio::net::{TcpListener, TcpStream}; 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}; /// RPC server handling TCP connections with record marking /// @@ -22,6 +23,7 @@ pub struct RpcServer { registry: Registry, filesystem: Arc, allowed_programs: Vec, + metrics: Arc, } impl RpcServer { @@ -34,6 +36,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 +45,7 @@ impl RpcServer { registry, filesystem, allowed_programs, + metrics, }) } @@ -67,9 +71,11 @@ impl RpcServer { let registry = self.registry.clone(); let filesystem = self.filesystem.clone(); let allowed_programs = self.allowed_programs.clone(); + let metrics = self.metrics.clone(); tokio::spawn(async move { if let Err(e) = - handle_connection(socket, registry, filesystem, &allowed_programs).await + handle_connection(socket, registry, filesystem, &allowed_programs, metrics) + .await { error!("Connection error from {}: {}", peer_addr, e); } @@ -84,6 +90,7 @@ async fn handle_connection( registry: Registry, filesystem: Arc, allowed_programs: &[u32], + metrics: Arc, ) -> Result<()> { let mut buffer = BytesMut::with_capacity(8192); @@ -113,36 +120,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 +189,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 +200,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 +309,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 +328,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/tests/test_admin_metrics.rs b/tests/test_admin_metrics.rs new file mode 100644 index 0000000..87f0f3f --- /dev/null +++ b/tests/test_admin_metrics.rs @@ -0,0 +1,109 @@ +//! 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}; + +/// 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, + tokio::task::JoinHandle>, +) { + 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"); + let server = tokio::spawn(admin::serve(listener, socket_path.clone(), context)); + (socket_path, fs, socket_dir, export_dir, log_guard, server) +} + +#[tokio::test] +async fn metrics_counts_admin_commands_and_per_export_io() { + let (socket_path, fs, _socket_dir, _export_dir, _log_guard, server) = 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); + + server.abort(); +} From 3ba743c7178dd7240e17902460407cd6423ed1bb Mon Sep 17 00:00:00 2001 From: amarok-bot Date: Sun, 14 Jun 2026 15:00:58 +0800 Subject: [PATCH 9/9] admin: graceful shutdown on SIGTERM/SIGINT Implements Phase 8 of #25 (final phase). Daemon catches SIGTERM and SIGINT, signals all four server loops (portmap, mount, NFS, admin) via a CancellationToken, drains in-flight requests bounded by [shutdown] drain_timeout_seconds (default 30), flushes the audit writer, and removes the admin socket file before exit. A second signal during drain escalates to immediate process exit. Shutdown completes with exit status 0; drain timeout logs a warn but is not a failure. Signed-off-by: amarok-bot --- Cargo.toml | 5 + arcticwolf.example.toml | 18 +++ src/admin/audit.rs | 148 ++++++++++++++++-- src/admin/mod.rs | 2 +- src/admin/server.rs | 198 ++++++++++++++++++++---- src/config.rs | 90 +++++++++++ src/lib.rs | 1 + src/main.rs | 215 ++++++++++++++++++++++---- src/rpc/server.rs | 94 ++++++++++-- src/shutdown.rs | 232 ++++++++++++++++++++++++++++ tests/test_admin_audit.rs | 35 +++-- tests/test_admin_exports.rs | 54 ++++--- tests/test_admin_log_level.rs | 25 ++- tests/test_admin_metrics.rs | 21 ++- tests/test_admin_status_version.rs | 14 +- tests/test_graceful_shutdown.rs | 234 +++++++++++++++++++++++++++++ 16 files changed, 1264 insertions(+), 122 deletions(-) create mode 100644 src/shutdown.rs create mode 100644 tests/test_graceful_shutdown.rs diff --git a/Cargo.toml b/Cargo.toml index 74c7048..d06cc39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,11 @@ tempfile = "3" # 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] # xdrgen itself is installed as a CLI tool (see build.rs), not a crate. 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/src/admin/audit.rs b/src/admin/audit.rs index 03817c3..bac70cd 100644 --- a/src/admin/audit.rs +++ b/src/admin/audit.rs @@ -61,14 +61,17 @@ 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. @@ -157,9 +160,22 @@ pub struct AuditEvent { /// 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 @@ -167,8 +183,12 @@ pub trait AuditWriter: Send + Sync { /// `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. @@ -178,9 +198,23 @@ impl AuditWriter for NoopAuditWriter { /// 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 { - tx: mpsc::UnboundedSender, + /// 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 { @@ -214,8 +248,11 @@ impl FileAuditWriter { let file = File::from_std(std_file); let (tx, rx) = mpsc::unbounded_channel::(); - tokio::spawn(writer_task(file, rx)); - Ok(Self { tx }) + let task = tokio::spawn(writer_task(file, rx)); + Ok(Self { + tx: Mutex::new(Some(tx)), + task: Mutex::new(Some(task)), + }) } } @@ -229,20 +266,58 @@ impl FileAuditWriter { fn with_dropped_receiver() -> Self { let (tx, rx) = mpsc::unbounded_channel::(); drop(rx); - Self { tx } + Self { + tx: Mutex::new(Some(tx)), + task: Mutex::new(None), + } } } +#[async_trait] impl AuditWriter for FileAuditWriter { fn record(&self, event: AuditEvent) { - // The receiver lives in the spawned writer task. If that task has - // exited (panic, runtime shutdown, channel close on drop), there - // is nothing to do with the event — losing an audit record is - // strictly preferable to stalling or failing the admin request. - if let Err(err) = self.tx.send(event) { + 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 @@ -375,6 +450,61 @@ mod tests { ); } + #[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"); diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 526d2d4..66f69b0 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -20,7 +20,7 @@ pub use audit::{AuditEvent, AuditWriter, FileAuditWriter, NoopAuditWriter, PeerC pub use context::{AdminContext, ServerMetadata}; pub use request::AdminRequest; pub use response::AdminResponse; -pub use server::serve; +pub use server::serve_with_shutdown; /// Default filesystem path for the admin Unix domain socket. /// diff --git a/src/admin/server.rs b/src/admin/server.rs index c2fb282..4bc8a2c 100644 --- a/src/admin/server.rs +++ b/src/admin/server.rs @@ -14,6 +14,8 @@ 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}; @@ -22,6 +24,7 @@ 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 @@ -139,36 +142,101 @@ pub fn bind_admin_socket(socket_path: &Path, socket_mode: u32) -> Result { + 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). /// -/// `socket_path` is held only so the file can be unlinked on a clean -/// shutdown — graceful shutdown lands in Phase 8, so today we just log -/// the socket path on accept loop termination. -pub async fn serve( +/// `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 { - let (socket, _) = listener - .accept() - .await - .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(); - tokio::spawn(async move { - if let Err(err) = handle_connection(socket, context, peer).await { - warn!("admin connection error: {err:#}"); + 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( @@ -178,6 +246,10 @@ async fn handle_connection( ) -> 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, @@ -551,8 +623,14 @@ mod tests { 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 server = - tokio::spawn(async move { serve(listener, socket_path_for_serve, context).await }); + 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 @@ -581,7 +659,60 @@ mod tests { } } - server.abort(); + // 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 @@ -604,8 +735,14 @@ mod tests { 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 server = - tokio::spawn(async move { serve(listener, socket_path_for_serve, context).await }); + 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 @@ -630,7 +767,14 @@ mod tests { "frame-error event request must be null; got: {value}", ); - server.abort(); + // 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. diff --git a/src/config.rs b/src/config.rs index 5c0c9c0..4816cf3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,6 +29,7 @@ pub struct Config { pub logging: LoggingConfig, pub admin: AdminConfig, pub audit: AuditConfig, + pub shutdown: ShutdownConfig, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -145,6 +146,32 @@ pub struct AuditConfig { 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 { @@ -170,6 +197,7 @@ impl Default for Config { logging: LoggingConfig::default(), admin: AdminConfig::default(), audit: AuditConfig::default(), + shutdown: ShutdownConfig::default(), } } } @@ -505,6 +533,7 @@ mod tests { 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(); @@ -522,6 +551,7 @@ mod tests { 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(); @@ -547,6 +577,7 @@ mod tests { 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(); @@ -564,6 +595,7 @@ mod tests { 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(); @@ -578,6 +610,7 @@ mod tests { 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(); @@ -614,6 +647,7 @@ mod tests { logging: LoggingConfig::default(), admin: AdminConfig::default(), audit: AuditConfig::default(), + shutdown: ShutdownConfig::default(), }; config.validate().expect("distinct exports must validate"); } @@ -916,6 +950,62 @@ path = "/srv/data" 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#" diff --git a/src/lib.rs b/src/lib.rs index 4578649..2619a39 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ 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 7caebf0..a4854ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,11 @@ #[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}; use arcticwolf::admin; @@ -12,6 +15,7 @@ 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; @@ -64,17 +68,27 @@ fn register_services( /// Build the admin server future based on `[admin]` config. /// -/// When `enabled = false`, returns a `pending` future — the caller drops -/// it into `tokio::select!` alongside the RPC servers, and that branch -/// never fires. 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. +/// 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, -) -> Result>>>> { + shutdown: CancellationToken, + in_flight: Arc, +) -> Result> + Send>>> { if !admin.enabled { - return Ok(Box::pin(std::future::pending::>())); + return Ok(Box::pin(async move { + shutdown.cancelled().await; + Ok(()) + })); } println!("Admin server:"); @@ -84,14 +98,20 @@ fn build_admin_future( 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(listener, socket_path, context))) + Ok(Box::pin(admin::serve_with_shutdown( + listener, + socket_path, + context, + shutdown, + in_flight, + ))) } #[tokio::main] async fn main() -> Result<()> { // Capture the process start instant up front so the admin `status` // command can report an accurate uptime. - let start_time = std::time::Instant::now(); + 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. @@ -125,6 +145,78 @@ async fn main() -> Result<()> { .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!("======================"); println!("Configuration:"); @@ -264,12 +356,17 @@ async fn main() -> Result<()> { } 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( - start_time, + process_start_instant, server_metadata, log_reload, multi_export.clone(), @@ -278,24 +375,90 @@ async fn main() -> Result<()> { metrics.clone(), ); - let admin_future = build_admin_future(&config.admin, admin_context)?; + // 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) + }; - // Run all servers concurrently. The admin branch is a `pending` future - // when admin is disabled, so its presence in `select!` is free. - tokio::select! { - result = portmap_server.serve() => { - result?; - } - result = mount_server.serve() => { - result?; - } - result = nfs_server.serve() => { - result?; - } - result = admin_future => { - result?; + // 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/rpc/server.rs b/src/rpc/server.rs index a0ad68f..890727f 100644 --- a/src/rpc/server.rs +++ b/src/rpc/server.rs @@ -7,12 +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, rpc_call_msg}; +use crate::shutdown::{InFlight, InFlightGuard}; /// RPC server handling TCP connections with record marking /// @@ -56,31 +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(); - let metrics = self.metrics.clone(); - tokio::spawn(async move { - if let Err(e) = - handle_connection(socket, registry, filesystem, &allowed_programs, metrics) + 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); + { + 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(()) } } @@ -95,6 +155,10 @@ async fn handle_connection( 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() { 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 index f46970c..1e69625 100644 --- a/tests/test_admin_audit.rs +++ b/tests/test_admin_audit.rs @@ -12,11 +12,13 @@ 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 { @@ -47,7 +49,7 @@ fn client_codec() -> LengthDelimitedCodec { /// - `_audit_dir` — owns the audit-log tempdir /// - `_export_dir` — owns the for_test() export tempdir /// - `_log_guard` — serializes shared tracing reload handle -/// - `server` — the spawned serve() task; aborted by caller +/// - `token` — cancels the detached serve() task on teardown async fn spawn_with_audit() -> ( PathBuf, PathBuf, @@ -55,7 +57,7 @@ async fn spawn_with_audit() -> ( tempfile::TempDir, tempfile::TempDir, admin::context::TestLogReloadGuard, - tokio::task::JoinHandle>, + CancellationToken, ) { let socket_dir = tempfile::tempdir().expect("socket tempdir"); let socket_path = socket_dir.path().join("admin.sock"); @@ -67,7 +69,16 @@ async fn spawn_with_audit() -> ( 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"); - let server = tokio::spawn(admin::serve(listener, socket_path.clone(), context)); + // 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, @@ -75,7 +86,7 @@ async fn spawn_with_audit() -> ( audit_dir, export_dir, log_guard, - server, + token, ) } @@ -128,7 +139,7 @@ async fn send_raw_frame(socket_path: &std::path::Path, body: &[u8]) -> AdminResp #[tokio::test] async fn audit_records_status_request_with_peer_uid() { - let (socket_path, audit_path, _sd, _ad, _xd, _g, server) = spawn_with_audit().await; + let (socket_path, audit_path, _sd, _ad, _xd, _g, token) = spawn_with_audit().await; admin::client::fetch_status(&socket_path) .await @@ -165,12 +176,12 @@ async fn audit_records_status_request_with_peer_uid() { u64::from(std::process::id()), ); } - server.abort(); + token.cancel(); } #[tokio::test] async fn audit_records_ok_and_err_outcomes_with_correct_fields() { - let (socket_path, audit_path, _sd, _ad, _xd, _g, server) = spawn_with_audit().await; + 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"); @@ -232,12 +243,12 @@ async fn audit_records_ok_and_err_outcomes_with_correct_fields() { assert!(lines[2].get("error").is_none()); assert_eq!(lines[2]["request"]["selector"]["uid"], 7); - server.abort(); + token.cancel(); } #[tokio::test] async fn audit_records_malformed_frame_with_undecodable_marker() { - let (socket_path, audit_path, _sd, _ad, _xd, _g, server) = spawn_with_audit().await; + 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 @@ -262,7 +273,7 @@ async fn audit_records_malformed_frame_with_undecodable_marker() { "undecodable frame must carry the decode error: {event}", ); - server.abort(); + token.cancel(); } #[tokio::test] @@ -270,7 +281,7 @@ 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, server) = spawn_with_audit().await; + 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(); @@ -296,5 +307,5 @@ async fn audit_records_every_request_in_order_including_repeats() { ); } - server.abort(); + token.cancel(); } diff --git a/tests/test_admin_exports.rs b/tests/test_admin_exports.rs index 44b1e17..642014e 100644 --- a/tests/test_admin_exports.rs +++ b/tests/test_admin_exports.rs @@ -5,11 +5,14 @@ //! 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 { @@ -25,7 +28,7 @@ async fn spawn() -> ( tempfile::TempDir, tempfile::TempDir, admin::context::TestLogReloadGuard, - tokio::task::JoinHandle>, + CancellationToken, ) { let socket_dir = tempfile::tempdir().expect("socket tempdir"); let socket_path = socket_dir.path().join("admin.sock"); @@ -33,13 +36,22 @@ async fn spawn() -> ( let (context, export_dir, log_guard) = AdminContext::for_test(); let listener = admin::server::bind_admin_socket(&socket_path, 0o600).expect("bind admin socket"); - let server = tokio::spawn(admin::serve(listener, socket_path.clone(), context)); - (socket_path, socket_dir, export_dir, log_guard, server) + // 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, server) = spawn().await; + let (socket_path, _sd, _xd, _g, token) = spawn().await; let data = tokio::time::timeout( Duration::from_secs(5), @@ -57,12 +69,12 @@ async fn exports_list_returns_seeded_exports_and_no_retired() { assert_eq!(exports[0]["read_only"], false); let retired = data["retired_uids"].as_array().expect("retired_uids array"); assert!(retired.is_empty()); - server.abort(); + token.cancel(); } #[tokio::test] async fn exports_add_happy_path_appears_in_list() { - let (socket_path, _sd, _xd, _g, server) = spawn().await; + let (socket_path, _sd, _xd, _g, token) = spawn().await; let new_dir = tempfile::tempdir().expect("new export dir"); let add = admin::client::exports_add( @@ -86,12 +98,12 @@ async fn exports_add_happy_path_appears_in_list() { .collect(); assert!(names.contains(&"/data"), "list missing /data: {list}"); assert!(names.contains(&"/extra"), "list missing /extra: {list}"); - server.abort(); + token.cancel(); } #[tokio::test] async fn exports_add_rejects_duplicate_uid_name_and_retired() { - let (socket_path, _sd, _xd, _g, server) = spawn().await; + let (socket_path, _sd, _xd, _g, token) = spawn().await; let dup_dir = tempfile::tempdir().expect("dup tempdir"); // Duplicate uid (1 is taken by /data). @@ -141,12 +153,12 @@ async fn exports_add_rejects_duplicate_uid_name_and_retired() { AdminResponse::Err { error } => assert!(error.contains("retired"), "got: {error}"), AdminResponse::Ok { .. } => panic!("retired uid must error"), } - server.abort(); + token.cancel(); } #[tokio::test] async fn exports_remove_by_name_disappears_and_uid_is_retired() { - let (socket_path, _sd, _xd, _g, server) = spawn().await; + let (socket_path, _sd, _xd, _g, token) = spawn().await; let resp = admin::client::exports_remove( &socket_path, ExportSelector::Name("/data".to_string()), @@ -168,23 +180,23 @@ async fn exports_remove_by_name_disappears_and_uid_is_retired() { .map(|v| v.as_u64().unwrap()) .collect(); assert_eq!(retired, vec![1]); - server.abort(); + token.cancel(); } #[tokio::test] async fn exports_remove_by_uid_succeeds() { - let (socket_path, _sd, _xd, _g, server) = spawn().await; + 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"); - server.abort(); + token.cancel(); } #[tokio::test] async fn exports_update_flips_read_only() { - let (socket_path, _sd, _xd, _g, server) = spawn().await; + let (socket_path, _sd, _xd, _g, token) = spawn().await; admin::client::exports_update( &socket_path, ExportSelector::Name("/data".to_string()), @@ -199,12 +211,12 @@ async fn exports_update_flips_read_only() { .expect("list succeeds"); let entry = &list["exports"][0]; assert_eq!(entry["read_only"], true); - server.abort(); + token.cancel(); } #[tokio::test] async fn exports_update_missing_export_errors() { - let (socket_path, _sd, _xd, _g, server) = spawn().await; + let (socket_path, _sd, _xd, _g, token) = spawn().await; let resp = admin::client::send_request( &socket_path, &AdminRequest::ExportsUpdate { @@ -221,12 +233,12 @@ async fn exports_update_missing_export_errors() { } AdminResponse::Ok { .. } => panic!("missing uid must error"), } - server.abort(); + token.cancel(); } #[tokio::test] async fn dry_run_does_not_mutate_state_across_add_remove_update() { - let (socket_path, _sd, _xd, _g, server) = spawn().await; + let (socket_path, _sd, _xd, _g, token) = spawn().await; let before = admin::client::fetch_exports_list(&socket_path) .await @@ -278,12 +290,12 @@ async fn dry_run_does_not_mutate_state_across_add_remove_update() { .await .expect("list after"); assert_eq!(before, after, "state must be unchanged after dry-runs"); - server.abort(); + token.cancel(); } #[tokio::test] async fn config_show_returns_startup_config() { - let (socket_path, _sd, _xd, _g, server) = spawn().await; + let (socket_path, _sd, _xd, _g, token) = spawn().await; let data = admin::client::fetch_config_show(&socket_path) .await .expect("config-show succeeds"); @@ -312,5 +324,5 @@ async fn config_show_returns_startup_config() { after["exports"][0]["read_only"], false, "config-show must reflect the startup config, not live state", ); - server.abort(); + token.cancel(); } diff --git a/tests/test_admin_log_level.rs b/tests/test_admin_log_level.rs index 36be0a5..3762098 100644 --- a/tests/test_admin_log_level.rs +++ b/tests/test_admin_log_level.rs @@ -5,9 +5,12 @@ //! 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() { @@ -17,7 +20,14 @@ async fn log_level_set_then_get_round_trips_over_the_socket() { let (context, _export_dir, _log_guard) = AdminContext::for_test(); let listener = admin::server::bind_admin_socket(&socket_path, 0o600).expect("bind admin socket"); - let server = tokio::spawn(admin::serve(listener, socket_path.clone(), context)); + 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( @@ -45,7 +55,7 @@ async fn log_level_set_then_get_round_trips_over_the_socket() { .expect("log-level get after set succeeds"); assert_eq!(after["level"], "debug"); - server.abort(); + token.cancel(); } #[tokio::test] @@ -56,7 +66,14 @@ async fn log_level_set_rejects_invalid_level_and_leaves_filter_unchanged() { let (context, _export_dir, _log_guard) = AdminContext::for_test(); let listener = admin::server::bind_admin_socket(&socket_path, 0o600).expect("bind admin socket"); - let server = tokio::spawn(admin::serve(listener, socket_path.clone(), context)); + 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( @@ -84,5 +101,5 @@ async fn log_level_set_rejects_invalid_level_and_leaves_filter_unchanged() { "a rejected `set` must leave the filter unchanged", ); - server.abort(); + token.cancel(); } diff --git a/tests/test_admin_metrics.rs b/tests/test_admin_metrics.rs index 87f0f3f..72852e3 100644 --- a/tests/test_admin_metrics.rs +++ b/tests/test_admin_metrics.rs @@ -11,6 +11,8 @@ 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 @@ -21,7 +23,7 @@ async fn spawn() -> ( tempfile::TempDir, tempfile::TempDir, admin::context::TestLogReloadGuard, - tokio::task::JoinHandle>, + CancellationToken, ) { let socket_dir = tempfile::tempdir().expect("socket tempdir"); let socket_path = socket_dir.path().join("admin.sock"); @@ -31,13 +33,22 @@ async fn spawn() -> ( let listener = admin::server::bind_admin_socket(&socket_path, 0o600).expect("bind admin socket"); - let server = tokio::spawn(admin::serve(listener, socket_path.clone(), context)); - (socket_path, fs, socket_dir, export_dir, log_guard, server) + // 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, server) = spawn().await; + 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) @@ -105,5 +116,5 @@ async fn metrics_counts_admin_commands_and_per_export_io() { assert_eq!(data["server"]["rpc_requests_total"], 0); assert_eq!(data["server"]["rpc_errors_total"], 0); - server.abort(); + token.cancel(); } diff --git a/tests/test_admin_status_version.rs b/tests/test_admin_status_version.rs index d117244..1b8192a 100644 --- a/tests/test_admin_status_version.rs +++ b/tests/test_admin_status_version.rs @@ -5,9 +5,12 @@ //! 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() { @@ -17,7 +20,14 @@ async fn status_and_version_round_trip_over_the_socket() { let (context, _export_dir, _log_guard) = AdminContext::for_test(); let listener = admin::server::bind_admin_socket(&socket_path, 0o600).expect("bind admin socket"); - let server = tokio::spawn(admin::serve(listener, socket_path.clone(), context)); + 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( @@ -91,7 +101,7 @@ async fn status_and_version_round_trip_over_the_socket() { let json_version = admin::client::render_version(&version, true).expect("render json version"); assert!(json_version.contains("\"rustc_version\"")); - server.abort(); + token.cancel(); } #[tokio::test] 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}", + ); +}