diff --git a/Cargo.lock b/Cargo.lock index f73b607..cb1d0d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,18 +26,50 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstyle" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +[[package]] +name = "anyhow" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "axum" version = "0.8.4" @@ -92,6 +124,35 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-test" +version = "18.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680e88effaafbb28675074f29cda0e984c984bed5eb513085c17caf7de564225" +dependencies = [ + "anyhow", + "axum", + "bytes", + "bytesize", + "cookie", + "expect-json", + "http", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -104,7 +165,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -125,12 +186,41 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "bytesize" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3c8f83209414aacf0eeae3cf730b18d6981697fba62f200fcfb92b9f082acba" + +[[package]] +name = "cc" +version = "1.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.47" @@ -169,6 +259,37 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "deranged" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "displaydoc" version = "0.2.5" @@ -180,20 +301,67 @@ dependencies = [ "syn", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110ca254af04e46794fcc4be0991e72e13fdd8c78119e02c76a5473f6f74e049" +dependencies = [ + "serde_core", + "typeid", +] + [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.0", +] + +[[package]] +name = "expect-json" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7519e78573c950576b89eb4f4fe82aedf3a80639245afa07e3ee3d199dcdb29e" +dependencies = [ + "chrono", + "email_address", + "expect-json-macros", + "num", + "serde", + "serde_json", + "thiserror", + "typetag", + "uuid", +] + +[[package]] +name = "expect-json-macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bf7f5979e98460a0eb412665514594f68f366a32b85fa8d7ffb65bb1edee6a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -202,6 +370,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + [[package]] name = "fnv" version = "1.0.7" @@ -232,6 +406,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + [[package]] name = "futures-macro" version = "0.3.31" @@ -262,8 +442,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-io", "futures-macro", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -278,7 +460,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.4+wasi-0.2.4", + "wasi 0.14.5+wasi-0.2.4", ] [[package]] @@ -289,12 +471,12 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "git-url-parse" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d7ff03a34ea818a59cf30c0d7aa55354925484fa30bcc4cb96d784ff07578f" +checksum = "1e7b317d596ee92449fde128f598e56e2d75aaa9436a3db7620dd3c0bb3e1543" dependencies = [ "strum", - "thiserror 1.0.69", + "thiserror", "url", ] @@ -302,7 +484,9 @@ dependencies = [ name = "gitvol" version = "0.2.0" dependencies = [ + "async-trait", "axum", + "axum-test", "clap", "git-url-parse", "once_cell", @@ -310,7 +494,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.16", + "thiserror", "tokio", "tokio-stream", "tracing", @@ -402,6 +586,7 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", ] [[package]] @@ -411,13 +596,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "hyper", + "libc", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", ] [[package]] @@ -529,14 +743,23 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +checksum = "206a8042aec68fa4a62e8d3f7aa4ceb508177d9324faf261e1959e495b7a1921" dependencies = [ "equivalent", "hashbrown", ] +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + [[package]] name = "io-uring" version = "0.7.10" @@ -578,9 +801,9 @@ checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" @@ -650,6 +873,85 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.36.7" @@ -692,6 +994,31 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -725,6 +1052,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom", +] + [[package]] name = "regex" version = "1.11.2" @@ -760,6 +1116,15 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "reserve-port" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" +dependencies = [ + "thiserror", +] + [[package]] name = "rstest" version = "0.26.1" @@ -789,6 +1154,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c839d037155ebc06a571e305af66ff9fd9063a6e662447051737e1ac75beea41" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http", + "mime", + "rand", + "thiserror", +] + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -806,15 +1186,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] @@ -837,18 +1217,28 @@ checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.221" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "341877e04a22458705eb4e131a1508483c877dca2792b3781d4e5d8a6019ec43" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.221" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c459bc0a14c840cb403fc14b148620de1e0778c96ecd6e0c8c3cacb6d8d00fe" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.221" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d6185cf75117e20e62b1ff867b9518577271e58abe0037c40bb4794969355ab0" dependencies = [ "proc-macro2", "quote", @@ -857,14 +1247,14 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.144" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "56177480b00303e689183f110b4e727bb4211d692c62d4fcd16d02be93077d40" dependencies = [ "itoa", "memchr", "ryu", - "serde", + "serde_core", ] [[package]] @@ -898,6 +1288,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.6" @@ -937,23 +1333,22 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "strum" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", "syn", ] @@ -987,24 +1382,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" dependencies = [ "fastrand", "getrandom", "once_cell", "rustix", - "windows-sys 0.60.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", + "windows-sys 0.61.0", ] [[package]] @@ -1013,18 +1399,7 @@ version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.16", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -1047,6 +1422,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -1205,11 +1610,47 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typetag" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f22b40dd7bfe8c14230cf9702081366421890435b2d625fa92b4acc4c3de6f" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f5380909ffc31b4de4f4bdf96b877175a016aa2ca98cee39fcfd8c4d53d952" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "url" @@ -1246,6 +1687,21 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1254,9 +1710,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.4+wasi-0.2.4" +version = "0.14.5+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4494f6290a82f5fe584817a676a34b9d6763e8d9d18204009fb31dceca98fd4" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.0+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a5f4a424faf49c3c2c344f166f0662341d470ea185e939657aaff130f0ec4a" +checksum = "03fa2761397e5bd52002cd7e73110c71af2109aca4e521a9f40473fe685b0a24" dependencies = [ "wit-bindgen", ] @@ -1320,11 +1785,64 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "windows-core" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" -version = "0.1.3" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link", +] [[package]] name = "windows-sys" @@ -1332,7 +1850,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -1341,16 +1859,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" dependencies = [ - "windows-targets 0.53.3", + "windows-link", ] [[package]] @@ -1359,31 +1877,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "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", ] [[package]] @@ -1392,96 +1893,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" - [[package]] name = "winnow" version = "0.7.13" @@ -1503,6 +1956,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.0" @@ -1527,6 +1986,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index a526beb..15ca723 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.2.0" edition = "2024" [dependencies] -axum = {version = "0.8.4", features = ["tower-log", "tokio"]} +axum = { version = "0.8.4", features = ["tower-log", "tokio"] } tracing = { version = "0.1.41", features = ["async-await", "log", "valuable"] } tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } serde = { version = "1.0.219", features = ["derive"] } @@ -13,12 +13,14 @@ tokio = { version = "1.47.1", features = ["rt", "rt-multi-thread", "fs", "macros tokio-stream = "0.1.17" clap = { version = "4.5.45", default-features = false, features = ["derive", "std", "help"] } thiserror = "2.0.16" +async-trait = "0.1.89" # broken deps git-url-parse = "0.4.5" url = { version = "2.5.4" } [dev-dependencies] +axum-test = "18.1.0" once_cell = "1.21.3" rstest = "0.26.1" tempfile = "3.20.0" diff --git a/build.sh b/build.sh index 9eb44cd..9b0da63 100755 --- a/build.sh +++ b/build.sh @@ -8,7 +8,7 @@ print_err() { } PLUGIN_NAME="nerjs/gitvol" -VERSION=$(cargo run --quiet -- --version | awk '{split($0,a," "); print a[2]}') || print_err "Failed to get version from cargo" +VERSION=$(RUST_LOG=error cargo run --quiet -- --version | awk '{split($0,a," "); print a[2]}') || print_err "Failed to get version from cargo" BUILD_IMAGE="${PLUGIN_NAME}_rootfs_image" BUILD_PATH="$PWD/build" ROOTFS_PATH="$BUILD_PATH/rootfs" @@ -91,8 +91,6 @@ create_plugin() { docker plugin create "${PLUGIN_NAME}:${1}" "$BUILD_PATH" || print_err "Failed to create plugin" } - - build_rootfs_image create_plugin $VERSION create_plugin latest diff --git a/docker-compose.yml b/docker-compose.yml index d9cd25b..5bfcb12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: volumes: my-vol: driver: nerjs/gitvol:latest - name: mv_v6 + name: mv_v7 driver_opts: url: https://github.com/nerjs/gitvol-test.git refetch: "true" diff --git a/src/app/handlers.rs b/src/app/handlers.rs deleted file mode 100644 index a382d0a..0000000 --- a/src/app/handlers.rs +++ /dev/null @@ -1,174 +0,0 @@ -use axum::{Json, extract::State}; -use serde_json::json; -use tokio::fs; -use tracing::{debug, field, warn}; - -use crate::{ - domains::volume::{Status, Volume}, - git, - result::ErrorIoExt, - state::GitvolState, -}; - -use super::shared::*; - -pub(super) async fn activate_plugin() -> Result { - debug!("Initiating plugin activation."); - Ok(Json(json!({ "Implements": ["VolumeDriver"] }))) -} - -pub(super) async fn capabilities_handler() -> Result { - debug!("Retrieving plugin capabilities."); - Ok(Json(json!({ "Capabilities": { "Scope": "local" } }))) -} - -pub(super) async fn get_volume_path( - State(state): State, - Json(Named { name }): Json, -) -> Result { - debug!(name, "Retrieving path for volume."); - let Some(volume) = state.read(&name).await else { - warn!(name, "Volume not found."); - return Ok(OptionalMp { mountpoint: None }); - }; - - let mountpoint = volume.path.clone(); - - debug!( - name, - mountpoint = field::debug(&mountpoint), - "Retrieved volume path information" - ); - Ok(OptionalMp { mountpoint }) -} - -pub(super) async fn get_volume( - State(state): State, - Json(Named { name }): Json, -) -> Result { - debug!(name, "Retrieving information for volume."); - let volume = state.try_read(&name).await?; - - let mountpoint = volume.path.clone(); - debug!( - name, - status = field::debug(&volume.status), - mountpoint = field::debug(&mountpoint), - "Retrieved volume information" - ); - - Ok(GetResponse { - volume: GetMp { - name, - mountpoint, - status: MpStatus { - status: volume.status.clone(), - }, - }, - }) -} - -pub(super) async fn list_volumes(State(state): State) -> Result { - debug!("Retrieving list of volumes."); - let list = state.read_all().await; - - debug!(count = list.len(), "Retrieved volumes list."); - - Ok(ListResponse { - volumes: list - .into_iter() - .map(|Volume { name, path, .. }| ListMp { - name, - mountpoint: path, - }) - .collect(), - }) -} - -pub(super) async fn create_volume( - State(state): State, - Json(RawCreateRequest { name, opts }): Json, -) -> Result { - state.create(&name, opts).await?; - debug!(name, "Volume created successfully."); - Ok(Empty {}) -} - -pub(super) async fn remove_volume( - State(state): State, - Json(Named { name }): Json, -) -> Result { - debug!(name, "Attempting to remove volume."); - - let Some(volume) = state.remove(&name).await else { - warn!(name, "Volume not found."); - return Ok(Empty {}); - }; - - remove_dir_if_exists(volume.path.clone()).await?; - - debug!(name, "Volume removed successfully."); - Ok(Empty {}) -} - -pub(super) async fn mount_volume_to_container( - State(state): State, - Json(NamedWID { name, id }): Json, -) -> Result { - debug!(name, id, "Attempting to mount volume."); - let mut volume = state.try_write(&name).await?; - - if let Some(path) = volume.path.clone() { - debug!(name, id, "Repository already cloned."); - if volume.repo.refetch { - debug!(name, id, "Attempting to refetch repository."); - git::refetch(&path).await?; - } - volume.containers.insert(id.clone()); - return Ok(Mp { - mountpoint: path.clone(), - }); - } - - let path = volume.create_path_from(&state.path); - if path.exists() { - debug!(name, id, "Repository directory already exists. Remooving"); - fs::remove_dir_all(&path).await.map_io_error(&path)?; - } - git::clone(&path, &volume.repo).await?; - - volume.containers.insert(id.clone()); - volume.status = Status::Clonned; - - debug!(name, id, "Volume mounted successfully."); - Ok(Mp { mountpoint: path }) -} - -pub(super) async fn unmount_volume_by_container( - State(state): State, - Json(NamedWID { name, id }): Json, -) -> Result { - debug!(name, id, "Attempting to unmount volume."); - let Some(mut volume) = state.write(&name).await else { - warn!(name, id, "Volume not found."); - return Ok(Empty {}); - }; - - volume.containers.remove(&id); - - if !volume.containers.is_empty() { - debug!( - name, - container_count = volume.containers.len(), - "Volume still in use by containers." - ); - return Ok(Empty {}); - } - - volume.status = Status::Cleared; - remove_dir_if_exists(volume.path.clone()).await?; - volume.path = None; - - debug!(name, "Volume unmounted successfully."); - Ok(Empty {}) -} diff --git a/src/app/mod.rs b/src/app/mod.rs deleted file mode 100644 index 60e93b7..0000000 --- a/src/app/mod.rs +++ /dev/null @@ -1,46 +0,0 @@ -mod handlers; -mod shared; -#[cfg(test)] -mod tests; - -use crate::state::GitvolState; -use axum::{ - Router, - extract::Request, - http::{HeaderValue, header::CONTENT_TYPE}, - middleware::{self, Next}, - response::Response, - routing::post, -}; -use handlers::*; -use tracing::{Instrument, info_span}; - -pub fn create(state: GitvolState) -> Router { - Router::new() - .route("/Plugin.Activate", post(activate_plugin)) - .route("/VolumeDriver.Capabilities", post(capabilities_handler)) - .route("/VolumeDriver.Path", post(get_volume_path)) - .route("/VolumeDriver.Get", post(get_volume)) - .route("/VolumeDriver.List", post(list_volumes)) - .route("/VolumeDriver.Create", post(create_volume)) - .route("/VolumeDriver.Remove", post(remove_volume)) - .route("/VolumeDriver.Mount", post(mount_volume_to_container)) - .route("/VolumeDriver.Unmount", post(unmount_volume_by_container)) - .layer(middleware::from_fn(transform_headers)) - .with_state(state) -} - -async fn transform_headers(mut request: Request, next: Next) -> Response { - let uri = request.uri().to_string(); - let span = info_span!("call", uri); - let headers = request.headers_mut(); - headers.append(CONTENT_TYPE, HeaderValue::from_static("application/json")); - let mut response = next.run(request).instrument(span).await; - let response_headers = response.headers_mut(); - response_headers.append( - CONTENT_TYPE, - HeaderValue::from_static("application/vnd.docker.plugin.v1+json"), - ); - - response -} diff --git a/src/app/shared.rs b/src/app/shared.rs deleted file mode 100644 index a56f88d..0000000 --- a/src/app/shared.rs +++ /dev/null @@ -1,138 +0,0 @@ -use std::path::PathBuf; - -use axum::{Json, http::StatusCode, response::IntoResponse}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use tokio::fs; -use tracing::{debug, error, field}; - -use crate::{ - domains::{repo::RawRepo, volume::Status}, - result::{Error, ErrorIoExt}, -}; - -// CORE - -impl IntoResponse for Error { - fn into_response(self) -> axum::response::Response { - error!("Response error: {:?}", self); - let error = format!("{}", self); - - (StatusCode::OK, Json(json!({"Err":error}))).into_response() - } -} - -pub(super) type Result> = std::result::Result; - -// INPUT - -#[cfg_attr(test, derive(Clone))] -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -pub(super) struct Named { - pub(super) name: String, -} - -#[cfg_attr(test, derive(Default, Clone))] -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -pub(super) struct RawCreateRequest { - pub(super) name: String, - pub(super) opts: Option, -} - -#[cfg_attr(test, derive(Clone, Debug))] -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -pub(super) struct NamedWID { - pub(super) name: String, - #[serde(rename = "ID")] - pub(super) id: String, -} - -// OUTPUT - -#[cfg_attr(test, derive(Debug))] -#[derive(Serialize)] -#[serde(rename_all = "PascalCase")] -pub(super) struct Mp { - pub(super) mountpoint: PathBuf, -} - -#[derive(Serialize)] -#[serde(rename_all = "PascalCase")] -pub(super) struct OptionalMp { - #[serde(skip_serializing_if = "Option::is_none")] - pub(super) mountpoint: Option, -} - -#[cfg_attr(test, derive(Debug, PartialEq))] -#[derive(Serialize)] -pub(super) struct MpStatus { - pub(super) status: Status, -} - -#[cfg_attr(test, derive(Debug, PartialEq))] -#[derive(Serialize)] -#[serde(rename_all = "PascalCase")] -pub(super) struct GetMp { - pub(super) name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(super) mountpoint: Option, - // TODO: format must be an object/ example: {"CreatedAt": "2025-08-24T19:44:31", "Size": "10GB", "Available": "5GB"} - pub(super) status: MpStatus, -} - -#[cfg_attr(test, derive(Debug))] -#[derive(Serialize)] -#[serde(rename_all = "PascalCase")] -pub(super) struct GetResponse { - pub(super) volume: GetMp, -} - -#[cfg_attr(test, derive(PartialEq))] -#[derive(Serialize)] -#[serde(rename_all = "PascalCase")] -pub(super) struct ListMp { - pub(super) name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(super) mountpoint: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "PascalCase")] -pub(super) struct ListResponse { - pub(super) volumes: Vec, -} - -#[cfg_attr(test, derive(Debug, PartialEq))] -#[derive(Serialize)] -pub(super) struct Empty {} - -macro_rules! into_response { - ($($name:ident),*) => { - $( - - impl axum::response::IntoResponse for $name { - fn into_response(self) -> axum::response::Response { - Json(self).into_response() - } - } - )* - }; -} - -into_response!(Mp, OptionalMp, GetResponse, ListResponse, Empty); - -// HELPERS - -pub(super) async fn remove_dir_if_exists(path: Option) -> crate::result::Result<()> { - if let Some(path) = path - && path.exists() - { - debug!(path = field::debug(&path), "Attempting to remove directory"); - fs::remove_dir_all(&path).await.map_io_error(&path)?; - } - - Ok(()) -} diff --git a/src/app/tests.rs b/src/app/tests.rs deleted file mode 100644 index 7600602..0000000 --- a/src/app/tests.rs +++ /dev/null @@ -1,824 +0,0 @@ -use super::handlers::*; -use super::shared::*; -use crate::domains::{ - repo::{RawRepo, Repo, test::REPO_URL}, - volume::test::VOLUME_NAME, -}; -use crate::result::Result; -use crate::state::GitvolState; -use axum::{Json, extract::State}; -use std::path::PathBuf; -use tempfile::{Builder as TempBuilder, TempDir}; -use uuid::Uuid; - -impl GitvolState { - pub async fn set_path(&self, name: &str, path: impl Into) -> Result<()> { - let mut volume = self.try_write(name).await?; - volume.path = Some(path.into()); - Ok(()) - } - - async fn stub_with_path(path: impl Into) -> Self { - let state = Self::stub_with_create().await; - let mut volume = state.try_write(VOLUME_NAME).await.unwrap(); - volume.path = Some(path.into()); - - state - } - - fn temp() -> (Self, TempDir) { - let temp = TempBuilder::new().prefix("temp-gitvol-").tempdir().unwrap(); - (Self::new(temp.path().to_path_buf()), temp) - } - - async fn temp_with_volume() -> (Self, TempDir) { - let (state, temp_guard) = Self::temp(); - _ = state - .create(VOLUME_NAME, Some(RawRepo::stub())) - .await - .unwrap(); - - (state, temp_guard) - } - - fn req(&self) -> State { - State(self.clone()) - } -} - -impl Named { - fn new(name: &str) -> Self { - Self { - name: name.to_string(), - } - } - - fn stub() -> Self { - Self::new(VOLUME_NAME) - } - - fn req(&self) -> Json { - Json(self.clone()) - } -} - -impl NamedWID { - fn new(name: &str) -> Self { - let id = Uuid::new_v4(); - Self { - name: name.to_string(), - id: id.to_string(), - } - } - - fn stub() -> Self { - Self::new(VOLUME_NAME) - } - - fn to_req(self) -> Json { - Json(self) - } - - fn req() -> Json { - Json(Self::stub()) - } -} - -impl RawCreateRequest { - fn new(name: &str) -> Self { - Self { - name: name.to_string(), - ..Default::default() - } - } - - fn stub() -> Self { - Self::new(VOLUME_NAME) - } - - fn stub_with_url() -> Self { - Self::stub().with_url(REPO_URL) - } - - fn req() -> Json { - Json(Self::stub()) - } - - fn to_req(self) -> Json { - Json(self) - } - - fn with_opts(mut self, opts: RawRepo) -> Self { - self.opts = Some(opts); - self - } - - fn with_url(self, url: &str) -> Self { - let mut opts = self.clone().opts.unwrap_or_default(); - opts.url = Some(url.to_string()); - self.with_opts(opts) - } - - fn with_tag(self, tag: &str) -> Self { - let mut opts = self.clone().opts.unwrap_or_default(); - opts.tag = Some(tag.to_string()); - self.with_opts(opts) - } - - fn with_branch(self, branch: &str) -> Self { - let mut opts = self.clone().opts.unwrap_or_default(); - opts.branch = Some(branch.to_string()); - self.with_opts(opts) - } - - fn with_refetch(self, refetch: bool) -> Self { - let mut opts = self.clone().opts.unwrap_or_default(); - opts.refetch = Some(if refetch { - "true".to_string() - } else { - "false".to_string() - }); - self.with_opts(opts) - } -} - -mod oneline { - use super::*; - - #[tokio::test] - async fn activate_plugin_success() { - let result = activate_plugin().await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn capabilities_handler_success() { - let result = capabilities_handler().await; - assert!(result.is_ok()) - } -} - -mod by_state_reactions { - use tokio::fs; - - use crate::{ - domains::volume::Status, - git::test::{TestRepo, is_git_dir}, - }; - - use super::*; - - #[tokio::test] - async fn get_path_if_non_existent_volume() { - let state = GitvolState::stub(); - let result = get_volume_path(State(state), Named::stub().req()).await; - - assert!(result.is_ok()); - let mount_point = result.unwrap(); - assert_eq!(mount_point.mountpoint, None); - } - - #[tokio::test] - async fn successfully_get_volume_path() { - let path = PathBuf::from("/tmp/test_path"); - let state = GitvolState::stub_with_path(&path).await; - - let result = get_volume_path(State(state), Named::stub().req()).await; - - assert!(result.is_ok()); - let mount_point = result.unwrap(); - assert_eq!(mount_point.mountpoint, Some(path)); - } - - #[tokio::test] - async fn get_volume_if_non_existent_volume() { - let state = GitvolState::stub(); - let result = get_volume(State(state), Named::stub().req()).await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn successfully_get_volume() { - let path = PathBuf::from("/tmp/test_volume"); - let state = GitvolState::stub_with_path(&path).await; - - let result = get_volume(State(state), Named::stub().req()).await; - - assert!(result.is_ok()); - let response = result.unwrap(); - assert_eq!( - response.volume, - GetMp { - name: VOLUME_NAME.to_string(), - mountpoint: Some(path), - status: MpStatus { - status: Status::Created - }, - } - ); - } - - #[tokio::test] - async fn empty_list_volumes() { - let state = GitvolState::stub(); - let result = list_volumes(State(state)).await; - assert!(result.is_ok()); - let response = result.unwrap(); - assert!(response.volumes.is_empty()); - } - - #[tokio::test] - async fn non_empty_list_volumes() { - let second_volume_name = format!("{}_2", VOLUME_NAME); - let path = PathBuf::from("/tmp/test_volume"); - let state = GitvolState::stub_with_path(path).await; - _ = state - .create(&second_volume_name, Some(RawRepo::stub())) - .await - .unwrap(); - - let result = list_volumes(State(state)).await; - assert!(result.is_ok()); - let response = result.unwrap(); - assert_eq!(response.volumes.len(), 2); - - assert!(response.volumes.contains(&ListMp { - name: VOLUME_NAME.to_string(), - mountpoint: Some(PathBuf::from("/tmp/test_volume")), - })); - assert!(response.volumes.contains(&ListMp { - name: second_volume_name, - mountpoint: None, - })); - } - - #[tokio::test] - async fn create_volume_missing_opt() { - let state = GitvolState::stub(); - let result = create_volume(State(state.clone()), RawCreateRequest::req()).await; - - assert!(result.is_err()); - } - - #[tokio::test] - async fn create_volume_missing_url() { - let state = GitvolState::stub(); - let request = RawCreateRequest::stub() - .with_opts(RawRepo::default()) - .to_req(); - - let result = create_volume(State(state.clone()), request).await; - - assert!(result.is_err()); - } - - #[tokio::test] - async fn create_handler_state_incorrect_name_error() { - let state = GitvolState::stub(); - // empty trimmed name - let request = RawCreateRequest::new(" ").with_url(REPO_URL).to_req(); - let result = create_volume(State(state.clone()), request).await; - - assert!(result.is_err()); - } - - #[tokio::test] - async fn create_volume_multiple_refs() { - let state = GitvolState::stub(); - let request = RawCreateRequest::new(VOLUME_NAME) - .with_url(REPO_URL) - .with_branch("branch") - .with_tag("tag") - .to_req(); - - let result = create_volume(State(state.clone()), request).await; - - assert!(result.is_err()); - } - - #[tokio::test] - async fn create_volume_state_duplicate_error() { - let state = GitvolState::stub_with_create().await; - let result = create_volume( - State(state.clone()), - RawCreateRequest::stub_with_url().to_req(), - ) - .await; - - assert!(result.is_err()); - } - - #[tokio::test] - async fn successfully_create_volume() { - let state = GitvolState::stub(); - let result = create_volume( - State(state.clone()), - RawCreateRequest::stub_with_url().to_req(), - ) - .await; - - assert!(result.is_ok()); - assert_eq!(result.unwrap(), Empty {}); - - let volume = state.read(VOLUME_NAME).await.unwrap(); - - assert_eq!(volume.name, VOLUME_NAME); - assert_eq!(volume.repo, Repo::stub()); - } - - #[tokio::test] - async fn successfully_remove_volume() { - let (state, temp) = GitvolState::temp(); - let path = temp.path().to_path_buf(); - state - .create(VOLUME_NAME, Some(RawRepo::stub())) - .await - .unwrap(); - - fs::create_dir_all(&path).await.unwrap(); - fs::write(path.join("some.file"), "contents").await.unwrap(); - state.set_path(VOLUME_NAME, &path).await.unwrap(); - - assert!(path.exists()); - let result = remove_volume(State(state.clone()), Named::stub().req()).await; - - assert!(result.is_ok()); - assert_eq!(result.unwrap(), Empty {}); - - let volume = state.read(VOLUME_NAME).await; - assert!(volume.is_none()); - assert!(!path.exists()); - } - - #[tokio::test] - async fn failed_mount_by_non_existent_volume() { - let state = GitvolState::stub(); - let result = mount_volume_to_container(State(state), NamedWID::req()).await; - - assert!(result.is_err()); - } - - #[tokio::test] - async fn successfully_mount_volume() { - let test_repo = TestRepo::get_or_create().await.unwrap(); - let (state, _temp) = GitvolState::temp(); - - _ = state - .create(VOLUME_NAME, Some(RawRepo::from_url(&test_repo.file))) - .await - .unwrap(); - let request = NamedWID::stub(); - - let result = mount_volume_to_container(State(state.clone()), Json(request.clone())).await; - assert!(result.is_ok()); - - let volume = state.read(VOLUME_NAME).await.unwrap().clone(); - let path = volume.path.unwrap(); - - assert!(path.exists()); - assert!(!is_git_dir(&path)); - assert!(volume.containers.contains(&request.id)); - } - - #[tokio::test] - async fn failed_unmount_by_non_existent_volume() { - let state = GitvolState::stub(); - let result = unmount_volume_by_container(State(state), NamedWID::req()).await; - - assert!(result.is_ok()); - } - - #[tokio::test] - async fn successfully_unmount_volume() { - let (state, temp) = GitvolState::temp_with_volume().await; - let request = NamedWID::stub(); - let path = temp.path().join("volume_path"); - let mut volume = state.try_write(VOLUME_NAME).await.unwrap(); - volume.containers.insert(request.id.clone()); - volume.path = Some(path.clone()); - drop(volume); - fs::create_dir_all(&path).await.unwrap(); - - let result = unmount_volume_by_container(State(state.clone()), Json(request.clone())).await; - - assert!(result.is_ok()); - assert!(!path.exists()); - - let volume = state.read(VOLUME_NAME).await.unwrap().clone(); - assert!(!volume.containers.contains(&request.id)); - } -} - -mod usecase { - use crate::{ - domains::volume::Status, - git::test::{TestRepo, is_git_dir}, - }; - - use super::*; - - #[derive(Clone)] - struct CheckVol { - name: String, - status: Status, - mountpoint: Option, - } - - impl CheckVol { - fn new(name: &str) -> Self { - Self { - name: name.to_string(), - status: Status::Created, - mountpoint: None, - } - } - - fn with_status(mut self, status: Status) -> Self { - self.status = status; - self - } - - fn with_mp(mut self, mp: Option) -> Self { - self.mountpoint = mp; - self - } - - fn stub() -> Self { - Self::new(VOLUME_NAME) - } - } - - async fn assert_list(state: &GitvolState, len: usize, includes: Vec) { - let ListResponse { volumes } = list_volumes(state.req()).await.unwrap(); - - let msg = if len == 0 { - format!("The list of volumes is not empty") - } else { - format!( - "The length of the list of volumes is not equal to {}. Actual - {}", - len, - volumes.len() - ) - }; - assert_eq!(len, volumes.len(), "{}", msg); - - for CheckVol { - name, - mountpoint, - status, - } in includes - { - let name: String = name.to_string(); - let list_item = volumes.iter().find(|item| item.name == name); - assert!( - list_item.is_some(), - "The volume named '{}' was not found in the list.", - name - ); - let request = Named::new(&name).req(); - let get_response = get_volume(state.req(), request.clone()).await; - - assert!( - get_response.is_ok(), - "The volume named {} is missing upon getting. Error: {:?}", - name, - get_response.unwrap_err() - ); - let volume_from_get = get_response.unwrap().volume; - - assert_eq!( - MpStatus { - status: status.clone() - }, - volume_from_get.status, - "The volume status does not match the expected status. Current status: {:?}. Expected status: {:?}.", - volume_from_get.status, - status - ); - - let mountpoint_from_list = list_item.unwrap().mountpoint.clone(); - let mountpoint_from_get = volume_from_get.mountpoint; - assert_eq!( - mountpoint_from_list, mountpoint_from_get, - "Mountpoint from list is not equal to mountpoint from get" - ); - - let mountpoint_from_path = get_volume_path(state.req(), request) - .await - .unwrap() - .mountpoint; - assert_eq!( - mountpoint_from_get, mountpoint_from_path, - "Mountpoint from path is not equal to mountpoint from get/list" - ); - - assert_eq!( - mountpoint, mountpoint_from_path, - "Mountpoint from mount is not equal to mountpoint from get/list/path" - ); - } - } - - async fn assert_empty_list(state: &GitvolState) { - assert_list(state, 0, Vec::new()).await; - } - - async fn assert_created(state: &GitvolState, list: Vec<&str>) { - let includes = list - .into_iter() - .map(|name| CheckVol::new(name).with_status(Status::Created)) - .collect::>(); - assert_list(state, includes.len(), includes).await; - } - - async fn assert_created_stub(state: &GitvolState) { - assert_created(state, vec![VOLUME_NAME]).await - } - - #[tokio::test] - async fn emply_before_working() { - assert_empty_list(&GitvolState::stub()).await; - } - - #[tokio::test] - async fn onetime_creating_volume() { - let state = GitvolState::stub(); - assert_empty_list(&state).await; - - _ = create_volume(state.req(), RawCreateRequest::stub_with_url().to_req()) - .await - .unwrap(); - - assert_created_stub(&state).await; - - remove_volume(state.req(), Named::stub().req()) - .await - .unwrap(); - assert_empty_list(&state).await; - } - - #[tokio::test] - async fn creating_multiple_volumes() { - let first_vol = "first"; - let second_vol = "second"; - - let state = GitvolState::stub(); - assert_empty_list(&state).await; - - _ = create_volume( - state.req(), - RawCreateRequest::new(first_vol).with_url("/some").to_req(), - ) - .await - .unwrap(); - - assert_created(&state, vec![first_vol]).await; - - _ = create_volume( - state.req(), - RawCreateRequest::new(second_vol).with_url("/some").to_req(), - ) - .await - .unwrap(); - - assert_created(&state, vec![first_vol, second_vol]).await; - - remove_volume(state.req(), Named::new(first_vol).req()) - .await - .unwrap(); - assert_created(&state, vec![second_vol]).await; - - remove_volume(state.req(), Named::new(second_vol).req()) - .await - .unwrap(); - assert_empty_list(&state).await; - } - - #[tokio::test] - async fn onetime_creating_and_mounting_volume() { - let test_repo = TestRepo::get_or_create().await.unwrap(); - let (state, _) = GitvolState::temp(); - assert_empty_list(&state).await; - - _ = create_volume( - state.req(), - RawCreateRequest::stub().with_url(&test_repo.file).to_req(), - ) - .await - .unwrap(); - - assert_created_stub(&state).await; - - let Mp { mountpoint } = mount_volume_to_container(state.req(), NamedWID::stub().to_req()) - .await - .unwrap(); - - assert_list( - &state, - 1, - vec![ - CheckVol::stub() - .with_status(Status::Clonned) - .with_mp(Some(mountpoint.clone())), - ], - ) - .await; - assert!(mountpoint.exists()); - assert!(!is_git_dir(&mountpoint)); - assert!(test_repo.is_master(&mountpoint)); - - remove_volume(state.req(), Named::stub().req()) - .await - .unwrap(); - - assert!(!mountpoint.exists()); - assert_empty_list(&state).await; - } - - #[tokio::test] - async fn mount_and_unmount_pipeline() { - let test_repo = TestRepo::get_or_create().await.unwrap(); - let named_1 = NamedWID::stub(); - let named_2 = NamedWID::stub(); - let check_vol = CheckVol::stub(); - - let (state, _) = GitvolState::temp(); - assert_empty_list(&state).await; - - _ = create_volume( - state.req(), - RawCreateRequest::stub().with_url(&test_repo.file).to_req(), - ) - .await - .unwrap(); - - assert_list(&state, 1, vec![check_vol.clone()]).await; - - let mp1 = mount_volume_to_container(state.req(), named_1.clone().to_req()) - .await - .unwrap(); - assert_list( - &state, - 1, - vec![ - check_vol - .clone() - .with_status(Status::Clonned) - .with_mp(Some(mp1.mountpoint.clone())), - ], - ) - .await; - assert!(mp1.mountpoint.exists()); - - let mp2 = mount_volume_to_container(state.req(), named_2.clone().to_req()) - .await - .unwrap(); - assert_eq!(mp1.mountpoint, mp2.mountpoint); - assert_list( - &state, - 1, - vec![ - check_vol - .clone() - .with_status(Status::Clonned) - .with_mp(Some(mp2.mountpoint.clone())), - ], - ) - .await; - assert!(mp1.mountpoint.exists()); - - let _ = unmount_volume_by_container(state.req(), named_1.clone().to_req()) - .await - .unwrap(); - assert_list( - &state, - 1, - vec![ - check_vol - .clone() - .with_status(Status::Clonned) - .with_mp(Some(mp2.mountpoint.clone())), - ], - ) - .await; - assert!(mp1.mountpoint.exists()); - - let _ = unmount_volume_by_container(state.req(), named_2.clone().to_req()) - .await - .unwrap(); - assert_list( - &state, - 1, - vec![check_vol.clone().with_status(Status::Cleared).with_mp(None)], - ) - .await; - assert!(!mp1.mountpoint.exists()); - } - - #[tokio::test] - async fn create_and_mount_default_branch() { - let test_repo = TestRepo::get_or_create().await.unwrap(); - let (state, _) = GitvolState::temp(); - - let _ = create_volume( - state.req(), - RawCreateRequest::stub().with_url(&test_repo.file).to_req(), - ) - .await - .unwrap(); - let Mp { mountpoint } = mount_volume_to_container(state.req(), NamedWID::stub().to_req()) - .await - .unwrap(); - - assert!(!is_git_dir(&mountpoint)); - assert!(test_repo.is_master(&mountpoint)); - } - - #[tokio::test] - async fn create_and_mount_specific_branch() { - let test_repo = TestRepo::get_or_create().await.unwrap(); - let (state, _) = GitvolState::temp(); - - let _ = create_volume( - state.req(), - RawCreateRequest::stub() - .with_url(&test_repo.file) - .with_branch(&test_repo.develop) - .to_req(), - ) - .await - .unwrap(); - let Mp { mountpoint } = mount_volume_to_container(state.req(), NamedWID::stub().to_req()) - .await - .unwrap(); - - assert!(!is_git_dir(&mountpoint)); - assert!(test_repo.is_develop(&mountpoint)); - } - - #[tokio::test] - async fn create_and_mount_tag() { - let test_repo = TestRepo::get_or_create().await.unwrap(); - let (state, _) = GitvolState::temp(); - - let _ = create_volume( - state.req(), - RawCreateRequest::stub() - .with_url(&test_repo.file) - .with_tag(&test_repo.tag) - .to_req(), - ) - .await - .unwrap(); - let Mp { mountpoint } = mount_volume_to_container(state.req(), NamedWID::stub().to_req()) - .await - .unwrap(); - - assert!(!is_git_dir(&mountpoint)); - assert!(test_repo.is_tag(&mountpoint)); - } - - #[tokio::test] - async fn create_refetchable_and_mount_twice() { - let test_repo = TestRepo::get_or_create().await.unwrap(); - let (state, _) = GitvolState::temp(); - let branch_name = format!("branch_{}", Uuid::new_v4().to_string()); - - test_repo.setup_temp_branch(&branch_name).await.unwrap(); - - let _ = create_volume( - state.req(), - RawCreateRequest::stub() - .with_url(&test_repo.file) - .with_branch(&branch_name) - .with_refetch(true) - .to_req(), - ) - .await - .unwrap(); - let Mp { mountpoint } = mount_volume_to_container(state.req(), NamedWID::stub().to_req()) - .await - .unwrap(); - - assert!(is_git_dir(&mountpoint)); - assert!( - !TestRepo::is_temp_changed(&mountpoint, &branch_name) - .await - .unwrap() - ); - - test_repo.change_temp_branch(&branch_name).await.unwrap(); - let Mp { - mountpoint: mountpoint2, - } = mount_volume_to_container(state.req(), NamedWID::stub().to_req()) - .await - .unwrap(); - - assert_eq!(mountpoint, mountpoint2); - assert!( - TestRepo::is_temp_changed(&mountpoint, &branch_name) - .await - .unwrap() - ); - } -} diff --git a/src/domains/repo.rs b/src/domains/repo.rs index cf1ce4b..323e746 100644 --- a/src/domains/repo.rs +++ b/src/domains/repo.rs @@ -24,8 +24,8 @@ pub struct Repo { pub refetch: bool, } -#[cfg_attr(test, derive(Default, Clone, Debug))] -#[derive(Deserialize)] +#[cfg_attr(test, derive(Default, Clone))] +#[derive(Debug, Deserialize)] pub struct RawRepo { pub url: Option, pub branch: Option, @@ -72,19 +72,6 @@ pub mod test { pub const REPO_URL: &str = "https://example.com/repo.git"; - impl Repo { - pub fn stub() -> Self { - Self::from_url(REPO_URL) - } - - pub fn from_url(url: &str) -> Self { - Self { - url: Url::from_str(url).unwrap(), - branch: None, - refetch: false, - } - } - } impl RawRepo { pub fn stub() -> Self { Self::from_url(REPO_URL) diff --git a/src/driver.rs b/src/driver.rs new file mode 100644 index 0000000..7683b9c --- /dev/null +++ b/src/driver.rs @@ -0,0 +1,895 @@ +use std::{fmt::Debug, path::PathBuf}; + +use axum::Router; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; + +#[cfg_attr(test, derive(Debug, PartialEq, Deserialize))] +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +#[allow(unused)] +pub enum Scope { + Local, + Global, +} + +#[cfg_attr(test, derive(Debug, PartialEq, Deserialize))] +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ItemVolume { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub mountpoint: Option, +} + +#[cfg_attr(test, derive(Clone, Debug, PartialEq))] +pub struct VolumeInfo { + pub mountpoint: Option, + pub status: S, +} + +#[async_trait::async_trait] +pub trait Driver: Clone + Send + Sync + 'static { + type Error: std::error::Error; + type Status: Serialize; + type Opts: DeserializeOwned + Debug + Send; + + async fn activate(&self) -> Result, Self::Error> { + Ok(vec!["VolumeDriver".to_string()]) + } + + async fn capabilities(&self) -> Result { + Ok(Scope::Global) + } + + async fn path(&self, name: &str) -> Result, Self::Error>; + async fn get(&self, name: &str) -> Result, Self::Error>; + async fn list(&self) -> Result, Self::Error>; + async fn create(&self, name: &str, opts: Option) -> Result<(), Self::Error>; + async fn remove(&self, name: &str) -> Result<(), Self::Error>; + async fn mount(&self, name: &str, id: &str) -> Result; + async fn unmount(&self, name: &str, id: &str) -> Result<(), Self::Error>; + + #[allow(dead_code)] + fn into_router(self) -> Router { + router::create_router(self) + } +} + +mod router { + + use super::*; + use axum::{ + Json, Router, + extract::{Request, State}, + http::{HeaderValue, Uri, header::CONTENT_TYPE}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::post, + }; + use serde::Serialize; + + macro_rules! log_request { + ($uri:ident, $($arg:tt)+) => { + println!("[DEBUG: {}] :: Request: {}", $uri.to_string(), format!($($arg)*)) + }; + ($uri:ident) => { + println!("[DEBUG: {}] :: Request", $uri.to_string()) + }; + } + macro_rules! parse_response { + ($uri:ident, $result:ident, $($arg:tt)+) => { + $result.map(Json).map_err(|e| { + let err = e.to_string(); + println!("[ERROR: {}] :: Failed: {}. {}", $uri.to_string(), err, format!($($arg)*)); + DriverError { err } + }) + }; + ($uri:ident, $result:ident) => { + $result.map(Json).map_err(|e| { + let err = e.to_string(); + println!("[ERROR: {}] :: Failed: {}", $uri.to_string(), err); + DriverError { err } + }) + }; + } + + #[cfg_attr(test, derive(Debug, PartialEq, Deserialize))] + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + pub struct DriverError { + pub err: String, + } + + impl IntoResponse for DriverError { + fn into_response(self) -> axum::response::Response { + Json(self).into_response() + } + } + + type Result = std::result::Result, DriverError>; + + #[cfg_attr(test, derive(Debug, PartialEq, Serialize))] + #[derive(Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct Named { + pub name: String, + } + + #[cfg_attr(test, derive(Debug, PartialEq, Serialize))] + #[derive(Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct NamedWID { + pub name: String, + #[serde(rename = "ID")] + pub id: String, + } + + #[cfg_attr(test, derive(Debug, PartialEq, Deserialize))] + #[derive(Serialize, Clone)] + pub struct Empty {} + + #[cfg_attr(test, derive(Debug, PartialEq, Deserialize))] + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + pub struct ImplementsDriver { + pub implements: Vec, + } + + #[cfg_attr(test, derive(Debug, PartialEq, Deserialize))] + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + pub struct Capabilities { + pub scope: Scope, + } + + #[cfg_attr(test, derive(Debug, PartialEq, Deserialize))] + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + pub struct CapabilitiesResponse { + pub capabilities: Capabilities, + } + + #[cfg_attr(test, derive(Debug, PartialEq, Deserialize))] + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + pub struct OptionalMountpoint { + #[serde(skip_serializing_if = "Option::is_none")] + pub mountpoint: Option, + } + + #[cfg_attr(test, derive(Debug, PartialEq, Deserialize))] + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + pub struct FullVolume { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub mountpoint: Option, + pub status: S, + } + + #[cfg_attr(test, derive(Debug, PartialEq, Deserialize))] + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + pub struct GetResponse { + pub volume: FullVolume, + } + + #[cfg_attr(test, derive(Debug, PartialEq, Deserialize))] + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + pub struct ListResponse { + pub volumes: Vec, + } + + #[cfg_attr(test, derive(Debug, PartialEq, Serialize))] + #[derive(Deserialize)] + #[serde(rename_all = "PascalCase")] + pub struct CreateRequest { + pub name: String, + pub opts: Option, + } + + #[cfg_attr(test, derive(Debug, PartialEq, Deserialize))] + #[derive(Serialize)] + #[serde(rename_all = "PascalCase")] + pub struct Mountpoint { + pub mountpoint: PathBuf, + } + + async fn activate_handler( + uri: Uri, + State(driver): State, + ) -> Result { + log_request!(uri); + let result = driver + .activate() + .await + .map(|implements| ImplementsDriver { implements }); + parse_response!(uri, result) + } + + async fn capabilities_handler( + uri: Uri, + State(driver): State, + ) -> Result { + log_request!(uri); + let result = driver + .capabilities() + .await + .map(|scope| CapabilitiesResponse { + capabilities: Capabilities { scope }, + }); + parse_response!(uri, result) + } + + async fn path_handler( + uri: Uri, + State(driver): State, + Json(Named { name }): Json, + ) -> Result { + log_request!(uri, "volume_name={}", name); + let result = driver + .path(&name) + .await + .map(|mountpoint| OptionalMountpoint { mountpoint }); + parse_response!(uri, result, "volume_name={}", name) + } + + async fn get_handler( + uri: Uri, + State(driver): State, + Json(Named { name }): Json, + ) -> Result> { + log_request!(uri, "volume_name={}", name); + let result = driver + .get(&name) + .await + .map(|VolumeInfo { mountpoint, status }| GetResponse { + volume: FullVolume { + name: name.clone(), + mountpoint, + status, + }, + }); + parse_response!(uri, result, "volume_name={}", name) + } + + async fn list_handler(uri: Uri, State(driver): State) -> Result { + log_request!(uri); + let result = driver.list().await.map(|volumes| ListResponse { volumes }); + parse_response!(uri, result) + } + + async fn create_handler( + uri: Uri, + State(driver): State, + Json(CreateRequest { name, opts }): Json>, + ) -> Result { + log_request!(uri, "volume_name={}, create_options={:?}", name, opts); + let result = driver.create(&name, opts).await.map(|_| Empty {}); + parse_response!(uri, result, "volume_name={}", name) + } + + async fn remove_handler( + uri: Uri, + State(driver): State, + Json(Named { name }): Json, + ) -> Result { + log_request!(uri, "volume_name={}", name); + let result = driver.remove(&name).await.map(|_| Empty {}); + parse_response!(uri, result, "volume_name={}", name) + } + + async fn mount_handler( + uri: Uri, + State(driver): State, + Json(NamedWID { name, id }): Json, + ) -> Result { + log_request!(uri, "volume_name={}; id={}", name, id); + let result = driver + .mount(&name, &id) + .await + .map(|mountpoint| Mountpoint { mountpoint }); + parse_response!(uri, result, "volume_name={}; id={}", name, id) + } + + async fn unmount_handler( + uri: Uri, + State(driver): State, + Json(NamedWID { name, id }): Json, + ) -> Result { + log_request!(uri, "volume_name={}; id={}", name, id); + let result = driver.unmount(&name, &id).await.map(|_| Empty {}); + parse_response!(uri, result, "volume_name={}; id={}", name, id) + } + + pub fn create_router(driver: D) -> Router { + Router::new() + .route("/Plugin.Activate", post(activate_handler::)) + .route( + "/VolumeDriver.Capabilities", + post(capabilities_handler::), + ) + .route("/VolumeDriver.Path", post(path_handler::)) + .route("/VolumeDriver.Get", post(get_handler::)) + .route("/VolumeDriver.List", post(list_handler::)) + .route("/VolumeDriver.Create", post(create_handler::)) + .route("/VolumeDriver.Remove", post(remove_handler::)) + .route("/VolumeDriver.Mount", post(mount_handler::)) + .route("/VolumeDriver.Unmount", post(unmount_handler::)) + .layer(middleware::from_fn(transform_headers)) + .with_state(driver) + } + + async fn transform_headers(mut request: Request, next: Next) -> Response { + let headers = request.headers_mut(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + let mut response = next.run(request).await; + let response_headers = response.headers_mut(); + response_headers.append( + CONTENT_TYPE, + HeaderValue::from_static("application/vnd.docker.plugin.v1+json"), + ); + + response + } +} + +#[cfg(test)] +mod test_mocks { + use super::router::*; + use super::*; + use axum_test::TestServer; + use std::{collections::HashMap, ops::Deref, sync::Arc}; + use tokio::sync::Mutex; + + pub const VOLUME_NAME: &str = "test_volume"; + const BASE_PATH: &str = "/plugin"; + const DEFAULT_OPTS: &str = "def"; + pub const MOUNTED_STATUS: &str = "mounted"; + pub const UNMOUNTED_STATUS: &str = "unmounted"; + pub const PATH: &str = "/VolumeDriver.Path"; + pub const GET: &str = "/VolumeDriver.Get"; + pub const LIST: &str = "/VolumeDriver.List"; + pub const CREATE: &str = "/VolumeDriver.Create"; + pub const REMOVE: &str = "/VolumeDriver.Remove"; + pub const MOUNT: &str = "/VolumeDriver.Mount"; + pub const UNMOUNT: &str = "/VolumeDriver.Unmount"; + + pub fn base_mp() -> PathBuf { + PathBuf::from(BASE_PATH).join(DEFAULT_OPTS) + } + + #[derive(Debug)] + pub struct StrError(String); + + impl std::fmt::Display for StrError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } + } + + impl std::error::Error for StrError {} + + #[derive(Clone)] + pub struct Test { + volumes: Arc>>>, + next_error: Arc>>, + } + + impl Test { + fn new() -> Self { + Self { + volumes: Arc::new(Mutex::new(HashMap::new())), + next_error: Arc::new(Mutex::new(None)), + } + } + + async fn set_error(&self, msg: &str) { + let mut next_error = self.next_error.lock().await; + *next_error = Some(msg.to_string()); + } + + async fn check_error(&self) -> Result<(), StrError> { + let mut next_error = self.next_error.lock().await; + if let Some(msg) = next_error.take() { + return Err(StrError(msg)); + } + Ok(()) + } + + pub fn into_server() -> Server { + let app = Self::new(); + let server = TestServer::new(app.clone().into_router()).unwrap(); + Server { app, server } + } + } + + #[async_trait::async_trait] + impl Driver for Test { + type Error = StrError; + type Status = String; + type Opts = String; + + async fn path(&self, name: &str) -> Result, Self::Error> { + self.check_error().await?; + let volumes = self.volumes.lock().await; + let vol = volumes.get(name); + Ok(vol.and_then(|v| v.mountpoint.clone())) + } + + async fn get(&self, name: &str) -> Result, Self::Error> { + self.check_error().await?; + let volumes = self.volumes.lock().await; + volumes + .get(name) + .cloned() + .ok_or(StrError("not found".into())) + } + + async fn list(&self) -> Result, Self::Error> { + self.check_error().await?; + let volumes = self.volumes.lock().await; + let list = volumes + .iter() + .map(|(k, v)| ItemVolume { + mountpoint: v.mountpoint.clone(), + name: k.clone(), + }) + .collect::>(); + Ok(list) + } + + async fn create(&self, name: &str, opts: Option) -> Result<(), Self::Error> { + self.check_error().await?; + let Some(opts) = opts else { + return Err(StrError("empty options".into())); + }; + let mut volumes = self.volumes.lock().await; + volumes.insert( + name.to_string(), + VolumeInfo { + mountpoint: None, + status: opts, + }, + ); + Ok(()) + } + + async fn remove(&self, name: &str) -> Result<(), Self::Error> { + self.check_error().await?; + let mut volumes = self.volumes.lock().await; + volumes.remove(name); + Ok(()) + } + + async fn mount(&self, name: &str, _id: &str) -> Result { + self.check_error().await?; + let VolumeInfo { mountpoint, status } = self.get(name).await?; + if let Some(path) = mountpoint { + return Ok(path); + } + + let mountpoint = PathBuf::from(BASE_PATH).join(status.clone()); + let mut volumes = self.volumes.lock().await; + volumes.insert( + name.to_string(), + VolumeInfo { + mountpoint: Some(mountpoint.clone()), + status: MOUNTED_STATUS.to_string(), + }, + ); + + Ok(mountpoint) + } + + async fn unmount(&self, name: &str, _id: &str) -> Result<(), Self::Error> { + self.check_error().await?; + let VolumeInfo { mountpoint, .. } = self.get(name).await?; + if mountpoint.is_some() { + let mut volumes = self.volumes.lock().await; + volumes.insert( + name.to_string(), + VolumeInfo { + mountpoint: None, + status: UNMOUNTED_STATUS.to_string(), + }, + ); + return Ok(()); + } + + Ok(()) + } + } + + pub struct Server { + app: Test, + server: TestServer, + } + + impl Deref for Server { + type Target = TestServer; + + fn deref(&self) -> &Self::Target { + &self.server + } + } + + impl Server { + pub async fn set_error(&self, msg: &str) { + self.app.set_error(msg).await; + } + } + + impl Named { + pub fn stub() -> Self { + Self { + name: VOLUME_NAME.into(), + } + } + } + + impl NamedWID { + fn stub_id(id: &str) -> Self { + Self { + name: VOLUME_NAME.to_string(), + id: id.to_string(), + } + } + + pub fn stub() -> Self { + Self::stub_id("id") + } + } + + impl ItemVolume { + fn stub() -> Self { + Self { + name: VOLUME_NAME.to_string(), + mountpoint: None, + } + } + } + + impl ListResponse { + fn new(volumes: Vec) -> Self { + Self { volumes } + } + + fn item(item: ItemVolume) -> Self { + Self::new(vec![item]) + } + + pub fn stub_item() -> Self { + Self::item(ItemVolume::stub()) + } + + pub fn empty() -> Self { + Self::new(vec![]) + } + } + + impl DriverError { + pub fn new(msg: &str) -> Self { + Self { + err: msg.to_string(), + } + } + } + + impl CreateRequest { + fn new(name: &str, opts: &str) -> Self { + Self { + name: name.to_string(), + opts: Some(opts.to_string()), + } + } + + pub fn stub() -> Self { + Self::new(VOLUME_NAME, DEFAULT_OPTS) + } + } + + impl GetResponse { + fn new(volume: FullVolume) -> Self { + Self { volume } + } + + pub fn stub() -> Self { + Self::new(FullVolume { + name: VOLUME_NAME.to_string(), + mountpoint: None, + status: DEFAULT_OPTS.to_string(), + }) + } + + pub fn stub_mount(mountpoint: Option, status: &str) -> Self { + Self::new(FullVolume { + name: VOLUME_NAME.to_string(), + mountpoint, + status: status.to_string(), + }) + } + } + + impl Mountpoint { + fn new(mountpoint: PathBuf) -> Self { + Self { mountpoint } + } + + pub fn stub() -> Self { + Self::new(base_mp()) + } + } + + impl OptionalMountpoint { + pub fn new(mountpoint: Option) -> Self { + Self { mountpoint } + } + + pub fn empty() -> Self { + Self::new(None) + } + } +} + +#[cfg(test)] +mod tests { + use super::router::*; + use super::test_mocks::*; + use super::*; + + mod first_requests { + use super::*; + + #[tokio::test] + async fn activate_plugin() { + Test::into_server() + .post("/Plugin.Activate") + .await + .assert_json(&ImplementsDriver { + implements: vec!["VolumeDriver".into()], + }); + } + + #[tokio::test] + async fn capabilities() { + Test::into_server() + .post("/VolumeDriver.Capabilities") + .await + .assert_json(&CapabilitiesResponse { + capabilities: Capabilities { + scope: Scope::Global, + }, + }); + } + + #[tokio::test] + async fn empty_list() { + Test::into_server() + .post(LIST) + .await + .assert_json(&ListResponse::empty()); + } + + #[tokio::test] + async fn list_with_error() { + let server = Test::into_server(); + server.set_error("list error").await; + server + .post(LIST) + .await + .assert_json(&DriverError::new("list error")); + } + + #[tokio::test] + async fn empty_path() { + Test::into_server() + .post(PATH) + .json(&Named::stub()) + .await + .assert_json(&OptionalMountpoint::empty()); + } + + #[tokio::test] + async fn path_with_error() { + let server = Test::into_server(); + server.set_error("path error").await; + server + .post(PATH) + .json(&Named::stub()) + .await + .assert_json(&DriverError::new("path error")); + } + + #[tokio::test] + async fn empty_get() { + Test::into_server() + .post(GET) + .json(&Named::stub()) + .await + .assert_json(&DriverError::new("not found")); + } + + #[tokio::test] + async fn get_with_error() { + let server = Test::into_server(); + server.set_error("get error").await; + server + .post(GET) + .json(&Named::stub()) + .await + .assert_json(&DriverError::new("get error")); + } + } + + #[tokio::test] + async fn failed_created_volume_with_empty_opts() { + Test::into_server() + .post(CREATE) + .json(&CreateRequest:: { + name: VOLUME_NAME.into(), + opts: None, + }) + .await + .assert_json(&DriverError::new("empty options")); + } + + #[tokio::test] + async fn failed_created_volume() { + let server = Test::into_server(); + + server.set_error("creating error").await; + server + .post(CREATE) + .json(&CreateRequest::stub()) + .await + .assert_json(&DriverError::new("creating error")); + } + + #[tokio::test] + async fn successfully_created_volume() { + let server = Test::into_server(); + server + .post(CREATE) + .json(&CreateRequest::stub()) + .await + .assert_json(&Empty {}); + + server + .post(LIST) + .await + .assert_json(&ListResponse::stub_item()); + server + .post(GET) + .json(&Named::stub()) + .await + .assert_json(&GetResponse::stub()); + server + .post(PATH) + .json(&Named::stub()) + .await + .assert_json(&OptionalMountpoint::empty()); + } + + #[tokio::test] + async fn failed_remove_volume() { + let server = Test::into_server(); + server.post(CREATE).json(&CreateRequest::stub()).await; + + server.set_error("remove error").await; + server + .post(REMOVE) + .json(&Named::stub()) + .await + .assert_json(&DriverError::new("remove error")); + + server + .post(LIST) + .await + .assert_json(&ListResponse::stub_item()); + } + + #[tokio::test] + async fn successfully_remove_volume() { + let server = Test::into_server(); + server.post(CREATE).json(&CreateRequest::stub()).await; + + server + .post(REMOVE) + .json(&Named::stub()) + .await + .assert_json(&Empty {}); + + server.post(LIST).await.assert_json(&ListResponse::empty()); + } + + #[tokio::test] + async fn failed_non_existent_mount() { + Test::into_server() + .post(MOUNT) + .json(&NamedWID::stub()) + .await + .assert_json(&DriverError::new("not found")); + } + + #[tokio::test] + async fn failed_mount() { + let server = Test::into_server(); + server.set_error("mount error").await; + server + .post(MOUNT) + .json(&NamedWID::stub()) + .await + .assert_json(&DriverError::new("mount error")); + } + + #[tokio::test] + async fn failed_non_existent_unmount() { + Test::into_server() + .post(UNMOUNT) + .json(&NamedWID::stub()) + .await + .assert_json(&DriverError::new("not found")); + } + #[tokio::test] + async fn failed_unmount() { + let server = Test::into_server(); + server.set_error("unmount error").await; + server + .post(UNMOUNT) + .json(&NamedWID::stub()) + .await + .assert_json(&DriverError::new("unmount error")); + } + + #[tokio::test] + async fn successfully_mount() { + let server = Test::into_server(); + server.post(CREATE).json(&CreateRequest::stub()).await; + + server + .post(MOUNT) + .json(&NamedWID::stub()) + .await + .assert_json(&Mountpoint::stub()); + server + .post(GET) + .json(&Named::stub()) + .await + .assert_json(&GetResponse::stub_mount(Some(base_mp()), MOUNTED_STATUS)); + server + .post(PATH) + .json(&Named::stub()) + .await + .assert_json(&OptionalMountpoint::new(Some(base_mp()))); + } + + #[tokio::test] + async fn successfully_unmount() { + let server = Test::into_server(); + server.post(CREATE).json(&CreateRequest::stub()).await; + + server + .post(MOUNT) + .json(&NamedWID::stub()) + .await + .assert_json(&Mountpoint::stub()); + server + .post(UNMOUNT) + .json(&NamedWID::stub()) + .await + .assert_json(&Empty {}); + + server + .post(GET) + .json(&Named::stub()) + .await + .assert_json(&GetResponse::stub_mount(None, UNMOUNTED_STATUS)); + server + .post(PATH) + .json(&Named::stub()) + .await + .assert_json(&OptionalMountpoint::new(None)); + } +} diff --git a/src/main.rs b/src/main.rs index 41367a7..7dd45d4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ -mod app; mod domains; +mod driver; mod git; mod macros; +mod plugin; mod result; mod services; mod split_tracing; -mod state; use std::{fmt::Debug, os::unix::fs::FileTypeExt, path::PathBuf}; @@ -19,8 +19,9 @@ use tracing::{ }; use crate::{ + driver::Driver, + plugin::Plugin, result::{Error, ErrorIoExt, Result}, - state::GitvolState, }; #[tokio::main] @@ -38,12 +39,11 @@ async fn main() -> Result<()> { .map_io_error(&settings.socket)?; } - let state = GitvolState::new(settings.mount_path); - let app = app::create(state); + let plugin = Plugin::new(&settings.mount_path).into_router(); let listener = UnixListener::bind(&settings.socket).map_io_error(&settings.socket)?; info!("listening on {:?}", listener.local_addr().unwrap()); - serve(listener, app.into_make_service()) + serve(listener, plugin) .await .map_io_error(&format!("serve: {:?}", settings.socket))?; diff --git a/src/plugin.rs b/src/plugin.rs new file mode 100644 index 0000000..23ea5f7 --- /dev/null +++ b/src/plugin.rs @@ -0,0 +1,733 @@ +use crate::{git, result::ErrorIoExt}; +use serde::Serialize; +use std::path::{Path, PathBuf}; +use tokio::fs; + +use crate::{ + domains::{repo::RawRepo, volume::Status as VolumeStatus}, + driver::{Driver, ItemVolume, VolumeInfo}, + services::volumes::{Error as VolumesError, Volumes}, +}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Volumes(#[from] VolumesError), + + #[error(transparent)] + App(#[from] crate::result::Error), +} + +#[cfg_attr(test, derive(Debug, PartialEq, Clone))] +#[derive(Serialize)] +pub struct Status { + pub status: VolumeStatus, +} + +impl From for Status { + fn from(status: VolumeStatus) -> Self { + Self { status } + } +} + +#[derive(Clone)] +pub struct Plugin { + base_path: PathBuf, + volumes: Volumes, +} + +impl Plugin { + pub fn new(base_path: &Path) -> Self { + Self { + base_path: base_path.to_path_buf(), + volumes: Volumes::new(), + } + } +} + +#[async_trait::async_trait] +impl Driver for Plugin { + type Error = Error; + type Status = Status; + type Opts = RawRepo; + + async fn path(&self, name: &str) -> Result, Self::Error> { + let Some(volume) = self.volumes.read(name).await else { + eprintln!("WARN: Volume named {} not found", name); + return Ok(None); + }; + + Ok(volume.path.clone()) + } + + async fn get(&self, name: &str) -> Result, Self::Error> { + let volume = self.volumes.try_read(name).await?; + Ok(VolumeInfo { + mountpoint: volume.path.clone(), + status: Status { + status: volume.status.clone(), + }, + }) + } + + async fn list(&self) -> Result, Self::Error> { + let list = self.volumes.read_all().await; + Ok(list + .iter() + .map(|v| ItemVolume { + name: v.name.clone(), + mountpoint: v.path.clone(), + }) + .collect()) + } + + async fn create(&self, name: &str, opts: Option) -> Result<(), Self::Error> { + self.volumes.create(name, opts).await?; + Ok(()) + } + + async fn remove(&self, name: &str) -> Result<(), Self::Error> { + let Some(volume) = self.volumes.remove(name).await else { + eprintln!("WARN: Volume named {} not found", name); + return Ok(()); + }; + + remove_dir_if_exists(volume.path.clone()).await?; + + Ok(()) + } + async fn mount(&self, name: &str, id: &str) -> Result { + let mut volume = self.volumes.try_write(name).await?; + + if let Some(path) = volume.path.clone() { + println!("Repository {} already cloned.", name); + if volume.repo.refetch { + println!("Attempting to refetch repository {} for id {}.", name, id); + git::refetch(&path).await?; + } + volume.containers.insert(id.to_string()); + return Ok(path); + } + + let path = volume.create_path_from(&self.base_path); + if path.exists() { + println!("Repository directory {:?} already exists. Remooving", &path); + fs::remove_dir_all(&path).await.map_io_error(&path)?; + } + git::clone(&path, &volume.repo).await?; + + volume.containers.insert(id.to_string()); + volume.status = VolumeStatus::Clonned; + + println!("Volume {} mounted successfully.", name); + Ok(path) + } + + async fn unmount(&self, name: &str, id: &str) -> Result<(), Self::Error> { + let Some(mut volume) = self.volumes.write(name).await else { + eprintln!("WARN: Volume named {} not found", name); + return Ok(()); + }; + + volume.containers.remove(id); + + if !volume.containers.is_empty() { + println!( + "Volume {} still in use by containers. container_count={}", + name, + volume.containers.len(), + ); + return Ok(()); + } + + volume.status = VolumeStatus::Cleared; + remove_dir_if_exists(volume.path.clone()).await?; + volume.path = None; + + println!("Volume {} unmounted successfully.", name); + Ok(()) + } +} + +async fn remove_dir_if_exists(path: Option) -> crate::result::Result<()> { + if let Some(path) = path + && path.exists() + { + println!("Attempting to remove directory {:?}", &path); + fs::remove_dir_all(&path).await.map_io_error(&path)?; + } + + Ok(()) +} + +#[cfg(test)] +mod test_mocks { + use super::*; + use crate::git::test::TestRepo; + use std::ops::Deref; + use tempfile::{Builder as TempBuilder, TempDir}; + + pub const VOLUME_NAME: &str = "test_volume"; + + pub struct TempPlugin { + plugin: Plugin, + temp: TempDir, + } + + impl Deref for TempPlugin { + type Target = Plugin; + + fn deref(&self) -> &Self::Target { + &self.plugin + } + } + + #[allow(unused)] + pub struct TempWithTestRepoPlugin { + plugin: Plugin, + temp: TempDir, + pub test_repo: TestRepo, + } + + impl Deref for TempWithTestRepoPlugin { + type Target = Plugin; + + fn deref(&self) -> &Self::Target { + &self.plugin + } + } + + impl Plugin { + pub fn stub() -> Self { + Self::new(&std::env::temp_dir()) + } + + pub fn temp() -> TempPlugin { + let temp = TempBuilder::new().prefix("temp-gitvol-").tempdir().unwrap(); + let plugin = Self::new(&temp.path()); + TempPlugin { plugin, temp } + } + + pub async fn with_volume(self, volume_name: &str, raw_repo: RawRepo) -> Self { + self.create(volume_name, Some(raw_repo)).await.unwrap(); + self + } + + pub async fn with_stub_volume(self) -> Self { + self.with_volume(VOLUME_NAME, RawRepo::stub()).await + } + + pub async fn test_is_empty_list(&self) -> &Self { + let list = self.list().await.unwrap(); + assert_eq!(list.len(), 0); + self + } + + pub async fn test_in_list(&self, input_list: Vec) -> &Self { + let list = self.list().await.unwrap(); + + assert_eq!(list.len(), input_list.len()); + + for item in input_list { + let list_item = list.iter().find(|i| i.name == item.name); + assert!( + list_item.is_some(), + "The volume named {} was not found in the list.", + item.name + ); + + let mountpoint = list_item.and_then(|i| i.mountpoint.clone()); + assert_eq!(item.mountpoint, mountpoint); + } + + self + } + + pub async fn test_in_list_by_names(&self, input_list: Vec<&str>) -> &Self { + self.test_in_list( + input_list + .into_iter() + .map(|name| ItemVolume { + name: name.to_string(), + mountpoint: None, + }) + .collect(), + ) + .await + } + + pub async fn test_path_is(&self, volume_name: &str, path: Option) -> &Self { + let path_result = self.path(volume_name).await.unwrap(); + assert_eq!(path_result, path); + self + } + + pub async fn test_stub_path_is(&self, path: Option) -> &Self { + self.test_path_is(VOLUME_NAME, path).await + } + + pub async fn test_get_volume(&self, volume_name: &str, info: VolumeInfo) -> &Self { + let volume = self.get(volume_name).await.unwrap(); + assert_eq!(volume, info); + self + } + + pub async fn test_get_stub_volume(&self, info: VolumeInfo) -> &Self { + self.test_get_volume(VOLUME_NAME, info).await + } + } + + impl TempPlugin { + pub async fn with_temp_volume( + self, + volume_name: &str, + raw_repo: Option, + ) -> TempWithTestRepoPlugin { + let test_repo = TestRepo::get_or_create().await.unwrap(); + let repo = raw_repo.unwrap_or_default(); + let plugin = self + .plugin + .with_volume( + volume_name, + RawRepo { + url: Some(test_repo.file.clone()), + ..repo + }, + ) + .await; + + TempWithTestRepoPlugin { + plugin, + test_repo, + temp: self.temp, + } + } + + pub async fn with_stub_temp_volume(self) -> TempWithTestRepoPlugin { + self.with_temp_volume(VOLUME_NAME, None).await + } + } +} + +#[cfg(test)] +mod test { + use super::test_mocks::*; + use super::*; + use rstest::rstest; + use std::ops::Deref; + + use crate::git::test::{TestRepo, is_git_dir}; + + #[tokio::test] + async fn list_empty_initial() { + Plugin::stub().test_is_empty_list().await; + } + + #[tokio::test] + async fn path_nonexistent_returns_none() { + Plugin::stub().test_stub_path_is(None).await; + } + + #[tokio::test] + async fn get_nonexistent_returns_error() { + let plugin = Plugin::stub(); + + let result = plugin.get(VOLUME_NAME).await; + assert!( + result.is_err(), + "Retrieving a non-existent volume should result in an error." + ); + + let error = result.unwrap_err(); + assert!(matches!(error, Error::Volumes(VolumesError::NonExists(_)))); + } + + #[rstest] + #[case(RawRepo::stub())] + #[case(RawRepo { branch: Some("some_branch".into()), ..RawRepo::stub() })] + #[case(RawRepo { tag: Some("som-tag".into()), ..RawRepo::stub() })] + #[case(RawRepo { refetch: Some("true".into()), ..RawRepo::stub() })] + #[tokio::test] + async fn create_success_new_volume(#[case] raw_repo: RawRepo) { + Plugin::stub() + .with_volume(VOLUME_NAME, raw_repo) + .await + .test_in_list_by_names(vec![VOLUME_NAME]) + .await + .test_stub_path_is(None) + .await + .test_get_stub_volume(VolumeInfo { + status: VolumeStatus::Created.into(), + mountpoint: None, + }) + .await; + } + + #[tokio::test] + async fn create_duplicate_name_error() { + let plugin = Plugin::stub().with_stub_volume().await; + + let second_creating = plugin.create(VOLUME_NAME, Some(RawRepo::stub())).await; + assert!( + second_creating.is_err(), + "Recreating the volume (with the same name) should result in an error." + ); + + let error = second_creating.unwrap_err(); + assert!(matches!( + error, + Error::Volumes(VolumesError::AlreadyExists(_)) + )); + + plugin.test_in_list_by_names(vec![VOLUME_NAME]).await; + } + + #[rstest] + #[case("", Some(RawRepo::stub()))] + #[case(" ", Some(RawRepo::stub()))] + #[case(VOLUME_NAME, None)] + #[case(VOLUME_NAME, Some(RawRepo::default()))] + #[case(VOLUME_NAME, Some(RawRepo { branch: Some("some_branch".into()), tag: Some("some_tag".into()), ..RawRepo::stub()}))] + #[case(VOLUME_NAME, Some(RawRepo { url: Some("ftp://host/path-to-git-repo".into()), ..RawRepo::default()}))] + #[tokio::test] + async fn create_invalid_params_error( + #[case] volume_name: &str, + #[case] raw_repo: Option, + ) { + let plugin = Plugin::stub(); + + let result = plugin.create(volume_name, raw_repo.clone()).await; + assert!( + result.is_err(), + "Creating a volume with incorrect parameters should result in an error. name={:?}; options={:?}", + volume_name, + raw_repo + ); + + let error = result.unwrap_err(); + assert!(matches!(error, Error::Volumes(_))); + plugin.test_is_empty_list().await; + } + + #[tokio::test] + async fn list_multiple_volumes() { + Plugin::stub() + .with_stub_volume() + .await + .with_volume("other_volume", RawRepo::stub()) + .await + .test_in_list_by_names(vec![VOLUME_NAME, "other_volume"]) + .await; + } + + #[tokio::test] + async fn path_after_mount_returns_some() { + let plugin = Plugin::temp().with_stub_temp_volume().await; + + let mountpoint = plugin.mount(VOLUME_NAME, "id-123").await.unwrap(); + + plugin.test_stub_path_is(Some(mountpoint)).await; + } + + #[tokio::test] + async fn get_created_unmounted_status() { + let plugin = Plugin::temp().with_stub_temp_volume().await; + + let created = plugin.get(VOLUME_NAME).await.unwrap(); + assert_eq!( + created.status, + Status { + status: VolumeStatus::Created + } + ); + + plugin.mount(VOLUME_NAME, "id-123").await.unwrap(); + plugin.unmount(VOLUME_NAME, "id-123").await.unwrap(); + + let cleared = plugin.get(VOLUME_NAME).await.unwrap(); + assert_eq!( + cleared.status, + Status { + status: VolumeStatus::Cleared + } + ); + } + + #[tokio::test] + async fn get_after_mount_status_clonned() { + let plugin = Plugin::temp().with_stub_temp_volume().await; + + let mountpoint = plugin.mount(VOLUME_NAME, "id-123").await.unwrap(); + + assert!(mountpoint.exists()); + plugin + .test_get_stub_volume(VolumeInfo { + mountpoint: Some(mountpoint), + status: Status { + status: VolumeStatus::Clonned, + }, + }) + .await; + } + + #[tokio::test] + async fn remove_nonexistent_by_empty_ok() { + let plugin = Plugin::stub(); + let result = plugin.remove("other_volume").await; + assert!(result.is_ok()); + + plugin.test_is_empty_list().await; + } + + #[tokio::test] + async fn remove_nonexistent_with_other_volumes_ok() { + let plugin = Plugin::stub().with_stub_volume().await; + + let result = plugin.remove("other_volume").await; + assert!(result.is_ok()); + + plugin.test_in_list_by_names(vec![VOLUME_NAME]).await; + } + + #[tokio::test] + async fn remove_existing_unmounted_ok() { + let plugin = Plugin::stub().with_stub_volume().await; + + let result = plugin.remove(VOLUME_NAME).await; + assert!(result.is_ok()); + + plugin + .test_is_empty_list() + .await + .test_stub_path_is(None) + .await; + } + + #[tokio::test] + async fn remove_existing_mounted_ok() { + let plugin = Plugin::temp().with_stub_temp_volume().await; + + let mountpoint = plugin.mount(VOLUME_NAME, "id").await.unwrap(); + let result = plugin.remove(VOLUME_NAME).await; + assert!(result.is_ok()); + + plugin.test_is_empty_list().await; + assert!(!mountpoint.exists()); + } + + #[tokio::test] + async fn mount_first_time_clones_repo() { + let plugin = Plugin::temp().with_stub_temp_volume().await; + + let mountpoint = plugin.mount(VOLUME_NAME, "id").await.unwrap(); + + assert!(!is_git_dir(&mountpoint)); + assert!(plugin.test_repo.is_master(&mountpoint)); + } + + #[tokio::test] + async fn mount_when_already_mounted_no_clone() { + let plugin = Plugin::temp().with_stub_temp_volume().await; + + let first_mountpoint = plugin.mount(VOLUME_NAME, "id-1").await.unwrap(); + let second_mountpoint = plugin.mount(VOLUME_NAME, "id-2").await.unwrap(); + + assert_eq!(first_mountpoint, second_mountpoint); + } + + #[tokio::test] + async fn mount_with_branch() { + let plugin = Plugin::temp() + .with_temp_volume( + VOLUME_NAME, + Some(RawRepo { + branch: Some("develop".into()), + ..Default::default() + }), + ) + .await; + + let mountpoint = plugin.mount(VOLUME_NAME, "id").await.unwrap(); + assert!(plugin.test_repo.is_develop(&mountpoint)); + } + + #[tokio::test] + async fn mount_with_tag() { + let plugin = Plugin::temp() + .with_temp_volume( + VOLUME_NAME, + Some(RawRepo { + tag: Some("v1".into()), + ..Default::default() + }), + ) + .await; + + let mountpoint = plugin.mount(VOLUME_NAME, "id").await.unwrap(); + assert!(plugin.test_repo.is_tag(&mountpoint)); + } + + #[tokio::test] + async fn mount_with_refetch() { + let branch_name = "some_branch"; + let plugin = Plugin::temp() + .with_temp_volume( + VOLUME_NAME, + Some(RawRepo { + refetch: Some("true".into()), + branch: Some(branch_name.into()), + ..Default::default() + }), + ) + .await; + + plugin + .test_repo + .setup_temp_branch(branch_name) + .await + .unwrap(); + let mountpoint = plugin.mount(VOLUME_NAME, "id-1").await.unwrap(); + assert!(is_git_dir(&mountpoint)); + assert!( + !TestRepo::is_temp_changed(&mountpoint, branch_name) + .await + .unwrap() + ); + + plugin + .test_repo + .change_temp_branch(branch_name) + .await + .unwrap(); + plugin.mount(VOLUME_NAME, "id-2").await.unwrap(); + assert!( + TestRepo::is_temp_changed(&mountpoint, branch_name) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn mount_clone_failure_on_bad_url() { + let plugin = Plugin::stub().with_volume( + VOLUME_NAME, + RawRepo { + url: Some("http://host/path-to-git-repo".into()), + ..Default::default() + }, + ); + + let result = plugin.await.mount(VOLUME_NAME, "id").await; + assert!( + result.is_err(), + " Mounting a non-existent repository should result in an error." + ); + } + + #[tokio::test] + async fn unmount_nonexistent_ok() { + let plugin = Plugin::stub(); + + let result = plugin.unmount("name", "id").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn unmount_with_multiple_containers_keeps_dir() { + let plugin = Plugin::temp().with_stub_temp_volume().await; + + let mountpoint = plugin.mount(VOLUME_NAME, "id-1").await.unwrap(); + plugin.mount(VOLUME_NAME, "id-2").await.unwrap(); + + plugin.unmount(VOLUME_NAME, "id-1").await.unwrap(); + plugin + .test_in_list(vec![ItemVolume { + name: VOLUME_NAME.into(), + mountpoint: Some(mountpoint.clone()), + }]) + .await + .test_get_stub_volume(VolumeInfo { + mountpoint: Some(mountpoint.clone()), + status: VolumeStatus::Clonned.into(), + }) + .await; + assert!(mountpoint.exists()); + } + + #[tokio::test] + async fn unmount_last_container_removes_dir_and_clears() { + let plugin = Plugin::temp().with_stub_temp_volume().await; + + let mountpoint = plugin.mount(VOLUME_NAME, "id-1").await.unwrap(); + plugin.mount(VOLUME_NAME, "id-2").await.unwrap(); + + plugin.unmount(VOLUME_NAME, "id-1").await.unwrap(); + plugin.unmount(VOLUME_NAME, "id-2").await.unwrap(); + + plugin + .test_in_list(vec![ItemVolume { + name: VOLUME_NAME.into(), + mountpoint: None, + }]) + .await + .test_get_stub_volume(VolumeInfo { + mountpoint: None, + status: VolumeStatus::Cleared.into(), + }) + .await; + + assert!(!mountpoint.exists()); + } + + #[tokio::test] + async fn unmount_unknown_container_id_no_panic() { + let plugin = Plugin::temp().with_stub_temp_volume().await; + + let mountpoint = plugin.mount(VOLUME_NAME, "id-1").await.unwrap(); + let result = plugin.unmount(VOLUME_NAME, "ghost-id").await; + assert!(result.is_ok()); + + assert!(mountpoint.exists()); + } + + async fn full_check>( + plugin: &P, + mountpoint: Option, + status: VolumeStatus, + ) { + plugin + .test_in_list(vec![ItemVolume { + name: VOLUME_NAME.into(), + mountpoint: mountpoint.clone(), + }]) + .await + .test_stub_path_is(mountpoint.clone()) + .await + .test_get_stub_volume(VolumeInfo { + mountpoint: mountpoint.clone(), + status: status.into(), + }) + .await; + } + + #[tokio::test] + async fn happy_flow_create_mount_get_path_unmount_remove() { + let plugin = Plugin::temp().with_stub_temp_volume().await; + full_check(&plugin, None, VolumeStatus::Created).await; + + let mountpoint = plugin.mount(VOLUME_NAME, "id").await.unwrap(); + full_check(&plugin, Some(mountpoint.clone()), VolumeStatus::Clonned).await; + assert!(mountpoint.exists()); + + plugin.unmount(VOLUME_NAME, "id").await.unwrap(); + full_check(&plugin, None, VolumeStatus::Cleared).await; + assert!(!mountpoint.exists()); + + plugin.remove(VOLUME_NAME).await.unwrap(); + plugin + .test_is_empty_list() + .await + .test_stub_path_is(None) + .await; + } +}