commit fd80fbab7eb45d60b0fe18146bba8b8d0d184074 Author: atagen Date: Mon Mar 16 22:23:10 2026 +1100 init diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..58099af --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3427 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[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 = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "axum-macros", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b1866ecef4f2d06a0bb77880015fdf2b89e25a1c2e5addacb87e459c86dc67e" +dependencies = [ + "base64", + "blowfish", + "getrandom 0.2.17", + "subtle", + "zeroize", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.5.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "jupiter-api-types" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "jupiter-cache" +version = "0.1.0" +dependencies = [ + "axum", + "base64", + "jupiter-api-types", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "jupiter-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "jupiter-api-types", + "reqwest", + "serde", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "jupiter-db" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "jupiter-api-types", + "serde", + "serde_json", + "sqlx", + "thiserror", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "jupiter-forge" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "hex", + "hmac", + "jupiter-api-types", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "jupiter-scheduler" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "jupiter-api-types", + "jupiter-db", + "jupiter-forge", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "jupiter-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "axum-extra", + "bcrypt", + "chrono", + "futures", + "jsonwebtoken", + "jupiter-api-types", + "jupiter-cache", + "jupiter-db", + "jupiter-forge", + "jupiter-scheduler", + "serde", + "serde_json", + "tokio", + "toml", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall 0.7.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[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-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +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 = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[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 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dafd85c832c1b68bbb4ec0c72c7f6f4fc5179627d2bc7c26b30e4c0cc11e76cc" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cb7e4e8436d9db52fbd6625dbf2f45243ab84994a72882ec8227b99e72b439a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f370f22 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,74 @@ +[workspace] +resolver = "2" +members = [ + "crates/jupiter-api-types", + "crates/jupiter-server", + "crates/jupiter-db", + "crates/jupiter-forge", + "crates/jupiter-scheduler", + "crates/jupiter-cache", + "crates/jupiter-cli", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[workspace.dependencies] +# Internal crates +jupiter-api-types = { path = "crates/jupiter-api-types" } +jupiter-db = { path = "crates/jupiter-db" } +jupiter-forge = { path = "crates/jupiter-forge" } +jupiter-scheduler = { path = "crates/jupiter-scheduler" } +jupiter-cache = { path = "crates/jupiter-cache" } + +# Async +tokio = { version = "1", features = ["full"] } + +# Web +axum = { version = "0.8", features = ["ws", "macros"] } +axum-extra = { version = "0.10", features = ["typed-header"] } +tower = { version = "0.5" } +tower-http = { version = "0.6", features = ["cors", "trace"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" + +# Database +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres", "uuid", "chrono", "json"] } + +# Auth +jsonwebtoken = "9" +bcrypt = "0.16" +uuid = { version = "1", features = ["v4", "serde"] } + +# Crypto +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" + +# HTTP client +reqwest = { version = "0.12", features = ["json"] } + +# Error handling +thiserror = "2" +anyhow = "1" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +# CLI +clap = { version = "4", features = ["derive", "env"] } + +# Time +chrono = { version = "0.4", features = ["serde"] } + +# Misc +base64 = "0.22" +futures = "0.3" +async-trait = "0.1" +rand = "0.8" diff --git a/README.md b/README.md new file mode 100644 index 0000000..f188f42 --- /dev/null +++ b/README.md @@ -0,0 +1,187 @@ +# Jupiter + +A self-hosted, wire-compatible replacement for [hercules-ci.com](https://hercules-ci.com). + +Jupiter lets you run your own Hercules CI server. Unmodified `hercules-ci-agent` binaries connect to Jupiter instead of the official cloud service, giving you full control over your CI infrastructure while keeping the same agent tooling and Nix integration. + +## Features + +- **Wire-compatible** with the real Hercules CI agent protocol (WebSocket + JSON, matching Haskell `aeson` serialization) +- **Full CI pipeline**: evaluation, building, and effects (deployments, notifications, etc.) +- **Forge integrations**: GitHub (App-based), Gitea / Forgejo, and Radicle +- **Built-in Nix binary cache** compatible with `cache.nixos.org` protocol +- **Admin CLI** (`jupiter-ctl`) for managing accounts, projects, agents, jobs, and tokens +- **SQLite by default**, with a trait-based storage abstraction designed for adding PostgreSQL + +## Architecture + +``` +Forge webhook ──> /webhooks/{github,gitea} ──> SchedulerEvent::ForgeEvent + │ + v +CLI / UI ──> REST API (/api/v1/...) SchedulerEngine + │ + dispatches tasks via AgentHub + │ + v +hercules-ci-agent <── WebSocket (/api/v1/agent/socket) +``` + +The server is built with [Axum](https://docs.rs/axum) and [Tokio](https://tokio.rs). The scheduler runs as a single background task receiving events over a bounded `mpsc` channel. Agents connect via WebSocket and are dispatched evaluation, build, and effect tasks. + +### Job pipeline + +``` +Pending → Evaluating → Building → RunningEffects → Succeeded / Failed +``` + +Builds are deduplicated by derivation store path across jobs. Effects on the same (project, ref) pair are serialized via sequence numbers to prevent concurrent deploys. If an agent disconnects, its in-flight tasks are re-queued to `Pending`. + +### Crate structure + +| Crate | Purpose | +|---|---| +| `jupiter-server` | Axum HTTP/WebSocket server, route handlers, auth, config | +| `jupiter-api-types` | Wire-compatible type definitions shared across all crates | +| `jupiter-db` | `StorageBackend` trait and SQLite implementation (sqlx) | +| `jupiter-scheduler` | Event-driven scheduler engine driving the job pipeline | +| `jupiter-forge` | `ForgeProvider` trait with GitHub, Gitea, and Radicle backends | +| `jupiter-cache` | Optional built-in Nix binary cache (narinfo + NAR storage) | +| `jupiter-cli` | `jupiter-ctl` admin CLI | + +## Quick start + +### Prerequisites + +- Rust 1.75+ (2021 edition) +- A `hercules-ci-agent` binary (the standard one from Nixpkgs) + +### Build + +```bash +cargo build --release +``` + +This produces two binaries: +- `target/release/jupiter-server` -- the CI server +- `target/release/jupiter-ctl` -- the admin CLI + +### Run + +```bash +# Start with built-in defaults (localhost:3000, SQLite at ./jupiter.db) +./target/release/jupiter-server + +# Or with a custom config file +./target/release/jupiter-server /path/to/jupiter.toml +``` + +On first run without a config file, Jupiter uses sensible development defaults and creates the SQLite database automatically. + +### Set up an account and agent token + +```bash +# Create an account +jupiter-ctl account create myorg + +# Create a cluster join token (shown only once) +jupiter-ctl token create --account-id --name "my-agent" +``` + +Put the token in your `hercules-ci-agent` configuration and point the agent at your Jupiter server. + +## Configuration + +Jupiter is configured via a TOML file. All fields use `camelCase` to match the Hercules CI convention. + +```toml +listen = "0.0.0.0:3000" +baseUrl = "https://ci.example.com" +jwtPrivateKey = "change-me-in-production" + +[database] +type = "sqlite" +path = "/var/lib/jupiter/jupiter.db" + +# GitHub App integration +[[forges]] +type = "GitHub" +appId = "12345" +privateKeyPath = "/etc/jupiter/github-app.pem" +webhookSecret = "your-webhook-secret" + +# Gitea / Forgejo integration +[[forges]] +type = "Gitea" +baseUrl = "https://gitea.example.com" +apiToken = "your-gitea-token" +webhookSecret = "your-webhook-secret" + +# Optional built-in Nix binary cache +[binaryCache] +storage = "local" +path = "/var/cache/jupiter" +maxSizeGb = 50 +``` + +### Environment variables + +| Variable | Purpose | +|---|---| +| `RUST_LOG` | Tracing filter (default: `jupiter=info`) | +| `JUPITER_URL` | Server URL for `jupiter-ctl` (default: `http://localhost:3000`) | +| `JUPITER_TOKEN` | Bearer token for `jupiter-ctl` | + +## CLI reference + +`jupiter-ctl` provides administrative access to every server resource: + +``` +jupiter-ctl health # Server liveness check +jupiter-ctl account list # List accounts +jupiter-ctl account create # Create account +jupiter-ctl agent list # List connected agents +jupiter-ctl project list # List projects +jupiter-ctl project create --account-id ... --repo-id ... --name ... +jupiter-ctl job list --project-id # List jobs +jupiter-ctl job rerun # Re-run a job +jupiter-ctl job cancel # Cancel a running job +jupiter-ctl token create --account-id ... --name ... # New join token +jupiter-ctl token revoke # Revoke a join token +jupiter-ctl state list --project-id # List state files +jupiter-ctl state get --project-id ... --name ... [--output file] +jupiter-ctl state put --project-id ... --name ... --input file +``` + +## API + +The REST API is served under `/api/v1/`. Key endpoint groups: + +| Path | Description | +|---|---| +| `GET /api/v1/health` | Health check | +| `/api/v1/accounts` | Account CRUD | +| `/api/v1/agents` | Agent listing | +| `/api/v1/projects` | Project CRUD, enable/disable | +| `/api/v1/projects/{id}/jobs` | Jobs for a project | +| `/api/v1/jobs/{id}` | Job details, rerun, cancel | +| `/api/v1/jobs/{id}/builds` | Builds for a job | +| `/api/v1/jobs/{id}/effects` | Effects for a job | +| `/api/v1/projects/{id}/state/{name}/data` | State file upload/download | +| `/api/v1/agent/socket` | Agent WebSocket endpoint | +| `POST /webhooks/github` | GitHub webhook receiver | +| `POST /webhooks/gitea` | Gitea webhook receiver | +| `POST /auth/token` | JWT token issuance | + +When the binary cache is enabled, the Nix cache protocol is also served: + +| Path | Description | +|---|---| +| `GET /nix-cache-info` | Cache metadata | +| `GET /{hash}.narinfo` | NARInfo lookup | +| `PUT /{hash}.narinfo` | NARInfo upload | +| `GET /nar/{file}` | NAR archive download | + +## License + +Apache-2.0 diff --git a/crates/jupiter-api-types/Cargo.toml b/crates/jupiter-api-types/Cargo.toml new file mode 100644 index 0000000..34314e8 --- /dev/null +++ b/crates/jupiter-api-types/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "jupiter-api-types" +version.workspace = true +edition.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } diff --git a/crates/jupiter-api-types/src/lib.rs b/crates/jupiter-api-types/src/lib.rs new file mode 100644 index 0000000..085792c --- /dev/null +++ b/crates/jupiter-api-types/src/lib.rs @@ -0,0 +1,1793 @@ +//! # jupiter-api-types -- Wire-compatible type definitions for the Hercules CI agent protocol +//! +//! Jupiter is a self-hosted, wire-compatible replacement for the hercules-ci.com SaaS. +//! Real, unmodified `hercules-ci-agent` binaries connect to a Jupiter server instead of +//! the official Hercules CI cloud service. To make this work, every type in this crate +//! **must** serialize to the exact same JSON that the real Hercules CI API (written in +//! Haskell with `aeson`) produces and expects. +//! +//! ## Serialization contract +//! +//! All structs use `#[serde(rename_all = "camelCase")]` to match the default field naming +//! convention of Haskell's `aeson` library. Tagged enums use `#[serde(tag = "tag", content = +//! "contents")]` to match `aeson`'s `TaggedObject` encoding. Deviating from these +//! conventions will break compatibility with the real hercules-ci-agent. +//! +//! ## Architecture overview +//! +//! The types here are organized into layers that mirror the Hercules CI domain model: +//! +//! 1. **Generic wrappers** -- [`Id`], [`Name`], [`Sensitive`] provide type safety +//! and security for identifiers, names, and secrets without changing wire format. +//! 2. **Account & auth** -- [`Account`], [`ClusterJoinToken`], authentication requests and +//! responses. +//! 3. **Forge & repo** -- [`ForgeType`], [`Repo`], [`Project`] represent the source code +//! hosting layer (GitHub, Gitea, Radicle). +//! 4. **Agent connection** -- [`AgentHello`], [`AgentSession`], [`ServiceInfo`] handle the +//! initial WebSocket handshake when a hercules-ci-agent connects. +//! 5. **WebSocket framing** -- [`Frame`] is the low-level envelope that wraps every message +//! on the agent WebSocket. +//! 6. **Task dispatch** -- [`EvaluateTask`], [`BuildTask`], [`EffectTask`] are the three +//! work units the server dispatches to agents via [`ServerMessage`]. +//! 7. **Agent responses** -- [`AgentMessage`] variants report evaluation attributes, build +//! results, effect outcomes, and log entries back to the server. +//! 8. **Job pipeline** -- [`Job`], [`JobStatus`] model the high-level lifecycle: +//! `ForgeEvent -> Job (Pending) -> Evaluating -> Building -> RunningEffects -> Succeeded | Failed`. +//! 9. **Forge events** -- [`ForgeEvent`] is the normalized webhook payload (push, PR, patch) +//! that triggers new jobs. +//! 10. **Config** -- [`ServerConfig`] and friends define the Jupiter server's own +//! configuration (database, forges, binary cache). +//! +//! ## Crate consumers +//! +//! This crate is depended on by both the Jupiter server and any tooling (CLI, dashboard) +//! that needs to speak the Hercules CI protocol. It has no runtime behavior -- it is purely +//! type definitions and serde implementations. + +use std::fmt; +use std::marker::PhantomData; +use std::ops::Deref; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use uuid::Uuid; + +// =========================================================================== +// Generic Wrappers +// =========================================================================== + +/// A phantom-typed UUID identifier that prevents mixing up IDs for different entity types. +/// +/// On the wire this serializes as a plain UUID string (e.g. `"550e8400-e29b-41d4-a716-446655440000"`), +/// which matches how the Haskell Hercules CI server represents entity IDs. The type parameter `T` +/// exists solely at compile time -- it ensures you cannot accidentally pass an `Id` where an +/// `Id` is expected, catching a class of bugs that would otherwise be silent UUID mix-ups. +/// +/// # Examples +/// +/// ```ignore +/// let job_id: Id = Id::new(); +/// let build_id: Id = Id::new(); +/// // job_id == build_id; // compile error -- different types +/// ``` +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct Id { + id: Uuid, + _marker: PhantomData, +} + +impl Id { + /// Create a new random (v4) identifier. + /// + /// Used by the Jupiter server when minting new entities (jobs, tasks, etc.). + pub fn new() -> Self { + Self { + id: Uuid::new_v4(), + _marker: PhantomData, + } + } + + /// Wrap an existing UUID as a typed identifier. + /// + /// Typically used when loading IDs from the database or deserializing from + /// a non-serde source. + pub fn from_uuid(id: Uuid) -> Self { + Self { + id, + _marker: PhantomData, + } + } + + /// Consume this identifier and return the raw UUID. + pub fn into_uuid(self) -> Uuid { + self.id + } + + /// Borrow the inner UUID without consuming the identifier. + pub fn as_uuid(&self) -> &Uuid { + &self.id + } +} + +impl Default for Id { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Debug for Id { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Id({})", self.id) + } +} + +impl fmt::Display for Id { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.id) + } +} + +impl From for Id { + fn from(id: Uuid) -> Self { + Self { + id, + _marker: PhantomData, + } + } +} + +impl From> for Uuid { + fn from(id: Id) -> Self { + id.id + } +} + +/// Serializes as a plain UUID string to match Haskell aeson output. +impl Serialize for Id { + fn serialize(&self, serializer: S) -> Result { + self.id.serialize(serializer) + } +} + +/// Deserializes from a plain UUID string. +impl<'de, T> Deserialize<'de> for Id { + fn deserialize>(deserializer: D) -> Result { + let id = Uuid::deserialize(deserializer)?; + Ok(Self { + id, + _marker: PhantomData, + }) + } +} + +// --------------------------------------------------------------------------- + +/// A phantom-typed string wrapper analogous to [`Id`] but for human-readable names. +/// +/// Serializes as a plain string on the wire. The type parameter prevents mixing up +/// names that belong to different domains (e.g. project names vs. secret names). +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct Name { + name: String, + _marker: PhantomData, +} + +impl Name { + /// Create a new typed name from any string-like value. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + _marker: PhantomData, + } + } + + /// Borrow the inner string as a `&str`. + pub fn as_str(&self) -> &str { + &self.name + } + + /// Consume the wrapper and return the owned string. + pub fn into_string(self) -> String { + self.name + } +} + +impl fmt::Debug for Name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Name({:?})", self.name) + } +} + +impl fmt::Display for Name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name) + } +} + +impl Serialize for Name { + fn serialize(&self, serializer: S) -> Result { + self.name.serialize(serializer) + } +} + +impl<'de, T> Deserialize<'de> for Name { + fn deserialize>(deserializer: D) -> Result { + let name = String::deserialize(deserializer)?; + Ok(Self { + name, + _marker: PhantomData, + }) + } +} + +// --------------------------------------------------------------------------- + +/// A wrapper for sensitive data (tokens, secret payloads) that redacts its [`Debug`] output. +/// +/// The Hercules CI protocol requires secret data (e.g. `serverSecrets` in [`EffectTask`]) +/// to be transmitted in JSON. `Sensitive` ensures that if the value is accidentally +/// logged via `{:?}` formatting, the actual content is replaced with ``. +/// Serialization and deserialization pass through to the inner value unchanged, so the +/// wire format is identical to using `T` directly. +/// +/// This is a defense-in-depth measure: even if a debug log accidentally captures an +/// `EffectTask`, the secrets will not appear in plain text. +#[derive(Clone, PartialEq, Eq)] +pub struct Sensitive { + inner: T, +} + +impl Sensitive { + /// Wrap a value as sensitive, opting into redacted debug output. + pub fn new(inner: T) -> Self { + Self { inner } + } + + /// Consume the wrapper and return the inner value. + /// + /// Call this only when you genuinely need the secret (e.g. to pass it to an agent + /// over the WebSocket). Avoid holding the unwrapped value longer than necessary. + pub fn into_inner(self) -> T { + self.inner + } +} + +/// Always prints `` regardless of the inner value. +impl fmt::Debug for Sensitive { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "") + } +} + +/// Allows transparent access to the inner value via `&*sensitive_val`. +impl Deref for Sensitive { + type Target = T; + fn deref(&self) -> &T { + &self.inner + } +} + +/// Serializes as if the wrapper did not exist, preserving wire compatibility. +impl Serialize for Sensitive { + fn serialize(&self, serializer: S) -> Result { + self.inner.serialize(serializer) + } +} + +/// Deserializes the inner value and wraps it as sensitive. +impl<'de, T: Deserialize<'de>> Deserialize<'de> for Sensitive { + fn deserialize>(deserializer: D) -> Result { + let inner = T::deserialize(deserializer)?; + Ok(Self { inner }) + } +} + +// =========================================================================== +// Account & Auth Types +// =========================================================================== + +/// Whether an account represents an individual user or an organization. +/// +/// In the Hercules CI model, agents belong to accounts (via cluster join tokens), +/// and projects are owned by accounts. This mirrors how hercules-ci.com organizes +/// GitHub users and organizations. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AccountType { + User, + Organization, +} + +impl fmt::Display for AccountType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AccountType::User => write!(f, "user"), + AccountType::Organization => write!(f, "organization"), + } + } +} + +impl std::str::FromStr for AccountType { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "user" => Ok(AccountType::User), + "organization" => Ok(AccountType::Organization), + other => Err(format!("unknown account type: {other}")), + } + } +} + +/// A user or organization account that owns projects and agent clusters. +/// +/// Maps to the Hercules CI concept of an "account" which is associated with a +/// forge identity (e.g. a GitHub user or org). Agents authenticate against an +/// account via [`ClusterJoinToken`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Account { + pub id: Id, + /// Display name of the account (e.g. GitHub username or org name). + pub name: String, + /// Renamed from `type` to avoid the Rust keyword; serializes as `"type"` on the wire. + #[serde(rename = "type")] + pub typ: AccountType, +} + +/// A token that allows hercules-ci-agent instances to join an account's agent cluster. +/// +/// In the real Hercules CI, you generate a cluster join token in the web UI and put it +/// into the agent's configuration. The agent sends this token in [`AgentHello`] to +/// authenticate itself. Jupiter replicates this flow so the same agent configuration works. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClusterJoinToken { + pub id: Id, + /// The account this token grants cluster membership to. + pub account_id: Id, + /// A human-readable label for the token (e.g. "production-agents"). + pub name: String, + pub created_at: DateTime, +} + +/// Response returned when creating a new cluster join token. +/// +/// The plaintext `token` value is only returned once at creation time. It should be +/// copied into the agent's `clusterJoinTokenPath` file. Jupiter never stores the +/// plaintext -- only a hash. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateClusterJoinTokenResponse { + pub id: Id, + /// The raw bearer token. Show this to the user exactly once. + pub token: String, +} + +// =========================================================================== +// Forge & Repo Types +// =========================================================================== + +/// The type of source code forge (hosting platform). +/// +/// Jupiter extends the original Hercules CI model (GitHub only) with support for +/// Gitea and Radicle. The `ForgeType` determines which webhook format to expect +/// and which API to call for commit statuses. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ForgeType { + GitHub, + Gitea, + Radicle, +} + +impl fmt::Display for ForgeType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ForgeType::GitHub => write!(f, "github"), + ForgeType::Gitea => write!(f, "gitea"), + ForgeType::Radicle => write!(f, "radicle"), + } + } +} + +impl std::str::FromStr for ForgeType { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "github" | "GitHub" => Ok(ForgeType::GitHub), + "gitea" | "Gitea" => Ok(ForgeType::Gitea), + "radicle" | "Radicle" => Ok(ForgeType::Radicle), + other => Err(format!("unknown forge type: {other}")), + } + } +} + +/// A Git repository tracked by Jupiter. +/// +/// Repos are discovered from the configured forge and linked to [`Project`]s. +/// The `clone_url` is what gets passed to agents so they can fetch the source. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Repo { + /// Jupiter's internal identifier for this repo. + pub id: Id, + /// The forge-side identifier (e.g. GitHub's numeric repo ID). + pub forge_id: Id, + /// Repository owner (user or org name on the forge). + pub owner: String, + /// Repository name. + pub name: String, + /// The URL agents will use to clone the repo (HTTPS or SSH). + pub clone_url: String, + /// The default branch name (e.g. "main" or "master"). + pub default_branch: String, +} + +/// A Hercules CI project, linking an [`Account`] to a [`Repo`]. +/// +/// Projects are the top-level organizational unit: jobs, secrets, and state files +/// all belong to a project. Enabling/disabling a project controls whether forge +/// webhooks trigger new CI jobs. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Project { + pub id: Id, + /// The account that owns this project. + pub account_id: Id, + /// The repo this project is connected to. + pub repo_id: Id, + /// Display name, typically `"owner/repo"`. + pub name: String, + /// Whether CI is active for this project. Disabled projects ignore webhooks. + pub enabled: bool, +} + +// =========================================================================== +// Agent Types +// =========================================================================== + +/// Metadata about the Jupiter server, sent to agents during the WebSocket handshake. +/// +/// The version tuple `(major, minor)` allows agents to detect protocol incompatibilities. +/// This matches the `serviceInfo` field in the real Hercules CI handshake. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServiceInfo { + /// Protocol version as `(major, minor)`. + pub version: (u32, u32), +} + +/// The first message a hercules-ci-agent sends after opening the WebSocket connection. +/// +/// This is sent as an out-of-band ([`Frame::Oob`]) message. The server uses the +/// `cluster_join_token` to authenticate the agent and associate it with an [`Account`]. +/// The remaining fields describe the agent's capabilities so the server can make +/// intelligent scheduling decisions (e.g. only send `x86_64-linux` builds to agents +/// that list that platform). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentHello { + /// The bearer token from the agent's `clusterJoinTokenPath` file. + pub cluster_join_token: String, + /// The agent machine's hostname, for display in the dashboard. + pub hostname: String, + /// Nix system strings this agent can build for (e.g. `["x86_64-linux"]`). + pub platforms: Vec, + /// Nix system features the agent supports (e.g. `["kvm", "big-parallel"]`). + pub system_features: Vec, + /// The version of Nix installed on the agent machine. + pub nix_version: String, + /// The version of hercules-ci-agent itself. + pub agent_version: String, + /// Maximum number of tasks this agent can run concurrently. + pub concurrency: u32, +} + +/// A record of an active or past agent WebSocket session. +/// +/// Created by the server when an agent successfully completes the handshake. +/// Used to track which agent is working on which tasks, and to display +/// connected agents in the dashboard. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSession { + /// Unique identifier for this session. + pub id: Id, + /// The persistent agent identity (survives reconnections). + pub agent_id: Id, + /// Hostname reported in [`AgentHello`]. + pub hostname: String, + /// Platforms this agent supports (copied from [`AgentHello::platforms`]). + pub platforms: Vec, + /// System features this agent supports (copied from [`AgentHello::system_features`]). + pub system_features: Vec, + /// Task concurrency limit for this agent. + pub concurrency: u32, + /// When this WebSocket session was established. + pub connected_at: DateTime, +} + +// =========================================================================== +// WebSocket Frame Types +// =========================================================================== + +/// The low-level WebSocket framing protocol between the Jupiter server and hercules-ci-agent. +/// +/// Every WebSocket text message is a JSON-serialized `Frame`. The framing provides: +/// - **Sequenced delivery** via `Msg` with monotonically increasing sequence numbers. +/// - **Out-of-band signaling** via `Oob` for handshake messages that exist outside the +/// numbered sequence (e.g. [`AgentHello`], [`ServiceInfo`]). +/// - **Acknowledgment** via `Ack` so the sender knows the receiver has processed up to +/// sequence number `n`. This enables flow control and reliable delivery. +/// - **Error reporting** via `Exception` for protocol-level errors. +/// +/// The `#[serde(tag = "t")]` attribute produces `{"t": "msg", "n": 1, "p": ...}` which +/// matches the Haskell agent's expected wire format. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "t")] +pub enum Frame { + /// A sequenced payload message. `n` is the sequence number; `p` is the JSON payload + /// (a serialized [`ServerMessage`] or [`AgentMessage`]). + #[serde(rename = "msg")] + Msg { n: u64, p: serde_json::Value }, + + /// An out-of-band message outside the numbered sequence. Used for handshake + /// messages: the agent sends [`AgentHello`] as Oob, and the server responds + /// with [`ServiceInfo`] as Oob. + #[serde(rename = "oob")] + Oob { p: serde_json::Value }, + + /// Acknowledges receipt of all messages up to and including sequence number `n`. + /// Allows the sender to free buffered messages and manage backpressure. + #[serde(rename = "ack")] + Ack { n: u64 }, + + /// A protocol-level error. The connection should typically be closed after this. + #[serde(rename = "exception")] + Exception { message: String }, +} + +// =========================================================================== +// Task Types +// =========================================================================== + +/// The three kinds of work units that the server dispatches to agents. +/// +/// These map directly to the Hercules CI task pipeline: +/// 1. **Evaluate** -- Nix evaluation of a flake/project to discover derivations. +/// 2. **Build** -- Building a single Nix derivation. +/// 3. **Effect** -- Running a Hercules CI effect (a derivation with side effects, e.g. deployment). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TaskType { + Evaluate, + Build, + Effect, +} + +impl fmt::Display for TaskType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TaskType::Evaluate => write!(f, "evaluate"), + TaskType::Build => write!(f, "build"), + TaskType::Effect => write!(f, "effect"), + } + } +} + +impl std::str::FromStr for TaskType { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "evaluate" => Ok(TaskType::Evaluate), + "build" => Ok(TaskType::Build), + "effect" => Ok(TaskType::Effect), + other => Err(format!("unknown task type: {other}")), + } + } +} + +/// Lifecycle status of an individual task dispatched to an agent. +/// +/// The progression is: `Pending -> Assigned -> Running -> Successful | Failed | Cancelled | ExceptionRaised`. +/// - `Pending`: task is queued, waiting for an available agent. +/// - `Assigned`: an agent has been selected but hasn't started yet. +/// - `Running`: the agent has sent a [`AgentMessage::Started`] acknowledgment. +/// - `Successful`: the agent reported success. +/// - `Failed`: the agent reported failure (e.g. build failure, evaluation error). +/// - `Cancelled`: the server cancelled the task (e.g. superseded by a newer commit). +/// - `ExceptionRaised`: the agent encountered an unexpected error (protocol issue, crash). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TaskStatus { + Pending, + Assigned, + Running, + Successful, + Failed, + Cancelled, + ExceptionRaised, +} + +impl fmt::Display for TaskStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TaskStatus::Pending => write!(f, "pending"), + TaskStatus::Assigned => write!(f, "assigned"), + TaskStatus::Running => write!(f, "running"), + TaskStatus::Successful => write!(f, "successful"), + TaskStatus::Failed => write!(f, "failed"), + TaskStatus::Cancelled => write!(f, "cancelled"), + TaskStatus::ExceptionRaised => write!(f, "exception_raised"), + } + } +} + +impl std::str::FromStr for TaskStatus { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "pending" => Ok(TaskStatus::Pending), + "assigned" => Ok(TaskStatus::Assigned), + "running" => Ok(TaskStatus::Running), + "successful" => Ok(TaskStatus::Successful), + "failed" => Ok(TaskStatus::Failed), + "cancelled" => Ok(TaskStatus::Cancelled), + "exception_raised" => Ok(TaskStatus::ExceptionRaised), + other => Err(format!("unknown task status: {other}")), + } + } +} + +/// Common fields shared by all task types ([`EvaluateTask`], [`BuildTask`], [`EffectTask`]). +/// +/// Factored out so the server can track any task generically (status updates, agent +/// assignment, timestamps) without knowing the specific task payload. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskBase { + /// Unique identifier for this task. Used in [`AgentMessage`] variants to correlate + /// responses back to the dispatched task. + pub id: Id, + /// Serializes as `"type"` to match the Haskell field name. + #[serde(rename = "type")] + pub typ: TaskType, + pub status: TaskStatus, + pub created_at: DateTime, + pub updated_at: DateTime, + /// Which agent session is currently working on this task, if any. + pub agent_session_id: Option>, +} + +/// A Nix evaluation task dispatched to an agent via [`ServerMessage::StartEvaluation`]. +/// +/// The agent clones the repository described by `primary_input`, evaluates the Nix +/// expression at the `selector` path (default: `herculesCI`), and reports back the +/// discovered attributes, derivations, and any errors via [`AgentMessage`] variants +/// like `Attribute`, `AttributeEffect`, `AttributeError`, and `EvaluationDone`. +/// +/// Evaluation is the first phase of every job. Its results determine which builds +/// and effects need to run. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EvaluateTask { + /// Common task metadata (id, status, timestamps). + pub task: TaskBase, + /// The job this evaluation belongs to. + pub job_id: Id, + /// The main repository input to evaluate. + pub primary_input: EvaluateInput, + /// Arguments automatically injected by the server (e.g. `herculesCI.config`). + pub auto_arguments: serde_json::Value, + /// Additional flake inputs beyond the primary (for multi-repo setups). + pub inputs_extra: Vec, + /// Which Nix attribute path to evaluate (defaults to `["herculesCI"]`). + pub selector: AttributeSelector, +} + +/// Describes a single input to Nix evaluation -- a repository at a specific commit. +/// +/// Each evaluation can have a `primary_input` plus zero or more `inputs_extra`. +/// The agent uses `src` to fetch the source and `ref_info` for context about which +/// branch/commit triggered the job. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EvaluateInput { + /// Logical name for this input (e.g. `"src"` for the primary). + pub name: String, + /// The project name this input comes from. + pub project_name: String, + /// Git ref and commit that triggered the evaluation. + pub ref_info: RefInfo, + /// Source location (URL, rev, ref) for the agent to fetch. + pub src: Src, +} + +/// A git reference and its resolved commit SHA. +/// +/// Sent to the agent so it knows both the symbolic ref (e.g. `refs/heads/main`) +/// and the exact commit to evaluate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RefInfo { + /// The symbolic git ref (e.g. `"refs/heads/main"`). Serialized as `"ref"`. + #[serde(rename = "ref")] + pub ref_name: String, + /// The full commit SHA. + pub commit_sha: String, +} + +/// Source location for fetching a repository. +/// +/// The agent uses these fields to fetch the source tree. For flake-based projects, +/// `is_flake` is true and the agent uses `nix flake` commands; otherwise it uses +/// legacy Hercules CI evaluation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Src { + /// Clone URL (HTTPS or SSH). + pub url: String, + /// The exact revision (commit SHA) to check out. + pub rev: String, + /// Symbolic ref name. Serialized as `"ref"`. + #[serde(rename = "ref")] + pub ref_name: String, + /// Whether this project uses Nix flakes. Determines the evaluation strategy + /// the agent will use. + pub is_flake: bool, +} + +/// Selects which Nix attribute path to evaluate in a flake or project. +/// +/// Defaults to `["herculesCI"]`, which is the conventional entry point for +/// Hercules CI job definitions in a flake.nix. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttributeSelector { + /// The attribute path components (e.g. `["herculesCI"]` means `herculesCI` at the top level). + pub path: Vec, +} + +impl Default for AttributeSelector { + /// Defaults to `["herculesCI"]` -- the standard Hercules CI entry point. + fn default() -> Self { + Self { + path: vec!["herculesCI".to_string()], + } + } +} + +/// A Nix build task dispatched to an agent via [`ServerMessage::StartBuild`]. +/// +/// The server sends one `BuildTask` per derivation that needs building (as discovered +/// during evaluation). The agent builds the derivation and reports back via +/// [`AgentMessage::BuildDone`], [`AgentMessage::OutputInfo`], and [`AgentMessage::Pushed`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuildTask { + /// Common task metadata. + pub task: TaskBase, + /// Nix store path of the derivation to build (e.g. `/nix/store/...-foo.drv`). + pub derivation_path: String, + /// Maps input derivation paths to their already-built output paths. This lets the + /// agent substitute pre-built dependencies instead of rebuilding them. + pub input_derivation_outputs_map: serde_json::Value, +} + +/// An effect (side-effecting derivation) task dispatched via [`ServerMessage::StartEffect`]. +/// +/// Effects are the "deploy" phase of Hercules CI: derivations that perform actions like +/// pushing Docker images, updating DNS, or running deployment scripts. They run after +/// all builds have succeeded and can access server-provided secrets. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EffectTask { + /// Common task metadata. + pub task: TaskBase, + /// The job this effect belongs to. + pub job_id: Id, + /// Nix store path of the effect derivation. + pub derivation_path: String, + /// Pre-built input derivation outputs (same as [`BuildTask::input_derivation_outputs_map`]). + pub input_derivation_outputs_map: serde_json::Value, + /// Secrets from the Jupiter server that the effect is authorized to access. + /// Keyed by secret name. Wrapped in the protocol but NOT in [`Sensitive`] here + /// because the agent genuinely needs the plaintext. + pub server_secrets: serde_json::Value, + /// A short-lived JWT token the effect can use to call back to the Jupiter API + /// (e.g. to read/write state files). + pub token: String, + /// The Jupiter server's API base URL for effect callbacks. + pub api_base_url: String, +} + +// =========================================================================== +// Server -> Agent Messages +// =========================================================================== + +/// Messages sent from the Jupiter server to a connected hercules-ci-agent. +/// +/// These are serialized into the `p` (payload) field of a [`Frame::Msg`]. +/// The `#[serde(tag = "tag", content = "contents")]` encoding matches Haskell aeson's +/// `TaggedObject` style, which the real hercules-ci-agent expects. +/// +/// The server dispatches work via `StartEvaluation`, `StartBuild`, and `StartEffect`, +/// and can cancel in-progress tasks via `Cancel`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "tag", content = "contents")] +pub enum ServerMessage { + /// Dispatch a Nix evaluation task. The agent should evaluate the flake/project + /// and report discovered attributes. + StartEvaluation(EvaluateTask), + /// Dispatch a Nix build task. The agent should build the derivation and report + /// output info. + StartBuild(BuildTask), + /// Dispatch an effect task. The agent should realize the effect derivation + /// (which may perform deployments or other side effects). + StartEffect(EffectTask), + /// Cancel a previously dispatched task. The agent should stop the task and + /// respond with [`AgentMessage::Cancelled`]. + Cancel { + #[serde(rename = "taskId")] + task_id: Id, + }, +} + +// =========================================================================== +// Agent -> Server Messages +// =========================================================================== + +/// Classification of a Nix attribute discovered during evaluation. +/// +/// The agent reports each attribute's type so the server knows how to handle it: +/// - `Regular` attributes produce builds. +/// - `Effect` attributes produce effect tasks (run after builds complete). +/// - `MustFail` attributes are expected to fail (used for testing). +/// - `DependenciesOnly` means only the dependencies should be built, not the attribute itself. +/// - `Ignored` attributes are skipped entirely. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AttributeType { + Regular, + Effect, + MustFail, + DependenciesOnly, + Ignored, +} + +impl fmt::Display for AttributeType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AttributeType::Regular => write!(f, "regular"), + AttributeType::Effect => write!(f, "effect"), + AttributeType::MustFail => write!(f, "mustFail"), + AttributeType::DependenciesOnly => write!(f, "dependenciesOnly"), + AttributeType::Ignored => write!(f, "ignored"), + } + } +} + +impl std::str::FromStr for AttributeType { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "regular" => Ok(AttributeType::Regular), + "effect" => Ok(AttributeType::Effect), + "mustFail" => Ok(AttributeType::MustFail), + "dependenciesOnly" => Ok(AttributeType::DependenciesOnly), + "ignored" => Ok(AttributeType::Ignored), + other => Err(format!("unknown attribute type: {other}")), + } + } +} + +/// Severity level for log entries streamed from the agent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LogLevel { + Trace, + Debug, + Info, + Warning, + Error, +} + +impl fmt::Display for LogLevel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LogLevel::Trace => write!(f, "trace"), + LogLevel::Debug => write!(f, "debug"), + LogLevel::Info => write!(f, "info"), + LogLevel::Warning => write!(f, "warning"), + LogLevel::Error => write!(f, "error"), + } + } +} + +impl std::str::FromStr for LogLevel { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "trace" => Ok(LogLevel::Trace), + "debug" => Ok(LogLevel::Debug), + "info" => Ok(LogLevel::Info), + "warning" => Ok(LogLevel::Warning), + "error" => Ok(LogLevel::Error), + other => Err(format!("unknown log level: {other}")), + } + } +} + +/// A single log line from an agent task, streamed via [`AgentMessage::LogItems`]. +/// +/// Agents batch log entries and send them periodically. The `i` field provides +/// a monotonic index for ordering, and `ms` is the timestamp in milliseconds +/// since the task started. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LogEntry { + /// Monotonic sequence index within this task's log stream. + pub i: u64, + /// Milliseconds since the task started (relative timestamp). + pub ms: i64, + /// The log message text. + pub msg: String, + /// Severity level. + pub level: LogLevel, +} + +/// Messages sent from a hercules-ci-agent back to the Jupiter server. +/// +/// These are serialized into the `p` (payload) field of a [`Frame::Msg`]. +/// Uses `#[serde(tag = "tag", content = "contents")]` to match aeson `TaggedObject` encoding. +/// +/// During **evaluation**, the agent streams: `Started` -> (`Attribute` | `AttributeEffect` | +/// `AttributeError` | `DerivationInfo` | `BuildRequired`)* -> `EvaluationDone`. +/// +/// During **builds**, the agent streams: `Started` -> `OutputInfo`* -> `Pushed`* -> `BuildDone`. +/// +/// During **effects**, the agent streams: `Started` -> `EffectDone`. +/// +/// `LogItems` can arrive at any point during a running task. +/// `Cancelled` is sent in response to a [`ServerMessage::Cancel`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "tag", content = "contents")] +pub enum AgentMessage { + /// The agent has begun working on the specified task. + Started { + #[serde(rename = "taskId")] + task_id: Id, + }, + /// The agent has stopped working on a cancelled task. + Cancelled { + #[serde(rename = "taskId")] + task_id: Id, + }, + /// A regular (non-effect) Nix attribute discovered during evaluation. + /// Contains the derivation path and output names so the server can schedule builds. + Attribute { + #[serde(rename = "taskId")] + task_id: Id, + /// Nix attribute path (e.g. `["packages", "x86_64-linux", "default"]`). + path: Vec, + /// Nix store path of the derivation (e.g. `/nix/store/...-foo.drv`). + #[serde(rename = "derivationPath")] + derivation_path: String, + /// Which outputs this derivation produces (e.g. `["out", "dev"]`). + #[serde(rename = "outputNames")] + output_names: Vec, + /// The attribute classification. Serialized as `"type"`. + #[serde(rename = "type")] + typ: AttributeType, + }, + /// An effect attribute discovered during evaluation. Effects are handled differently + /// from regular builds: they run after all builds succeed and may access secrets. + AttributeEffect { + #[serde(rename = "taskId")] + task_id: Id, + /// Nix attribute path to the effect. + path: Vec, + /// Nix store path of the effect derivation. + #[serde(rename = "derivationPath")] + derivation_path: String, + }, + /// An error encountered while evaluating a specific attribute. The evaluation + /// continues for other attributes; this does not abort the entire evaluation. + AttributeError { + #[serde(rename = "taskId")] + task_id: Id, + /// Nix attribute path where the error occurred. + path: Vec, + /// Human-readable error message. + error: String, + /// Optional machine-readable error classification. + #[serde(rename = "errorType")] + error_type: Option, + /// If the error is associated with a derivation, its store path. + #[serde(rename = "errorDerivation")] + error_derivation: Option, + }, + /// Metadata about a derivation discovered during evaluation. The server uses this + /// to match derivations to agents with compatible platforms and system features. + DerivationInfo { + #[serde(rename = "taskId")] + task_id: Id, + /// Nix store path of the derivation. + #[serde(rename = "derivationPath")] + derivation_path: String, + /// Target platform (e.g. `"x86_64-linux"`). + platform: String, + /// System features the derivation requires (e.g. `["kvm"]`). + #[serde(rename = "requiredSystemFeatures")] + required_system_features: Vec, + /// Paths of input derivations this derivation depends on. + #[serde(rename = "inputDerivations")] + input_derivations: Vec, + /// Output specifications (names and paths). + outputs: serde_json::Value, + }, + /// Indicates that a derivation needs to be built (not available in any cache). + /// The server uses this to create [`BuildTask`]s. + BuildRequired { + #[serde(rename = "taskId")] + task_id: Id, + #[serde(rename = "derivationPath")] + derivation_path: String, + }, + /// The evaluation phase is complete. All attributes and derivation info have been + /// reported. The server can now transition the job to the Building phase. + EvaluationDone { + #[serde(rename = "taskId")] + task_id: Id, + }, + /// Information about a successfully built derivation output. Sent after a build + /// completes so the server can record output hashes and sizes. + OutputInfo { + #[serde(rename = "taskId")] + task_id: Id, + #[serde(rename = "derivationPath")] + derivation_path: String, + /// Which output of the derivation (e.g. `"out"`, `"dev"`). + #[serde(rename = "outputName")] + output_name: String, + /// The Nix store path of the realized output. + #[serde(rename = "outputPath")] + output_path: String, + /// Content hash of the output (for content-addressed verification). + #[serde(rename = "outputHash")] + output_hash: String, + /// Size of the output in bytes. + #[serde(rename = "outputSize")] + output_size: u64, + }, + /// An output has been pushed to the binary cache. This confirms the output is + /// available for other agents or subsequent builds to substitute. + Pushed { + #[serde(rename = "taskId")] + task_id: Id, + #[serde(rename = "derivationPath")] + derivation_path: String, + #[serde(rename = "outputName")] + output_name: String, + }, + /// A build has finished (successfully or not). + BuildDone { + #[serde(rename = "taskId")] + task_id: Id, + #[serde(rename = "derivationPath")] + derivation_path: String, + /// Whether the build succeeded. + success: bool, + }, + /// An effect has finished (successfully or not). + EffectDone { + #[serde(rename = "taskId")] + task_id: Id, + /// Whether the effect succeeded. + success: bool, + /// The process exit code, if available. + #[serde(rename = "exitCode")] + exit_code: Option, + }, + /// A batch of log entries from a running task. The server stores these for + /// display in the dashboard and API. + LogItems { + #[serde(rename = "taskId")] + task_id: Id, + #[serde(rename = "logEntries")] + log_entries: Vec, + }, +} + +// =========================================================================== +// Job Types +// =========================================================================== + +/// The high-level lifecycle status of a CI job. +/// +/// A job progresses through these phases in order: +/// +/// ```text +/// ForgeEvent -> Pending -> Evaluating -> Building -> RunningEffects -> Succeeded +/// | | | | +/// +-----> Cancelled ErrorEvaluating Failed +/// ``` +/// +/// - `Pending`: job created, waiting for an agent to start evaluation. +/// - `Evaluating`: agent is evaluating the Nix expression to discover builds/effects. +/// - `Building`: evaluation done, derivations are being built. +/// - `RunningEffects`: all builds succeeded, effect derivations are being executed. +/// - `Succeeded`: all builds and effects completed successfully. +/// - `Failed`: one or more builds or effects failed. +/// - `Cancelled`: the job was cancelled (e.g. superseded by a newer push). +/// - `ErrorEvaluating`: evaluation itself encountered an unrecoverable error. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum JobStatus { + Pending, + Evaluating, + Building, + RunningEffects, + Succeeded, + Failed, + Cancelled, + ErrorEvaluating, +} + +impl fmt::Display for JobStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + JobStatus::Pending => write!(f, "pending"), + JobStatus::Evaluating => write!(f, "evaluating"), + JobStatus::Building => write!(f, "building"), + JobStatus::RunningEffects => write!(f, "running_effects"), + JobStatus::Succeeded => write!(f, "succeeded"), + JobStatus::Failed => write!(f, "failed"), + JobStatus::Cancelled => write!(f, "cancelled"), + JobStatus::ErrorEvaluating => write!(f, "error_evaluating"), + } + } +} + +impl std::str::FromStr for JobStatus { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "pending" => Ok(JobStatus::Pending), + "evaluating" => Ok(JobStatus::Evaluating), + "building" => Ok(JobStatus::Building), + "running_effects" => Ok(JobStatus::RunningEffects), + "succeeded" => Ok(JobStatus::Succeeded), + "failed" => Ok(JobStatus::Failed), + "cancelled" => Ok(JobStatus::Cancelled), + "error_evaluating" => Ok(JobStatus::ErrorEvaluating), + other => Err(format!("unknown job status: {other}")), + } + } +} + +/// A CI job triggered by a forge event (push, PR, etc.). +/// +/// A job is the top-level unit of CI work. It is created when a [`ForgeEvent`] arrives, +/// linked to a [`Project`], and progresses through evaluation, building, and effects. +/// The `sequence_number` provides ordering within a project so the dashboard can show +/// jobs in chronological order (analogous to a "build number"). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Job { + pub id: Id, + /// The project this job belongs to. + pub project_id: Id, + /// Which forge the source repo lives on. + pub forge_type: ForgeType, + /// Repository owner on the forge. + pub repo_owner: String, + /// Repository name on the forge. + pub repo_name: String, + /// Git ref that triggered the job (e.g. `"refs/heads/main"`). + pub ref_name: String, + /// Commit SHA that triggered the job. + pub commit_sha: String, + /// Current lifecycle status. + pub status: JobStatus, + pub created_at: DateTime, + pub updated_at: DateTime, + /// Monotonically increasing number within the project (like a build number). + pub sequence_number: u64, +} + +/// A lightweight view of a job for list endpoints and summaries. +/// +/// Contains only the fields needed for dashboard listings, omitting +/// forge details and the full timestamp pair. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct JobSummary { + pub id: Id, + pub project_id: Id, + pub ref_name: String, + pub commit_sha: String, + pub status: JobStatus, + pub created_at: DateTime, +} + +/// The result of a completed evaluation phase, collected from [`AgentMessage`] variants. +/// +/// After the agent sends [`AgentMessage::EvaluationDone`], the server assembles this +/// structure from the accumulated `Attribute`, `AttributeEffect`, and `AttributeError` +/// messages. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EvaluationResult { + pub job_id: Id, + /// All attributes discovered during evaluation (successful or errored). + pub attributes: Vec, +} + +/// A single attribute from the evaluation result, representing one Nix derivation +/// or an error at a specific attribute path. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttributeResult { + /// Nix attribute path (e.g. `["packages", "x86_64-linux", "default"]`). + pub path: Vec, + /// Nix store path of the derivation, if evaluation succeeded for this attribute. + pub derivation_path: Option, + /// Error message, if evaluation failed for this attribute. + pub error: Option, + /// Attribute classification. Serialized as `"type"`. + #[serde(rename = "type")] + pub typ: AttributeType, +} + +// =========================================================================== +// Build Types +// =========================================================================== + +/// Lifecycle status of an individual Nix derivation build. +/// +/// Simpler than [`TaskStatus`] because builds don't have an "assigned" phase -- +/// they go directly from pending to building when an agent picks them up. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BuildStatus { + Pending, + Building, + Succeeded, + Failed, + Cancelled, +} + +impl fmt::Display for BuildStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BuildStatus::Pending => write!(f, "pending"), + BuildStatus::Building => write!(f, "building"), + BuildStatus::Succeeded => write!(f, "succeeded"), + BuildStatus::Failed => write!(f, "failed"), + BuildStatus::Cancelled => write!(f, "cancelled"), + } + } +} + +impl std::str::FromStr for BuildStatus { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "pending" => Ok(BuildStatus::Pending), + "building" => Ok(BuildStatus::Building), + "succeeded" => Ok(BuildStatus::Succeeded), + "failed" => Ok(BuildStatus::Failed), + "cancelled" => Ok(BuildStatus::Cancelled), + other => Err(format!("unknown build status: {other}")), + } + } +} + +/// A record of a Nix derivation build. +/// +/// One `Build` is created for each derivation that needs building as determined +/// during evaluation. The server tracks which agent is building it and when it +/// started/completed. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Build { + pub id: Id, + /// Nix store path of the derivation being built. + pub derivation_path: String, + pub status: BuildStatus, + /// When the agent started building (None if still pending). + pub started_at: Option>, + /// When the build finished (None if still in progress). + pub completed_at: Option>, + /// Which agent session is building this derivation. + pub agent_session_id: Option>, +} + +// =========================================================================== +// Effect Types +// =========================================================================== + +/// Lifecycle status of a Hercules CI effect (side-effecting derivation). +/// +/// Effects have additional waiting states because they can only run after their +/// build dependencies are complete and after any preceding effects in the same +/// job have finished (effects may have ordering constraints). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EffectStatus { + Pending, + /// Waiting for build dependencies to complete before this effect can run. + WaitingForBuilds, + /// Waiting for earlier effects in the same job to finish first. + WaitingForPrecedingEffects, + Running, + Succeeded, + Failed, + Cancelled, +} + +impl fmt::Display for EffectStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EffectStatus::Pending => write!(f, "pending"), + EffectStatus::WaitingForBuilds => write!(f, "waiting_for_builds"), + EffectStatus::WaitingForPrecedingEffects => write!(f, "waiting_for_preceding_effects"), + EffectStatus::Running => write!(f, "running"), + EffectStatus::Succeeded => write!(f, "succeeded"), + EffectStatus::Failed => write!(f, "failed"), + EffectStatus::Cancelled => write!(f, "cancelled"), + } + } +} + +impl std::str::FromStr for EffectStatus { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "pending" => Ok(EffectStatus::Pending), + "waiting_for_builds" => Ok(EffectStatus::WaitingForBuilds), + "waiting_for_preceding_effects" => Ok(EffectStatus::WaitingForPrecedingEffects), + "running" => Ok(EffectStatus::Running), + "succeeded" => Ok(EffectStatus::Succeeded), + "failed" => Ok(EffectStatus::Failed), + "cancelled" => Ok(EffectStatus::Cancelled), + other => Err(format!("unknown effect status: {other}")), + } + } +} + +/// A record of a Hercules CI effect execution. +/// +/// Effects are side-effecting derivations (deployments, notifications, etc.) that +/// run after all builds in a job have succeeded. They are discovered during evaluation +/// as [`AttributeType::Effect`] attributes. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Effect { + pub id: Id, + /// The job this effect belongs to. + pub job_id: Id, + /// Nix attribute path to the effect (e.g. `["effects", "deploy"]`). + pub attribute_path: Vec, + /// Nix store path of the effect derivation. + pub derivation_path: String, + pub status: EffectStatus, + pub started_at: Option>, + pub completed_at: Option>, +} + +// =========================================================================== +// State File Types +// =========================================================================== + +/// A versioned state file stored by the Jupiter server on behalf of effects. +/// +/// Hercules CI effects can persist state between runs (e.g. tracking which version +/// was last deployed). State files are scoped to a project and identified by name. +/// They are versioned so effects can detect concurrent modifications. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StateFile { + /// Logical name of the state file (e.g. `"deploy-status"`). + pub name: String, + /// The project this state file belongs to. + pub project_id: Id, + /// Monotonically increasing version number, incremented on each update. + pub version: u64, + /// Size of the state data in bytes. + pub size_bytes: u64, + pub updated_at: DateTime, +} + +/// A distributed lock on a state file to prevent concurrent writes. +/// +/// Effects acquire a lock before reading/writing state files. Locks have an +/// expiration time (`expires_at`) to prevent deadlocks if an agent crashes +/// while holding a lock. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StateLock { + pub id: Id, + pub project_id: Id, + /// Name of the state file being locked. + pub name: String, + /// Identifier of the lock holder (typically the agent session or effect task). + pub owner: String, + /// When this lock expires if not explicitly released. + pub expires_at: DateTime, + pub created_at: DateTime, +} + +/// Request body for acquiring a state file lock. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LockAcquireRequest { + /// Name of the state file to lock. + pub name: String, +} + +/// Request body for renewing (extending) an existing state file lock lease. +/// +/// Long-running effects periodically renew their lock to prevent expiration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LockLeaseRenewRequest { + /// New time-to-live in seconds from now. + pub ttl_seconds: u64, +} + +// =========================================================================== +// Secret Types +// =========================================================================== + +/// A named secret associated with a project, accessible to effects during execution. +/// +/// Secrets are stored server-side and injected into effect tasks via +/// [`EffectTask::server_secrets`]. The `condition` field controls under which +/// circumstances the secret is made available (e.g. only on the default branch, +/// only for the repo owner). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Secret { + pub id: Id, + pub project_id: Id, + /// Human-readable name used to reference this secret in effect code. + pub name: String, + /// The secret payload. Wrapped in [`Sensitive`] to prevent accidental logging. + pub data: Sensitive, + /// Under what conditions this secret is provided to effects. + pub condition: SecretCondition, +} + +/// A boolean expression controlling when a [`Secret`] is provided to effects. +/// +/// This prevents accidental secret leakage: for example, a deploy token should only +/// be available on the default branch, not on arbitrary pull requests from forks. +/// Conditions can be composed with `And`, `Or`, and `Not` for complex policies. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SecretCondition { + /// The event was triggered by the repository owner. + IsOwner, + /// The event is on the repository's default branch. + IsDefaultBranch, + /// The event is a pull request. Serialized as `"isPR"`. + #[serde(rename = "isPR")] + IsPR, + /// All sub-conditions must be true. + And(Vec), + /// At least one sub-condition must be true. + Or(Vec), + /// Negates a sub-condition. + Not(Box), + /// The secret is always available regardless of context. + Always, +} + +// =========================================================================== +// Forge Events (normalized) +// =========================================================================== + +/// Actions that can happen on a pull request / merge request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PullRequestAction { + /// A new PR was opened. + Opened, + /// New commits were pushed to an existing PR. + Synchronize, + /// A previously closed PR was reopened. + Reopened, + /// The PR was closed (merged or abandoned). + Closed, +} + +/// A normalized forge webhook event that triggers CI jobs. +/// +/// Jupiter receives raw webhooks from GitHub, Gitea, or Radicle, normalizes them +/// into this enum, and uses them to create new [`Job`]s. The normalization layer +/// means the job pipeline does not need to know which forge originated the event. +/// +/// The `#[serde(tag = "type")]` encoding produces `{"type": "push", ...}` etc. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type")] +#[serde(rename_all = "camelCase")] +pub enum ForgeEvent { + /// A git push (one or more commits pushed to a branch). + #[serde(rename_all = "camelCase")] + Push { + repo_id: Id, + /// The full git ref (e.g. `"refs/heads/main"`). + git_ref: String, + /// Commit SHA before the push (all zeros for new branches). + before: String, + /// Commit SHA after the push (the new HEAD). + after: String, + /// Username of the person who pushed. + sender: String, + }, + /// A pull request (or merge request) event. + #[serde(rename_all = "camelCase")] + PullRequest { + repo_id: Id, + action: PullRequestAction, + pr_number: u64, + /// The HEAD commit SHA of the PR branch. + head_sha: String, + /// The base branch the PR targets (e.g. `"main"`). + base_ref: String, + }, + /// A Radicle patch update event (Radicle's equivalent of a PR). + #[serde(rename_all = "camelCase")] + PatchUpdated { + repo_id: Id, + /// Radicle patch identifier. + patch_id: String, + /// Radicle revision identifier within the patch. + revision_id: String, + /// The HEAD commit SHA of the patch. + head_sha: String, + }, +} + +// =========================================================================== +// Commit Status +// =========================================================================== + +/// Status values for commit status checks posted back to the forge. +/// +/// After a job completes, Jupiter posts a commit status to the forge (GitHub check, +/// Gitea status, etc.) so the result appears on the commit and in pull requests. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CommitStatus { + /// CI is still running. + Pending, + /// All builds and effects succeeded. + Success, + /// One or more builds or effects failed. + Failure, + /// An infrastructure error occurred (not a build failure). + Error, +} + +/// A commit status update to post to the forge API. +/// +/// The `context` field identifies this status check (e.g. `"ci/hercules"`) and +/// `target_url` typically links to the Jupiter dashboard for the job. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommitStatusUpdate { + /// The status check name (e.g. `"ci/hercules"`). + pub context: String, + pub status: CommitStatus, + /// URL to the job details page in the Jupiter dashboard. + pub target_url: Option, + /// Short human-readable description (e.g. `"3/5 builds succeeded"`). + pub description: Option, +} + +// =========================================================================== +// Pagination +// =========================================================================== + +/// A paginated response envelope for list endpoints. +/// +/// Matches the pagination format used by the Hercules CI REST API so clients +/// (dashboard, CLI) can paginate through large result sets. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Paginated { + /// The items on this page. + pub items: Vec, + /// Total number of items across all pages. + pub total: u64, + /// Current page number (1-indexed). + pub page: u64, + /// Number of items per page. + pub per_page: u64, +} + +/// Query parameters for paginated list requests. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaginationParams { + /// Requested page number (1-indexed). Defaults to 1 if omitted. + pub page: Option, + /// Requested page size. Defaults to a server-defined value if omitted. + pub per_page: Option, +} + +// =========================================================================== +// Config Types +// =========================================================================== + +/// The database backend Jupiter uses for persistent storage. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DatabaseType { + /// SQLite -- suitable for single-node deployments and development. + Sqlite, + /// PostgreSQL -- recommended for production deployments. + Postgres, +} + +/// Database connection configuration for the Jupiter server. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DatabaseConfig { + /// Serialized as `"type"`. + #[serde(rename = "type")] + pub typ: DatabaseType, + /// File path for SQLite databases. Ignored for Postgres. + pub path: Option, + /// Connection URL for PostgreSQL (e.g. `"postgres://user:pass@host/db"`). Ignored for SQLite. + pub url: Option, +} + +/// How Jupiter integrates with Radicle. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RadicleMode { + /// Poll the Radicle HTTP daemon for changes at a regular interval. + HttpdPolling, + /// Use the Radicle CI broker for push-based notifications. + CiBroker, +} + +/// Configuration for a source code forge integration. +/// +/// Jupiter supports multiple forge backends. Each variant contains the +/// credentials and endpoints needed to receive webhooks and post commit statuses. +/// These are Jupiter-specific configuration types, not part of the Hercules CI +/// wire protocol. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ForgeConfig { + /// GitHub App integration. + GitHub { + /// The GitHub App ID. + app_id: String, + /// Path to the PEM-encoded private key file for the GitHub App. + private_key_path: String, + /// Shared secret for validating incoming GitHub webhooks. + webhook_secret: String, + }, + /// Gitea instance integration. + Gitea { + /// Base URL of the Gitea instance (e.g. `"https://gitea.example.com"`). + base_url: String, + /// API token for authenticating with Gitea. + api_token: String, + /// Shared secret for validating incoming Gitea webhooks. + webhook_secret: String, + }, + /// Radicle network integration. + Radicle { + /// URL of the Radicle HTTP daemon. + httpd_url: String, + /// The Radicle node ID to interact with. + node_id: String, + /// How to discover new patches/changes. + mode: RadicleMode, + /// Polling interval in seconds (only used with [`RadicleMode::HttpdPolling`]). + poll_interval_secs: Option, + }, +} + +/// Storage backend for the Nix binary cache. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CacheStorageType { + /// Store narinfo and nar files on the local filesystem. + Local, + /// Store narinfo and nar files in an S3-compatible object store. + S3, +} + +/// Configuration for Jupiter's Nix binary cache. +/// +/// Jupiter can serve as a binary cache so agents can push and fetch build outputs +/// without needing an external cache like cachix. This replaces the cache +/// functionality provided by hercules-ci.com. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BinaryCacheConfig { + /// Where to store cache data. + pub storage: CacheStorageType, + /// Filesystem path for local storage. + pub path: Option, + /// Maximum cache size in gigabytes. Older entries are evicted when exceeded. + pub max_size_gb: Option, + /// Path to the Nix signing key for signing cached narinfo. + pub signing_key_path: Option, +} + +/// Top-level configuration for the Jupiter server. +/// +/// This is Jupiter-specific (not part of the Hercules CI wire protocol). It defines +/// how the server binds, authenticates, connects to databases, integrates with forges, +/// and optionally operates a binary cache. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerConfig { + /// Socket address to listen on (e.g. `"0.0.0.0:8080"`). + pub listen: String, + /// Public-facing base URL of the Jupiter server (e.g. `"https://ci.example.com"`). + /// Used to generate callback URLs for effects and commit status links. + pub base_url: String, + /// Path to or inline PEM of the JWT signing key for authentication tokens. + pub jwt_private_key: String, + /// Database connection configuration. + pub database: DatabaseConfig, + /// One or more forge integrations. + pub forges: Vec, + /// Optional built-in Nix binary cache. + pub binary_cache: Option, +} + +// =========================================================================== +// Auth +// =========================================================================== + +/// Request body for obtaining a JWT authentication token via username/password. +/// +/// Used by the Jupiter dashboard and CLI to authenticate human users. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthTokenRequest { + pub username: String, + pub password: String, +} + +/// Response containing a JWT token after successful authentication. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthTokenResponse { + /// The JWT bearer token. Include in `Authorization: Bearer ` headers. + pub token: String, + /// When this token expires. The client should re-authenticate before this time. + pub expires_at: DateTime, +} + +/// Request body for obtaining a scoped effect token. +/// +/// Effects call back to the Jupiter API to read/write state files and access secrets. +/// This request creates a short-lived token scoped to a specific effect attribute path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EffectTokenRequest { + /// The Nix attribute path of the effect requesting the token. + pub effect_attribute_path: Vec, +} + +/// Response containing a scoped token for effect API callbacks. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EffectTokenResponse { + /// Short-lived JWT scoped to the requesting effect. + pub token: String, + /// The Jupiter API base URL the effect should use for callbacks. + pub api_base_url: String, +} diff --git a/crates/jupiter-cache/Cargo.toml b/crates/jupiter-cache/Cargo.toml new file mode 100644 index 0000000..efae6be --- /dev/null +++ b/crates/jupiter-cache/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "jupiter-cache" +version.workspace = true +edition.workspace = true + +[dependencies] +jupiter-api-types = { workspace = true } +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +base64 = { workspace = true } diff --git a/crates/jupiter-cache/src/error.rs b/crates/jupiter-cache/src/error.rs new file mode 100644 index 0000000..eac7c18 --- /dev/null +++ b/crates/jupiter-cache/src/error.rs @@ -0,0 +1,43 @@ +//! Error types for the Nix binary cache. +//! +//! [`CacheError`] is the single error enum used across every layer of the +//! cache -- storage I/O, NARInfo parsing, and capacity limits. Axum route +//! handlers in [`crate::routes`] translate these variants into the appropriate +//! HTTP status codes (404, 400, 500, etc.). + +use thiserror::Error; + +/// Unified error type for all binary cache operations. +/// +/// Each variant maps to a different failure mode that can occur when reading, +/// writing, or validating cache artefacts. +#[derive(Debug, Error)] +pub enum CacheError { + /// The requested store-path hash does not exist in the cache. + /// + /// A store hash is the first 32 base-32 characters of a Nix store path + /// (e.g. the `aaaa...` part of `/nix/store/aaaa...-hello-2.12`). + /// This error is returned when neither a `.narinfo` file nor a + /// corresponding NAR archive can be found on disk. + #[error("store hash not found: {0}")] + NotFound(String), + + /// A low-level filesystem I/O error occurred while reading or writing + /// cache data. This typically surfaces as an HTTP 500 to the client. + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + /// The NARInfo text failed to parse -- a required field is missing or a + /// value is malformed. When this comes from a `PUT` request it results + /// in an HTTP 400 (Bad Request) response. + #[error("invalid narinfo: {0}")] + InvalidNarInfo(String), + + /// The on-disk cache has exceeded its configured maximum size. + /// + /// This variant exists to support an optional size cap (`max_size_gb` in + /// [`crate::store::LocalStore::new`]). When the cap is hit, further + /// uploads are rejected until space is freed. + #[error("storage full")] + StorageFull, +} diff --git a/crates/jupiter-cache/src/lib.rs b/crates/jupiter-cache/src/lib.rs new file mode 100644 index 0000000..4aed87e --- /dev/null +++ b/crates/jupiter-cache/src/lib.rs @@ -0,0 +1,44 @@ +//! # jupiter-cache -- Built-in Nix Binary Cache for Jupiter +//! +//! Jupiter is a self-hosted, wire-compatible replacement for +//! [hercules-ci.com](https://hercules-ci.com). This crate implements an +//! **optional** built-in Nix binary cache server that speaks the same HTTP +//! protocol used by `cache.nixos.org` and other standard Nix binary caches. +//! +//! ## Why an optional cache? +//! +//! In a typical Hercules CI deployment each agent already ships with its own +//! binary cache support. However, some organisations prefer a single, shared +//! cache server that every agent (and every developer workstation) can push to +//! and pull from. `jupiter-cache` fills that role: it can be enabled inside +//! the Jupiter server process so that no separate cache infrastructure (e.g. +//! an S3 bucket or a dedicated `nix-serve` instance) is required. +//! +//! ## The Nix binary cache HTTP protocol +//! +//! The protocol is intentionally simple and fully compatible with the one +//! implemented by `cache.nixos.org`: +//! +//! | Method | Path | Description | +//! |--------|-------------------------------|-------------------------------------------| +//! | `GET` | `/nix-cache-info` | Returns cache metadata (store dir, etc.) | +//! | `GET` | `/{storeHash}.narinfo` | Returns the NARInfo for a store path hash | +//! | `PUT` | `/{storeHash}.narinfo` | Uploads a NARInfo (agent -> cache) | +//! | `GET` | `/nar/{narHash}.nar[.xz|.zst]`| Serves the actual NAR archive | +//! +//! A **NARInfo** file is the metadata envelope for a Nix store path. It +//! describes the NAR archive's hash, compressed and uncompressed sizes, +//! references to other store paths, the derivation that produced the path, +//! and one or more cryptographic signatures that attest to its authenticity. +//! +//! ## Crate layout +//! +//! * [`error`] -- Error types for cache operations. +//! * [`narinfo`] -- Parser and serialiser for the NARInfo text format. +//! * [`store`] -- [`LocalStore`](store::LocalStore) -- on-disk storage backend. +//! * [`routes`] -- Axum route handlers that expose the cache over HTTP. + +pub mod error; +pub mod narinfo; +pub mod routes; +pub mod store; diff --git a/crates/jupiter-cache/src/narinfo.rs b/crates/jupiter-cache/src/narinfo.rs new file mode 100644 index 0000000..98b9581 --- /dev/null +++ b/crates/jupiter-cache/src/narinfo.rs @@ -0,0 +1,233 @@ +//! NARInfo parser and serialiser. +//! +//! Every path in a Nix binary cache is described by a **NARInfo** file. When +//! a Nix client wants to know whether a particular store path is available in +//! the cache it fetches `https:///.narinfo`. The response +//! is a simple, line-oriented, key-value text format that looks like this: +//! +//! ```text +//! StorePath: /nix/store/aaaa...-hello-2.12 +//! URL: nar/1b2m2y0h...nar.xz +//! Compression: xz +//! FileHash: sha256:1b2m2y0h... +//! FileSize: 54321 +//! NarHash: sha256:0abcdef... +//! NarSize: 123456 +//! References: bbbb...-glibc-2.37 cccc...-gcc-12.3.0 +//! Deriver: dddd...-hello-2.12.drv +//! Sig: cache.example.com:AAAA...== +//! ``` +//! +//! ## Fields +//! +//! | Field | Required | Description | +//! |---------------|----------|-----------------------------------------------------| +//! | `StorePath` | yes | Full Nix store path | +//! | `URL` | yes | Relative URL to the (possibly compressed) NAR file | +//! | `Compression` | no | `xz`, `zstd`, `bzip2`, or `none` (default: `none`) | +//! | `FileHash` | yes | Hash of the compressed file on disk | +//! | `FileSize` | yes | Size (bytes) of the compressed file | +//! | `NarHash` | yes | Hash of the uncompressed NAR archive | +//! | `NarSize` | yes | Size (bytes) of the uncompressed NAR archive | +//! | `References` | no | Space-separated list of store-path basenames this path depends on | +//! | `Deriver` | no | The `.drv` file that produced this output | +//! | `Sig` | no | Cryptographic signature(s); may appear multiple times| +//! +//! This module provides [`NarInfo::parse`] to deserialise the text format and +//! a [`fmt::Display`] implementation to serialise it back. + +use std::fmt; + +/// A parsed NARInfo record. +/// +/// Represents all the metadata Nix needs to fetch and verify a single store +/// path from a binary cache. Instances are created by parsing the text +/// format received from HTTP requests ([`NarInfo::parse`]) and serialised +/// back to text via the [`Display`](fmt::Display) implementation when serving +/// `GET /{storeHash}.narinfo` responses. +#[derive(Debug, Clone)] +pub struct NarInfo { + /// Full Nix store path, e.g. `/nix/store/aaaa...-hello-2.12`. + pub store_path: String, + + /// Relative URL pointing to the (possibly compressed) NAR archive, + /// e.g. `nar/1b2m2y0h...nar.xz`. The client appends this to the + /// cache base URL to download the archive. + pub url: String, + + /// Compression algorithm applied to the NAR archive on disk. + /// Nix uses this to decide how to decompress after downloading. + pub compression: Compression, + + /// Content-addressed hash of the compressed file (the file referenced + /// by [`url`](Self::url)), usually in the form `sha256:`. + pub file_hash: String, + + /// Size in bytes of the compressed file on disk. + pub file_size: u64, + + /// Content-addressed hash of the *uncompressed* NAR archive. + /// Nix uses this to verify integrity after decompression. + pub nar_hash: String, + + /// Size in bytes of the uncompressed NAR archive. + pub nar_size: u64, + + /// Other store paths that this path depends on at runtime, listed as + /// basenames (e.g. `bbbb...-glibc-2.37`). An empty `Vec` means the + /// path is self-contained. + pub references: Vec, + + /// The `.drv` basename that built this output, if known. + pub deriver: Option, + + /// Zero or more cryptographic signatures that attest to the + /// authenticity of this store path. Each signature is of the form + /// `:`. Multiple `Sig` lines are allowed in + /// the on-wire format. + pub sig: Vec, +} + +/// The compression algorithm used for a NAR archive on disk. +/// +/// When a NAR file is stored in the cache it may be compressed to save space +/// and bandwidth. The compression type is inferred from the file extension +/// (`.xz`, `.zst`, `.bz2`) and recorded in the NARInfo so that clients know +/// how to decompress the download. +#[derive(Debug, Clone, PartialEq)] +pub enum Compression { + /// No compression -- the file is a raw `.nar`. + None, + /// LZMA2 compression (`.nar.xz`). This is the most common format used + /// by `cache.nixos.org`. + Xz, + /// Zstandard compression (`.nar.zst`). Faster than xz with comparable + /// compression ratios; increasingly popular for newer caches. + Zstd, + /// Bzip2 compression (`.nar.bz2`). A legacy format still seen in some + /// older caches. + Bzip2, +} + +impl NarInfo { + /// Parse the line-oriented NARInfo text format into a [`NarInfo`] struct. + /// + /// The format is a series of `Key: Value` lines. Required fields are + /// `StorePath`, `URL`, `FileHash`, `FileSize`, `NarHash`, and `NarSize`. + /// If any required field is missing, a + /// [`CacheError::InvalidNarInfo`](crate::error::CacheError::InvalidNarInfo) + /// error is returned. + /// + /// Unknown keys are silently ignored so that forward-compatibility with + /// future Nix versions is preserved. + pub fn parse(input: &str) -> Result { + let mut store_path = None; + let mut url = None; + let mut compression = Compression::None; + let mut file_hash = None; + let mut file_size = None; + let mut nar_hash = None; + let mut nar_size = None; + let mut references = Vec::new(); + let mut deriver = None; + let mut sig = Vec::new(); + + for line in input.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + if let Some((key, value)) = line.split_once(": ") { + match key { + "StorePath" => store_path = Some(value.to_string()), + "URL" => url = Some(value.to_string()), + "Compression" => { + compression = match value { + "xz" => Compression::Xz, + "zstd" => Compression::Zstd, + "bzip2" => Compression::Bzip2, + "none" => Compression::None, + _ => Compression::None, + } + } + "FileHash" => file_hash = Some(value.to_string()), + "FileSize" => file_size = value.parse().ok(), + "NarHash" => nar_hash = Some(value.to_string()), + "NarSize" => nar_size = value.parse().ok(), + "References" => { + if !value.is_empty() { + references = + value.split_whitespace().map(String::from).collect(); + } + } + "Deriver" => deriver = Some(value.to_string()), + "Sig" => sig.push(value.to_string()), + _ => {} // ignore unknown fields for forward-compatibility + } + } + } + + Ok(NarInfo { + store_path: store_path.ok_or_else(|| { + crate::error::CacheError::InvalidNarInfo("missing StorePath".into()) + })?, + url: url.ok_or_else(|| { + crate::error::CacheError::InvalidNarInfo("missing URL".into()) + })?, + compression, + file_hash: file_hash.ok_or_else(|| { + crate::error::CacheError::InvalidNarInfo("missing FileHash".into()) + })?, + file_size: file_size.ok_or_else(|| { + crate::error::CacheError::InvalidNarInfo("missing FileSize".into()) + })?, + nar_hash: nar_hash.ok_or_else(|| { + crate::error::CacheError::InvalidNarInfo("missing NarHash".into()) + })?, + nar_size: nar_size.ok_or_else(|| { + crate::error::CacheError::InvalidNarInfo("missing NarSize".into()) + })?, + references, + deriver, + sig, + }) + } +} + +/// Serialises the [`NarInfo`] back into the canonical line-oriented text +/// format expected by Nix clients. +/// +/// The output is suitable for returning directly as the body of a +/// `GET /{storeHash}.narinfo` HTTP response. Optional fields (`References`, +/// `Deriver`, `Sig`) are only emitted when present. +impl fmt::Display for NarInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "StorePath: {}", self.store_path)?; + writeln!(f, "URL: {}", self.url)?; + writeln!( + f, + "Compression: {}", + match self.compression { + Compression::None => "none", + Compression::Xz => "xz", + Compression::Zstd => "zstd", + Compression::Bzip2 => "bzip2", + } + )?; + writeln!(f, "FileHash: {}", self.file_hash)?; + writeln!(f, "FileSize: {}", self.file_size)?; + writeln!(f, "NarHash: {}", self.nar_hash)?; + writeln!(f, "NarSize: {}", self.nar_size)?; + if !self.references.is_empty() { + writeln!(f, "References: {}", self.references.join(" "))?; + } + if let Some(ref deriver) = self.deriver { + writeln!(f, "Deriver: {}", deriver)?; + } + for s in &self.sig { + writeln!(f, "Sig: {}", s)?; + } + Ok(()) + } +} diff --git a/crates/jupiter-cache/src/routes.rs b/crates/jupiter-cache/src/routes.rs new file mode 100644 index 0000000..00d9d39 --- /dev/null +++ b/crates/jupiter-cache/src/routes.rs @@ -0,0 +1,158 @@ +//! Axum HTTP route handlers for the Nix binary cache protocol. +//! +//! This module exposes the standard Nix binary cache endpoints so that any +//! Nix client (or Hercules CI agent) can interact with Jupiter's built-in +//! cache exactly as it would with `cache.nixos.org` or any other +//! Nix-compatible binary cache. +//! +//! ## Endpoints +//! +//! | Method | Path | Handler | Purpose | +//! |--------|--------------------------------|--------------------|--------------------------------------------------| +//! | `GET` | `/nix-cache-info` | [`nix_cache_info`] | Return cache metadata (store dir, priority, etc.)| +//! | `GET` | `/{storeHash}.narinfo` | [`get_narinfo`] | Fetch NARInfo metadata for a store path hash | +//! | `PUT` | `/{storeHash}.narinfo` | [`put_narinfo`] | Upload NARInfo metadata (agent -> cache) | +//! | `GET` | `/nar/{filename}` | [`get_nar`] | Download a (possibly compressed) NAR archive | +//! +//! All handlers receive an `Arc` via Axum's shared state +//! mechanism. Use [`cache_routes`] to obtain a configured [`Router`] that +//! can be merged into the main Jupiter server. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::get, + Router, +}; +use std::sync::Arc; + +use crate::store::LocalStore; + +/// Build an Axum [`Router`] that serves the Nix binary cache protocol. +/// +/// The router expects an `Arc` as shared state. Callers +/// typically do: +/// +/// ```ignore +/// let store = Arc::new(LocalStore::new("/var/cache/jupiter", None).await?); +/// let app = cache_routes().with_state(store); +/// ``` +/// +/// The returned router can be nested under a sub-path or merged directly +/// into the top-level Jupiter application router. +pub fn cache_routes() -> Router> { + Router::new() + .route("/nix-cache-info", get(nix_cache_info)) + .route( + "/{store_hash}.narinfo", + get(get_narinfo).put(put_narinfo), + ) + .route("/nar/{filename}", get(get_nar)) +} + +/// `GET /nix-cache-info` -- return static cache metadata. +/// +/// This is the first endpoint a Nix client hits when it discovers a new +/// binary cache. The response is a simple key-value text format: +/// +/// * `StoreDir: /nix/store` -- the Nix store prefix (always `/nix/store`). +/// * `WantMassQuery: 1` -- tells the client it is OK to query many +/// paths at once (e.g. during `nix-store --query`). +/// * `Priority: 30` -- a hint for substitution ordering. Lower +/// numbers are preferred. 30 is a reasonable middle-ground that lets +/// upstream caches (priority 10-20) take precedence when configured. +async fn nix_cache_info() -> impl IntoResponse { + ( + StatusCode::OK, + [("Content-Type", "text/x-nix-cache-info")], + "StoreDir: /nix/store\nWantMassQuery: 1\nPriority: 30\n", + ) +} + +/// `GET /{store_hash}.narinfo` -- look up NARInfo by store-path hash. +/// +/// `store_hash` is the 32-character base-32 hash that identifies a Nix +/// store path (the portion between `/nix/store/` and the first `-`). +/// +/// On success the response has content-type `text/x-nix-narinfo` and +/// contains the NARInfo in its canonical text representation. If the hash +/// is not present in the cache, a plain 404 is returned so that the Nix +/// client can fall through to the next configured substituter. +async fn get_narinfo( + State(store): State>, + Path(store_hash): Path, +) -> impl IntoResponse { + match store.get_narinfo(&store_hash).await { + Ok(narinfo) => ( + StatusCode::OK, + [("Content-Type", "text/x-nix-narinfo")], + narinfo.to_string(), + ) + .into_response(), + Err(_) => StatusCode::NOT_FOUND.into_response(), + } +} + +/// `PUT /{store_hash}.narinfo` -- upload NARInfo metadata. +/// +/// Hercules CI agents call this endpoint after building a derivation to +/// publish the artefact metadata to the shared cache. The request body +/// must be a valid NARInfo text document. The handler parses it to validate +/// correctness before persisting it to disk via +/// [`LocalStore::put_narinfo`](crate::store::LocalStore::put_narinfo). +/// +/// ## Response codes +/// +/// * `200 OK` -- the NARInfo was stored successfully. +/// * `400 Bad Request` -- the body could not be parsed as valid NARInfo. +/// * `500 Internal Server Error` -- an I/O error occurred while writing. +async fn put_narinfo( + State(store): State>, + Path(store_hash): Path, + body: String, +) -> impl IntoResponse { + match crate::narinfo::NarInfo::parse(&body) { + Ok(narinfo) => match store.put_narinfo(&store_hash, &narinfo).await { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }, + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +/// `GET /nar/{filename}` -- download a NAR archive. +/// +/// `filename` has the form `.nar[.xz|.zst]`. The handler strips +/// the hash from the filename, delegates to +/// [`LocalStore::get_nar`](crate::store::LocalStore::get_nar) to read the +/// raw bytes from disk, and returns them with the appropriate `Content-Type`: +/// +/// | Extension | Content-Type | +/// |-----------|--------------------------| +/// | `.xz` | `application/x-xz` | +/// | `.zst` | `application/zstd` | +/// | (none) | `application/x-nix-nar` | +/// +/// No server-side decompression is performed; the Nix client handles that +/// based on the `Compression` field in the corresponding NARInfo. +async fn get_nar( + State(store): State>, + Path(filename): Path, +) -> impl IntoResponse { + // Extract hash from filename (e.g., "abc123.nar.xz" -> "abc123") + let nar_hash = filename.split('.').next().unwrap_or(&filename); + match store.get_nar(nar_hash).await { + Ok(data) => { + let content_type = if filename.ends_with(".xz") { + "application/x-xz" + } else if filename.ends_with(".zst") { + "application/zstd" + } else { + "application/x-nix-nar" + }; + (StatusCode::OK, [("Content-Type", content_type)], data).into_response() + } + Err(_) => StatusCode::NOT_FOUND.into_response(), + } +} diff --git a/crates/jupiter-cache/src/store.rs b/crates/jupiter-cache/src/store.rs new file mode 100644 index 0000000..17ac441 --- /dev/null +++ b/crates/jupiter-cache/src/store.rs @@ -0,0 +1,178 @@ +//! On-disk storage backend for the Nix binary cache. +//! +//! [`LocalStore`] persists NARInfo files and NAR archives to a local +//! directory. The on-disk layout mirrors the URL scheme of the binary cache +//! HTTP protocol: +//! +//! ```text +//! / +//! aaaa....narinfo # NARInfo for store hash "aaaa..." +//! bbbb....narinfo # NARInfo for store hash "bbbb..." +//! nar/ +//! 1b2m2y0h....nar.xz # Compressed NAR archive +//! cdef5678....nar.zst # Another archive, zstd-compressed +//! abcd1234....nar # Uncompressed NAR archive +//! ``` +//! +//! This layout means the cache directory can also be served by any static +//! file HTTP server (e.g. nginx) if desired, without any application logic. +//! +//! ## Concurrency +//! +//! All public methods are `async` and use `tokio::fs` for non-blocking I/O. +//! The struct is designed to be wrapped in an `Arc` and shared across Axum +//! handler tasks. + +use std::path::PathBuf; +use tokio::fs; + +use crate::error::CacheError; +use crate::narinfo::NarInfo; + +/// A filesystem-backed Nix binary cache store. +/// +/// Holds NARInfo metadata files and NAR archives in a directory tree whose +/// layout is compatible with the standard Nix binary cache HTTP protocol. +/// An optional maximum size (in gigabytes) can be specified at construction +/// time to cap disk usage. +/// +/// # Usage within Jupiter +/// +/// When the built-in cache feature is enabled, a `LocalStore` is created at +/// server startup, wrapped in an `Arc`, and passed as Axum shared state to +/// the route handlers in [`crate::routes`]. Hercules CI agents can then +/// `PUT` NARInfo files and NAR archives into the cache and `GET` them back +/// when they (or other agents / developer workstations) need to fetch build +/// artefacts. +pub struct LocalStore { + /// Root directory of the cache on disk. + path: PathBuf, + /// Optional disk-usage cap, in bytes. Derived from the `max_size_gb` + /// constructor parameter. Currently stored for future enforcement; + /// the quota-checking logic is not yet wired up. + #[allow(dead_code)] + max_size_bytes: Option, +} + +impl LocalStore { + /// Create or open a [`LocalStore`] rooted at `path`. + /// + /// The constructor ensures that both the root directory and the `nar/` + /// subdirectory exist (creating them if necessary). The optional + /// `max_size_gb` parameter sets an upper bound on total disk usage; pass + /// `None` for an unbounded cache. + /// + /// # Errors + /// + /// Returns [`CacheError::Io`] if the directories cannot be created. + pub async fn new( + path: impl Into, + max_size_gb: Option, + ) -> Result { + let path = path.into(); + fs::create_dir_all(&path).await?; + fs::create_dir_all(path.join("nar")).await?; + Ok(Self { + path, + max_size_bytes: max_size_gb.map(|gb| gb * 1024 * 1024 * 1024), + }) + } + + /// Retrieve and parse the NARInfo for a given store hash. + /// + /// `store_hash` is the first 32 base-32 characters of a Nix store path + /// (everything between `/nix/store/` and the first `-`). The method + /// reads `/.narinfo` from disk and parses it into a + /// [`NarInfo`] struct. + /// + /// # Errors + /// + /// * [`CacheError::NotFound`] -- the `.narinfo` file does not exist. + /// * [`CacheError::InvalidNarInfo`] -- the file exists but cannot be parsed. + pub async fn get_narinfo(&self, store_hash: &str) -> Result { + let path = self.path.join(format!("{}.narinfo", store_hash)); + let content = fs::read_to_string(&path) + .await + .map_err(|_| CacheError::NotFound(store_hash.to_string()))?; + NarInfo::parse(&content) + } + + /// Write a NARInfo file for the given store hash. + /// + /// Serialises `narinfo` using its [`Display`](std::fmt::Display) + /// implementation and writes it to `/.narinfo`. If a + /// narinfo for this hash already exists it is silently overwritten. + /// + /// This is the server-side handler for `PUT /{storeHash}.narinfo` -- + /// Hercules CI agents call this after building a derivation to publish + /// the artefact metadata to the shared cache. + /// + /// # Errors + /// + /// Returns [`CacheError::Io`] if the file cannot be written. + pub async fn put_narinfo( + &self, + store_hash: &str, + narinfo: &NarInfo, + ) -> Result<(), CacheError> { + let path = self.path.join(format!("{}.narinfo", store_hash)); + fs::write(&path, narinfo.to_string()).await?; + Ok(()) + } + + /// Read a NAR archive from disk. + /// + /// Because the archive may be stored with any compression extension, this + /// method probes for the file with no extension, `.xz`, and `.zst` in + /// that order. The first match wins. The raw bytes are returned without + /// any decompression -- the HTTP response will carry the appropriate + /// `Content-Type` header so the Nix client knows how to handle it. + /// + /// `nar_hash` is the content-address portion of the filename (the part + /// before `.nar`). + /// + /// # Errors + /// + /// * [`CacheError::NotFound`] -- no file matches any of the probed extensions. + /// * [`CacheError::Io`] -- the file exists but could not be read. + pub async fn get_nar(&self, nar_hash: &str) -> Result, CacheError> { + // Try multiple extensions to support all compression variants that + // may have been uploaded. + for ext in &["", ".xz", ".zst"] { + let path = self + .path + .join("nar") + .join(format!("{}.nar{}", nar_hash, ext)); + if path.exists() { + return fs::read(&path).await.map_err(CacheError::Io); + } + } + Err(CacheError::NotFound(nar_hash.to_string())) + } + + /// Write a NAR archive to the `nar/` subdirectory. + /// + /// `filename` should include the full name with extension, e.g. + /// `1b2m2y0h...nar.xz`. The compression variant is implicit in the + /// extension; no server-side (de)compression is performed. + /// + /// # Errors + /// + /// Returns [`CacheError::Io`] if the file cannot be written. + pub async fn put_nar(&self, filename: &str, data: &[u8]) -> Result<(), CacheError> { + let path = self.path.join("nar").join(filename); + fs::write(&path, data).await?; + Ok(()) + } + + /// Check whether a NARInfo file exists for the given store hash. + /// + /// This is a lightweight existence check (no parsing) useful for + /// short-circuiting duplicate uploads. Returns `true` if + /// `/.narinfo` is present on disk. + pub async fn has_narinfo(&self, store_hash: &str) -> bool { + self.path + .join(format!("{}.narinfo", store_hash)) + .exists() + } +} diff --git a/crates/jupiter-cli/Cargo.toml b/crates/jupiter-cli/Cargo.toml new file mode 100644 index 0000000..7596513 --- /dev/null +++ b/crates/jupiter-cli/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "jupiter-cli" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "jupiter-ctl" +path = "src/main.rs" + +[dependencies] +jupiter-api-types = { workspace = true } +reqwest = { workspace = true } +clap = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +uuid = { workspace = true } diff --git a/crates/jupiter-cli/src/main.rs b/crates/jupiter-cli/src/main.rs new file mode 100644 index 0000000..ca57771 --- /dev/null +++ b/crates/jupiter-cli/src/main.rs @@ -0,0 +1,761 @@ +//! # jupiter-ctl -- Admin CLI for the Jupiter CI Server +//! +//! `jupiter-ctl` is the administrative command-line interface for +//! [Jupiter](https://github.com/example/jupiter), a self-hosted, +//! wire-compatible replacement for [hercules-ci.com](https://hercules-ci.com). +//! It is analogous to the upstream `hci` CLI provided by Hercules CI, but +//! targets the Jupiter server's REST API instead. +//! +//! ## Architecture +//! +//! The CLI is built with [clap 4](https://docs.rs/clap/4) using derive macros. +//! Every top-level subcommand maps directly to a resource in the Jupiter REST +//! API (`/api/v1/...`): +//! +//! | Subcommand | API resource | Purpose | +//! |-------------|---------------------------------------|------------------------------------------| +//! | `account` | `/api/v1/accounts` | Create, list, inspect accounts | +//! | `agent` | `/api/v1/agents` | List and inspect connected build agents | +//! | `project` | `/api/v1/projects` | CRUD and enable/disable projects | +//! | `job` | `/api/v1/projects/{id}/jobs`, `/jobs` | List, inspect, rerun, cancel CI jobs | +//! | `state` | `/api/v1/projects/{id}/state` | Binary upload/download of state files | +//! | `token` | `/api/v1/.../clusterJoinTokens` | Manage cluster join tokens for agents | +//! | `health` | `/api/v1/health` | Quick server liveness/readiness check | +//! +//! ## Authentication +//! +//! All requests are authenticated with a bearer token. The token can be +//! supplied via the `--token` flag or the `JUPITER_TOKEN` environment +//! variable. Tokens are issued by the Jupiter server through +//! `POST /api/v1/auth/token` (or stored from a previous session). +//! +//! ## Intended audience +//! +//! This tool is designed for **server administrators**, not end users. It +//! provides unrestricted access to every server management operation +//! exposed by the Jupiter REST API. + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use reqwest::Client; +use std::io::Write; + +/// Top-level CLI definition for `jupiter-ctl`. +/// +/// Parsed by clap from command-line arguments. The two global options -- +/// `--server` and `--token` -- configure the [`ApiClient`] that every +/// subcommand uses to talk to the Jupiter server. +/// +/// # Examples +/// +/// ```bash +/// # Check server health (uses defaults: localhost:3000, no token) +/// jupiter-ctl health +/// +/// # List all projects with an explicit server and token +/// jupiter-ctl --server https://ci.example.com --token $TOK project list +/// ``` +#[derive(Parser)] +#[command(name = "jupiter-ctl", about = "Jupiter CI admin CLI")] +struct Cli { + /// Base URL of the Jupiter server (scheme + host + port). + /// + /// Defaults to `http://localhost:3000`. Can also be set via the + /// `JUPITER_URL` environment variable. + #[arg(long, env = "JUPITER_URL", default_value = "http://localhost:3000")] + server: String, + + /// Bearer token used to authenticate API requests. + /// + /// Obtained from `POST /api/v1/auth/token` or stored from a previous + /// session. Can also be set via the `JUPITER_TOKEN` environment + /// variable. If omitted, requests are sent without authentication + /// (only useful for unauthenticated endpoints such as `health`). + #[arg(long, env = "JUPITER_TOKEN")] + token: Option, + + #[command(subcommand)] + command: Commands, +} + +/// Top-level subcommands, each corresponding to a major REST API resource. +/// +/// The structure mirrors the Jupiter server's REST API so that administrators +/// can perform any server-side operation from the command line. +#[derive(Subcommand)] +enum Commands { + /// Account management -- create, list, and inspect accounts. + /// + /// Accounts are the top-level organizational unit in Hercules CI (and + /// therefore Jupiter). Projects, agents, and cluster join tokens all + /// belong to an account. Maps to `GET/POST /api/v1/accounts`. + Account { + #[command(subcommand)] + action: AccountAction, + }, + + /// Agent management -- list and inspect connected build agents. + /// + /// Agents are the hercules-ci-agent processes that connect to the + /// Jupiter server to pick up and execute CI jobs. This subcommand is + /// read-only; agent lifecycle is managed by the agents themselves. + /// Maps to `GET /api/v1/agents`. + Agent { + #[command(subcommand)] + action: AgentAction, + }, + + /// Project management -- full CRUD plus enable/disable toggle. + /// + /// A project links an account to a source repository. When enabled, + /// pushes to the repository trigger evaluation and build jobs. + /// Maps to `GET/POST /api/v1/projects`. + Project { + #[command(subcommand)] + action: ProjectAction, + }, + + /// Job management -- list, inspect, rerun, and cancel CI jobs. + /// + /// Jobs represent individual evaluation or build tasks dispatched to + /// agents. They belong to a project and are created automatically on + /// push events. Maps to `GET/POST /api/v1/jobs` and + /// `GET /api/v1/projects/{id}/jobs`. + Job { + #[command(subcommand)] + action: JobAction, + }, + + /// State file management -- list, download, and upload binary state. + /// + /// Hercules CI effects can persist arbitrary binary data between runs + /// using "state files". This subcommand exposes the upload/download + /// endpoints so administrators can inspect or seed state data. + /// Maps to `GET/PUT /api/v1/projects/{id}/state/{name}/data`. + State { + #[command(subcommand)] + action: StateAction, + }, + + /// Cluster join token management -- create, list, and revoke tokens. + /// + /// Cluster join tokens authorize new hercules-ci-agent instances to + /// connect to the Jupiter server under a specific account. They are + /// analogous to the tokens generated in the Hercules CI dashboard. + /// Maps to `GET/POST /api/v1/accounts/{id}/clusterJoinTokens` and + /// `DELETE /api/v1/cluster-join-tokens/{id}`. + Token { + #[command(subcommand)] + action: TokenAction, + }, + + /// Server health check. + /// + /// Performs a simple `GET /api/v1/health` request and prints the JSON + /// response. Useful for verifying that the Jupiter server is running + /// and reachable. Does not require authentication. + Health, +} + +// --------------------------------------------------------------------------- +// Account subcommands +// --------------------------------------------------------------------------- + +/// Actions available under `jupiter-ctl account`. +/// +/// Maps to the `/api/v1/accounts` REST resource. +#[derive(Subcommand)] +enum AccountAction { + /// Create a new account. + /// + /// Sends `POST /api/v1/accounts` with `{ "name": "" }`. + /// Prints the created account object (including its server-assigned ID). + Create { name: String }, + + /// List all accounts. + /// + /// Sends `GET /api/v1/accounts` and prints the JSON array of accounts. + List, + + /// Get details for a single account by ID. + /// + /// Sends `GET /api/v1/accounts/{id}` and prints the account object. + Get { id: String }, +} + +// --------------------------------------------------------------------------- +// Agent subcommands +// --------------------------------------------------------------------------- + +/// Actions available under `jupiter-ctl agent`. +/// +/// Agents are read-only from the CLI's perspective. Their lifecycle is +/// controlled by the hercules-ci-agent processes themselves; the server +/// merely tracks their state. Maps to `/api/v1/agents`. +#[derive(Subcommand)] +enum AgentAction { + /// List all connected agents. + /// + /// Sends `GET /api/v1/agents` and prints the JSON array of agents. + List, + + /// Get details for a single agent by ID. + /// + /// Sends `GET /api/v1/agents/{id}` and prints the agent object, + /// including hostname, platform capabilities, and connection status. + Get { id: String }, +} + +// --------------------------------------------------------------------------- +// Project subcommands +// --------------------------------------------------------------------------- + +/// Actions available under `jupiter-ctl project`. +/// +/// Projects tie an account to a source repository and control whether CI +/// jobs are created on push events. Maps to `/api/v1/projects`. +#[derive(Subcommand)] +enum ProjectAction { + /// Create a new project. + /// + /// Sends `POST /api/v1/projects` with the account ID, repository ID, + /// and display name. The repository ID is the forge-specific + /// identifier (e.g. GitHub repo ID). + Create { + /// Account that owns this project. + #[arg(long)] + account_id: String, + /// Forge-specific repository identifier. + #[arg(long)] + repo_id: String, + /// Human-readable project name. + #[arg(long)] + name: String, + }, + + /// List all projects. + /// + /// Sends `GET /api/v1/projects` and prints the JSON array. + List, + + /// Get details for a single project by ID. + /// + /// Sends `GET /api/v1/projects/{id}`. + Get { id: String }, + + /// Enable a project so that pushes trigger CI jobs. + /// + /// Sends `POST /api/v1/projects/{id}` with `{ "enabled": true }`. + Enable { id: String }, + + /// Disable a project so that pushes no longer trigger CI jobs. + /// + /// Sends `POST /api/v1/projects/{id}` with `{ "enabled": false }`. + Disable { id: String }, +} + +// --------------------------------------------------------------------------- +// Job subcommands +// --------------------------------------------------------------------------- + +/// Actions available under `jupiter-ctl job`. +/// +/// Jobs represent evaluation or build work dispatched to agents. Maps to +/// `/api/v1/jobs` and `/api/v1/projects/{id}/jobs`. +#[derive(Subcommand)] +enum JobAction { + /// List jobs for a specific project (paginated). + /// + /// Sends `GET /api/v1/projects/{project_id}/jobs?page={page}`. + List { + /// Project whose jobs to list. + #[arg(long)] + project_id: String, + /// Page number (1-indexed). Defaults to 1. + #[arg(long, default_value = "1")] + page: u64, + }, + + /// Get details for a single job by ID. + /// + /// Sends `GET /api/v1/jobs/{id}` and prints the job object, including + /// status, timestamps, and associated evaluation results. + Get { id: String }, + + /// Re-run a previously completed (or failed) job. + /// + /// Sends `POST /api/v1/jobs/{id}/rerun`. The server will create a new + /// job execution with the same parameters. + Rerun { id: String }, + + /// Cancel a currently running job. + /// + /// Sends `POST /api/v1/jobs/{id}/cancel`. The agent executing the + /// job will be notified to abort. + Cancel { id: String }, +} + +// --------------------------------------------------------------------------- +// State subcommands +// --------------------------------------------------------------------------- + +/// Actions available under `jupiter-ctl state`. +/// +/// State files are opaque binary blobs that Hercules CI effects can +/// persist between runs. For example, a deployment effect might store a +/// Terraform state file. The state API uses `application/octet-stream` +/// for upload and download rather than JSON. +/// +/// Maps to `/api/v1/projects/{id}/states` (listing) and +/// `/api/v1/projects/{id}/state/{name}/data` (get/put). +#[derive(Subcommand)] +enum StateAction { + /// List all state files for a project. + /// + /// Sends `GET /api/v1/projects/{project_id}/states` and prints the + /// JSON array of state file metadata. + List { + /// Project whose state files to list. + #[arg(long)] + project_id: String, + }, + + /// Download a state file (binary). + /// + /// Sends `GET /api/v1/projects/{project_id}/state/{name}/data`. + /// The raw bytes are written to `--output` if specified, otherwise + /// they are written directly to stdout. This allows piping into + /// other tools (e.g. `jupiter-ctl state get ... | tar xz`). + Get { + /// Project that owns the state file. + #[arg(long)] + project_id: String, + /// Logical name of the state file (as used in the Hercules CI effect). + #[arg(long)] + name: String, + /// Output file path. If omitted, raw bytes are written to stdout. + #[arg(long)] + output: Option, + }, + + /// Upload (create or replace) a state file (binary). + /// + /// Reads the file at `--input` and sends its contents as + /// `PUT /api/v1/projects/{project_id}/state/{name}/data` with + /// `Content-Type: application/octet-stream`. + Put { + /// Project that owns the state file. + #[arg(long)] + project_id: String, + /// Logical name of the state file. + #[arg(long)] + name: String, + /// Path to the local file whose contents will be uploaded. + #[arg(long)] + input: String, + }, +} + +// --------------------------------------------------------------------------- +// Token subcommands +// --------------------------------------------------------------------------- + +/// Actions available under `jupiter-ctl token`. +/// +/// Cluster join tokens authorize hercules-ci-agent instances to register +/// with the Jupiter server under a specific account. An agent presents +/// this token during its initial handshake; the server then associates +/// the agent with the account. +/// +/// Maps to `/api/v1/accounts/{id}/clusterJoinTokens` and +/// `/api/v1/cluster-join-tokens/{id}`. +#[derive(Subcommand)] +enum TokenAction { + /// Create a new cluster join token for an account. + /// + /// Sends `POST /api/v1/accounts/{account_id}/clusterJoinTokens` + /// with `{ "name": "" }`. The response includes the raw token + /// value -- this is the only time it is returned in cleartext. + Create { + /// Account the token belongs to. + #[arg(long)] + account_id: String, + /// Human-readable label for the token. + #[arg(long)] + name: String, + }, + + /// List all cluster join tokens for an account. + /// + /// Sends `GET /api/v1/accounts/{account_id}/clusterJoinTokens`. + /// Note: the raw token values are **not** included in the listing for + /// security reasons. + List { + /// Account whose tokens to list. + #[arg(long)] + account_id: String, + }, + + /// Revoke (delete) a cluster join token by ID. + /// + /// Sends `DELETE /api/v1/cluster-join-tokens/{id}`. Any agent that + /// was using this token will be unable to re-authenticate after its + /// current session expires. + Revoke { id: String }, +} + +// --------------------------------------------------------------------------- +// API client +// --------------------------------------------------------------------------- + +/// HTTP client wrapper for the Jupiter REST API. +/// +/// `ApiClient` encapsulates a [`reqwest::Client`], the server base URL, and +/// an optional bearer token. All subcommand handlers use this struct to +/// issue HTTP requests against the Jupiter server. +/// +/// The client provides convenience methods for common request patterns: +/// +/// - [`get_json`](Self::get_json) / [`post_json`](Self::post_json) -- +/// JSON request/response for most CRUD operations. +/// - [`get_bytes`](Self::get_bytes) / [`put_bytes`](Self::put_bytes) -- +/// raw binary transfer for state file operations. +/// - [`delete`](Self::delete) -- resource deletion (e.g. token revocation). +/// +/// Every method checks the HTTP status code and returns an [`anyhow::Error`] +/// with the status and response body on non-2xx responses. +struct ApiClient { + /// Underlying HTTP client (connection pool, TLS, etc.). + client: Client, + /// Base URL of the Jupiter server, e.g. `http://localhost:3000`. + base_url: String, + /// Optional bearer token for authentication. When `Some`, it is + /// attached to every outgoing request as an `Authorization: Bearer` + /// header. + token: Option, +} + +impl ApiClient { + /// Create a new `ApiClient` targeting the given server URL. + /// + /// If `token` is `Some`, all requests will include an + /// `Authorization: Bearer ` header. + fn new(base_url: String, token: Option) -> Self { + Self { + client: Client::new(), + base_url, + token, + } + } + + /// Build an absolute URL for the given API path. + /// + /// Joins the base URL with `/api/v1` and the provided `path`. + /// Trailing slashes on the base URL are normalized to avoid double + /// slashes. + /// + /// # Example + /// + /// ```text + /// base_url = "http://localhost:3000/" + /// path = "/accounts" + /// result = "http://localhost:3000/api/v1/accounts" + /// ``` + fn url(&self, path: &str) -> String { + format!("{}/api/v1{}", self.base_url.trim_end_matches('/'), path) + } + + /// Start building an HTTP request with the given method and API path. + /// + /// The bearer token (if present) is automatically attached. Callers + /// can further customize the [`reqwest::RequestBuilder`] before + /// sending (e.g. adding a JSON body or custom headers). + fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder { + let mut req = self.client.request(method, self.url(path)); + if let Some(ref token) = self.token { + req = req.bearer_auth(token); + } + req + } + + /// Send a `GET` request and deserialize the response as JSON. + /// + /// Returns `Err` if the server responds with a non-2xx status code + /// (the error message includes both the status and the response body). + async fn get_json(&self, path: &str) -> Result { + let resp = self.request(reqwest::Method::GET, path).send().await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("HTTP {}: {}", status, body); + } + Ok(resp.json().await?) + } + + /// Send a `POST` request with a JSON body and deserialize the JSON + /// response. + /// + /// Used for creating resources (accounts, projects, tokens) and for + /// triggering actions (rerun, cancel, enable/disable). + /// + /// Returns `Err` on non-2xx status codes. + async fn post_json(&self, path: &str, body: &serde_json::Value) -> Result { + let resp = self + .request(reqwest::Method::POST, path) + .json(body) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("HTTP {}: {}", status, body); + } + Ok(resp.json().await?) + } + + /// Send a `DELETE` request. Expects no response body. + /// + /// Currently used only for revoking cluster join tokens + /// (`DELETE /api/v1/cluster-join-tokens/{id}`). + /// + /// Returns `Err` on non-2xx status codes. + async fn delete(&self, path: &str) -> Result<()> { + let resp = self + .request(reqwest::Method::DELETE, path) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("HTTP {}: {}", status, body); + } + Ok(()) + } + + /// Send a `PUT` request with a raw binary body + /// (`Content-Type: application/octet-stream`). + /// + /// Used to upload state file data. The Jupiter server stores the + /// bytes verbatim and makes them available for subsequent downloads. + /// + /// Returns `Err` on non-2xx status codes. + async fn put_bytes(&self, path: &str, data: Vec) -> Result<()> { + let resp = self + .request(reqwest::Method::PUT, path) + .header("content-type", "application/octet-stream") + .body(data) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("HTTP {}: {}", status, body); + } + Ok(()) + } + + /// Send a `GET` request and return the response as raw bytes. + /// + /// Used to download state file data. The response is returned as an + /// owned `Vec` without any deserialization. + /// + /// Returns `Err` on non-2xx status codes. + async fn get_bytes(&self, path: &str) -> Result> { + let resp = self.request(reqwest::Method::GET, path).send().await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("HTTP {}: {}", status, body); + } + Ok(resp.bytes().await?.to_vec()) + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +/// Application entry point. +/// +/// Parses CLI arguments via clap, constructs an [`ApiClient`] from the +/// global `--server` and `--token` options, then dispatches to the +/// appropriate handler based on the selected subcommand. +/// +/// All subcommand handlers follow the same pattern: +/// 1. Build the API path from the subcommand arguments. +/// 2. Call the matching [`ApiClient`] method (`get_json`, `post_json`, +/// `delete`, `get_bytes`, or `put_bytes`). +/// 3. Pretty-print the JSON response to stdout (or write raw bytes for +/// state file downloads). +/// +/// Errors from the HTTP layer or JSON serialization are propagated via +/// `anyhow` and printed to stderr by the tokio runtime. +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + let api = ApiClient::new(cli.server, cli.token); + + match cli.command { + Commands::Health => { + let resp = api.get_json("/health").await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + Commands::Account { action } => match action { + AccountAction::Create { name } => { + let resp = api + .post_json("/accounts", &serde_json::json!({ "name": name })) + .await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + AccountAction::List => { + let resp = api.get_json("/accounts").await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + AccountAction::Get { id } => { + let resp = api.get_json(&format!("/accounts/{}", id)).await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + }, + Commands::Agent { action } => match action { + AgentAction::List => { + let resp = api.get_json("/agents").await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + AgentAction::Get { id } => { + let resp = api.get_json(&format!("/agents/{}", id)).await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + }, + Commands::Project { action } => match action { + ProjectAction::Create { + account_id, + repo_id, + name, + } => { + let resp = api + .post_json( + "/projects", + &serde_json::json!({ + "accountId": account_id, + "repoId": repo_id, + "name": name, + }), + ) + .await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + ProjectAction::List => { + let resp = api.get_json("/projects").await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + ProjectAction::Get { id } => { + let resp = api.get_json(&format!("/projects/{}", id)).await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + ProjectAction::Enable { id } => { + let resp = api + .post_json( + &format!("/projects/{}", id), + &serde_json::json!({ "enabled": true }), + ) + .await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + ProjectAction::Disable { id } => { + let resp = api + .post_json( + &format!("/projects/{}", id), + &serde_json::json!({ "enabled": false }), + ) + .await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + }, + Commands::Job { action } => match action { + JobAction::List { project_id, page } => { + let resp = api + .get_json(&format!("/projects/{}/jobs?page={}", project_id, page)) + .await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + JobAction::Get { id } => { + let resp = api.get_json(&format!("/jobs/{}", id)).await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + JobAction::Rerun { id } => { + let resp = api + .post_json(&format!("/jobs/{}/rerun", id), &serde_json::json!({})) + .await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + JobAction::Cancel { id } => { + let resp = api + .post_json(&format!("/jobs/{}/cancel", id), &serde_json::json!({})) + .await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + }, + Commands::State { action } => match action { + StateAction::List { project_id } => { + let resp = api + .get_json(&format!("/projects/{}/states", project_id)) + .await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + StateAction::Get { + project_id, + name, + output, + } => { + let data = api + .get_bytes(&format!( + "/projects/{}/state/{}/data", + project_id, name + )) + .await?; + if let Some(path) = output { + tokio::fs::write(&path, &data).await?; + println!("Written {} bytes to {}", data.len(), path); + } else { + std::io::stdout().write_all(&data)?; + } + } + StateAction::Put { + project_id, + name, + input, + } => { + let data = tokio::fs::read(&input).await?; + api.put_bytes( + &format!("/projects/{}/state/{}/data", project_id, name), + data, + ) + .await?; + println!("State '{}' updated", name); + } + }, + Commands::Token { action } => match action { + TokenAction::Create { account_id, name } => { + let resp = api + .post_json( + &format!("/accounts/{}/clusterJoinTokens", account_id), + &serde_json::json!({ "name": name }), + ) + .await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + TokenAction::List { account_id } => { + let resp = api + .get_json(&format!("/accounts/{}/clusterJoinTokens", account_id)) + .await?; + println!("{}", serde_json::to_string_pretty(&resp)?); + } + TokenAction::Revoke { id } => { + api.delete(&format!("/cluster-join-tokens/{}", id)).await?; + println!("Token revoked"); + } + }, + } + + Ok(()) +} diff --git a/crates/jupiter-db/Cargo.toml b/crates/jupiter-db/Cargo.toml new file mode 100644 index 0000000..88e1705 --- /dev/null +++ b/crates/jupiter-db/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "jupiter-db" +version.workspace = true +edition.workspace = true + +[dependencies] +jupiter-api-types = { workspace = true } +sqlx = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +tracing = { workspace = true } diff --git a/crates/jupiter-db/migrations/20240101000000_initial.sql b/crates/jupiter-db/migrations/20240101000000_initial.sql new file mode 100644 index 0000000..6faee00 --- /dev/null +++ b/crates/jupiter-db/migrations/20240101000000_initial.sql @@ -0,0 +1,337 @@ +-- ======================================================================= +-- Jupiter initial schema +-- ======================================================================= +-- +-- This migration creates the complete data model for Jupiter, a +-- self-hosted, wire-compatible replacement for hercules-ci.com. +-- +-- The schema mirrors the Hercules CI object hierarchy: +-- +-- Account -> Project -> Job -> [Attributes, Builds, Effects] +-- +-- Key design choices: +-- - All IDs are UUIDv4 stored as TEXT (SQLite has no native UUID type). +-- - All timestamps are TEXT in UTC "YYYY-MM-DD HH:MM:SS" format. +-- - Booleans are INTEGER 0/1 (SQLite convention). +-- - Structured data (JSON arrays/objects) are stored as TEXT and +-- serialized/deserialized at the application layer. +-- - Foreign keys enforce referential integrity (requires PRAGMA +-- foreign_keys=ON at connection time). +-- ======================================================================= + +-- ── Accounts ───────────────────────────────────────────────────────── +-- Top-level ownership entity. Every project, join token, and agent +-- session belongs to exactly one account. In Hercules CI an account +-- can be a "user" or an "organization". +CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL UNIQUE, -- Human-readable display name; also used for login. + account_type TEXT NOT NULL DEFAULT 'user', -- 'user' | 'organization' + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ── Cluster Join Tokens ────────────────────────────────────────────── +-- Bearer tokens that hercules-ci-agent presents during the WebSocket +-- handshake. Only the bcrypt hash is stored; the raw token is shown +-- to the admin once at creation time and never persisted. +CREATE TABLE IF NOT EXISTS cluster_join_tokens ( + id TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL REFERENCES accounts(id), -- Owning account; agent inherits this identity. + name TEXT NOT NULL, -- Admin-friendly label (e.g., "prod-agent-1"). + token_hash TEXT NOT NULL, -- bcrypt hash of the raw bearer token. + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ── Forges ─────────────────────────────────────────────────────────── +-- A forge is an external code-hosting platform (GitHub, Gitea, etc.). +-- Webhook secrets and API credentials are stored in `config` (JSON). +CREATE TABLE IF NOT EXISTS forges ( + id TEXT PRIMARY KEY NOT NULL, + forge_type TEXT NOT NULL, -- 'github' | 'gitea' | etc. + config TEXT NOT NULL, -- JSON blob with API URL, webhook secret, tokens, etc. + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ── Repos ──────────────────────────────────────────────────────────── +-- Mirror of a repository on a forge. Stores the clone URL and default +-- branch so agents know where to fetch code. +-- UNIQUE(forge_id, owner, name) prevents duplicate registrations of +-- the same repo from different webhook deliveries. +CREATE TABLE IF NOT EXISTS repos ( + id TEXT PRIMARY KEY NOT NULL, + forge_id TEXT NOT NULL REFERENCES forges(id), -- Which forge this repo lives on. + owner TEXT NOT NULL, -- GitHub/Gitea user or org owning the repo. + name TEXT NOT NULL, -- Repository name (without owner prefix). + clone_url TEXT NOT NULL, -- HTTPS or SSH clone URL. + default_branch TEXT NOT NULL DEFAULT 'main', -- Used to decide if a push triggers effects. + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(forge_id, owner, name) +); + +-- ── Projects ───────────────────────────────────────────────────────── +-- A project binds an account to a repo. It is the primary grouping +-- entity for jobs, secrets, state files, and schedules. +-- `enabled` controls whether incoming webhooks create jobs. +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL REFERENCES accounts(id), -- Owning account. + repo_id TEXT NOT NULL REFERENCES repos(id), -- Backing repository. + name TEXT NOT NULL UNIQUE, -- Human-readable project name. + enabled INTEGER NOT NULL DEFAULT 1, -- 1 = active, 0 = paused. + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ── Agent Sessions ─────────────────────────────────────────────────── +-- Each connected hercules-ci-agent has one row here. The session +-- records the agent's self-reported capabilities so the scheduler can +-- match tasks to capable agents. +-- +-- `platforms` is a JSON array of Nix system strings, e.g., +-- ["x86_64-linux", "aarch64-linux"]. +-- `system_features` is a JSON array of required features, e.g., +-- ["kvm", "big-parallel"]. +CREATE TABLE IF NOT EXISTS agent_sessions ( + id TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL REFERENCES accounts(id), -- Account the agent authenticated as. + hostname TEXT NOT NULL, -- Self-reported hostname. + platforms TEXT NOT NULL, -- JSON array of Nix system strings. + system_features TEXT NOT NULL DEFAULT '[]', -- JSON array of system feature strings. + concurrency INTEGER NOT NULL DEFAULT 2, -- Max parallel builds this agent supports. + agent_version TEXT, -- Agent software version (informational). + nix_version TEXT, -- Nix version (informational). + connected_at TEXT NOT NULL DEFAULT (datetime('now')), -- When the WebSocket session started. + last_heartbeat TEXT NOT NULL DEFAULT (datetime('now')) -- Updated on each keepalive ping. +); + +-- ── Jobs ───────────────────────────────────────────────────────────── +-- A job is a single CI run triggered by a push or PR event. It +-- progresses through: +-- pending -> evaluating -> building -> running_effects -> succeeded / failed +-- +-- `sequence_number` is per-(project, ref) and monotonically increases. +-- Effects use it to ensure ordering: effects for sequence N cannot +-- start until all effects for sequence < N on the same ref are done. +-- +-- Forge/repo metadata is denormalized for convenient display without +-- extra joins. +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL REFERENCES projects(id), + forge_type TEXT NOT NULL, -- Denormalized from forges.forge_type. + repo_owner TEXT NOT NULL, -- Denormalized from repos.owner. + repo_name TEXT NOT NULL, -- Denormalized from repos.name. + ref_name TEXT NOT NULL, -- Git ref (e.g., "refs/heads/main"). + commit_sha TEXT NOT NULL, -- Full 40-char SHA. + status TEXT NOT NULL DEFAULT 'pending', -- Job lifecycle state. + sequence_number INTEGER NOT NULL DEFAULT 0, -- Per-(project, ref) ordering counter. + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +-- Speeds up lookups by (project, ref) for the "latest job on branch" +-- query and for sequence-number computation. +CREATE INDEX IF NOT EXISTS idx_jobs_project_ref ON jobs(project_id, ref_name); + +-- ── Task Queue ─────────────────────────────────────────────────────── +-- Unified dispatch queue for all agent work: evaluation, build, and +-- effect tasks. Each task optionally specifies a required `platform` +-- so the scheduler can route it to a capable agent. +-- +-- Lifecycle: pending -> running -> succeeded / failed +-- +-- If an agent disconnects, its running tasks are reset to pending +-- (see `requeue_agent_tasks`). +CREATE TABLE IF NOT EXISTS task_queue ( + id TEXT PRIMARY KEY NOT NULL, + job_id TEXT NOT NULL REFERENCES jobs(id), -- Owning job. + task_type TEXT NOT NULL, -- 'evaluation' | 'build' | 'effect' + status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'running' | 'succeeded' | 'failed' + platform TEXT, -- Required Nix system (NULL = any agent). + required_features TEXT NOT NULL DEFAULT '[]', -- JSON array of required system features (future use). + payload TEXT NOT NULL, -- JSON blob; schema depends on task_type. + agent_session_id TEXT REFERENCES agent_sessions(id), -- Agent that claimed this task (NULL while pending). + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +-- Speeds up `dequeue_task`: find the oldest pending task matching a platform. +CREATE INDEX IF NOT EXISTS idx_task_queue_status ON task_queue(status, platform); + +-- ── Attributes (evaluation results) ───────────────────────────────── +-- During evaluation the agent walks the flake's `herculesCI` output +-- attribute tree and reports each attribute back. Each row records +-- the attribute path (JSON array), its type, an optional derivation +-- path, and any evaluation error. +CREATE TABLE IF NOT EXISTS attributes ( + id TEXT PRIMARY KEY NOT NULL, + job_id TEXT NOT NULL REFERENCES jobs(id), + path TEXT NOT NULL, -- JSON array of path segments, e.g. '["onPush","default"]'. + derivation_path TEXT, -- /nix/store/…drv path, if this attr produces a derivation. + attribute_type TEXT NOT NULL DEFAULT 'regular', -- 'regular' | 'effect' | etc. + error TEXT, -- Evaluation error message, if any. + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +-- Speeds up "get all attributes for a job" queries. +CREATE INDEX IF NOT EXISTS idx_attributes_job ON attributes(job_id); + +-- ── Derivation Info ────────────────────────────────────────────────── +-- Stores Nix-level metadata from `nix show-derivation` so the +-- scheduler knows which platform a build targets without +-- re-evaluating. +-- +-- `required_system_features` (JSON array) and `platform` are used to +-- match builds to agents. `input_derivations` (JSON array) lists +-- transitive build inputs. `outputs` (JSON object) maps output names +-- to store paths. +CREATE TABLE IF NOT EXISTS derivation_info ( + id TEXT PRIMARY KEY NOT NULL, + job_id TEXT NOT NULL REFERENCES jobs(id), + derivation_path TEXT NOT NULL, -- /nix/store/…drv path. + platform TEXT NOT NULL, -- Nix system string, e.g. "x86_64-linux". + required_system_features TEXT NOT NULL DEFAULT '[]', -- JSON array, e.g. '["kvm"]'. + input_derivations TEXT NOT NULL DEFAULT '[]', -- JSON array of input .drv paths. + outputs TEXT NOT NULL DEFAULT '{}', -- JSON object: {"out": "/nix/store/…", …}. + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +-- Speeds up "get all derivation info for a job" queries. +CREATE INDEX IF NOT EXISTS idx_derivation_info_job ON derivation_info(job_id); + +-- ── Builds ─────────────────────────────────────────────────────────── +-- Builds are **deduplicated by derivation path**. If two different +-- jobs require the same /nix/store/…drv, only one build record is +-- created. The many-to-many `build_jobs` table below tracks which +-- jobs share a build. +-- +-- `INSERT OR IGNORE` on the UNIQUE derivation_path column implements +-- the deduplication (see `create_or_get_build`). +-- +-- Lifecycle: pending -> building -> succeeded / failed / cancelled +CREATE TABLE IF NOT EXISTS builds ( + id TEXT PRIMARY KEY NOT NULL, + derivation_path TEXT NOT NULL UNIQUE, -- Deduplication key. + status TEXT NOT NULL DEFAULT 'pending', -- Build lifecycle state. + agent_session_id TEXT REFERENCES agent_sessions(id), -- Agent that is building (NULL while pending). + started_at TEXT, -- Set when status becomes 'building'. + completed_at TEXT, -- Set when status reaches a terminal state. + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ── Build-Jobs join table ──────────────────────────────────────────── +-- Many-to-many relationship between builds and jobs. Because builds +-- are deduplicated, a single build can be shared across multiple jobs +-- (and even projects). This table lets the job controller query +-- "are all builds for job X done?". +CREATE TABLE IF NOT EXISTS build_jobs ( + build_id TEXT NOT NULL REFERENCES builds(id), + job_id TEXT NOT NULL REFERENCES jobs(id), + PRIMARY KEY (build_id, job_id) -- Composite PK prevents duplicate links. +); + +-- ── Effects ────────────────────────────────────────────────────────── +-- Effects are post-build side-effects (deploys, notifications, state +-- file updates) defined in the `herculesCI.onPush` output. They run +-- after all builds for a job complete. +-- +-- Effects are serialised per (project, ref): effects for sequence +-- number N do not start until all effects for sequence < N on the +-- same ref have completed. This prevents overlapping deploys. +-- +-- Lifecycle: pending -> running -> succeeded / failed / cancelled +CREATE TABLE IF NOT EXISTS effects ( + id TEXT PRIMARY KEY NOT NULL, + job_id TEXT NOT NULL REFERENCES jobs(id), + attribute_path TEXT NOT NULL, -- JSON array of the Nix attribute path. + derivation_path TEXT NOT NULL, -- /nix/store/…drv path of the effect derivation. + status TEXT NOT NULL DEFAULT 'pending', + started_at TEXT, -- Set when status becomes 'running'. + completed_at TEXT, -- Set on terminal status. + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +-- Speeds up "get all effects for a job" queries. +CREATE INDEX IF NOT EXISTS idx_effects_job ON effects(job_id); + +-- ── State Files ────────────────────────────────────────────────────── +-- Implements the Hercules CI `hci state` feature: a key-value store +-- of versioned binary blobs scoped per project. Effects can read and +-- write state files to persist data across CI runs (e.g., Terraform +-- state, deployment manifests). +-- +-- Each write bumps the `version` counter and replaces the `data` BLOB. +-- The composite primary key (project_id, name) enforces uniqueness. +CREATE TABLE IF NOT EXISTS state_files ( + project_id TEXT NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, -- User-defined state file name. + data BLOB NOT NULL, -- Raw binary payload. + version INTEGER NOT NULL DEFAULT 1, -- Monotonically increasing on each write. + size_bytes INTEGER NOT NULL DEFAULT 0, -- Cached size for listing without loading data. + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (project_id, name) +); + +-- ── State Locks ────────────────────────────────────────────────────── +-- Distributed advisory locks with automatic lease expiry. Effects +-- acquire a lock before reading/writing a state file to prevent +-- concurrent modifications from parallel jobs. +-- +-- The UNIQUE(project_id, name) constraint enforces mutual exclusion: +-- only one lock per (project, name) can exist at a time. Expired +-- locks are cleaned up lazily on acquire and periodically by a +-- background janitor. +CREATE TABLE IF NOT EXISTS state_locks ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, -- Lock name (typically matches the state file name). + owner TEXT NOT NULL, -- Free-form identifier of the lock holder. + expires_at TEXT NOT NULL, -- Lease expiry; after this time the lock is stale. + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(project_id, name) -- At most one active lock per (project, name). +); + +-- ── Secrets ────────────────────────────────────────────────────────── +-- Encrypted JSON blobs scoped to a project. Secrets are delivered to +-- the agent during effect execution when the `condition` matches +-- (e.g., "always", or only for pushes to the default branch). +-- +-- The `data` column stores the secret payload as JSON text. At the +-- Rust layer it is wrapped in `Sensitive<_>` to prevent accidental +-- logging. +CREATE TABLE IF NOT EXISTS secrets ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, -- User-defined secret name. + data TEXT NOT NULL, -- JSON blob with the secret payload. + condition TEXT NOT NULL DEFAULT '"always"', -- JSON-serialized SecretCondition enum. + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(project_id, name) -- One secret per name per project. +); + +-- ── Log Entries ────────────────────────────────────────────────────── +-- Agents stream structured log lines while executing tasks (evaluation, +-- build, or effect). Each line has a zero-based index, a millisecond +-- timestamp, a message string, and a severity level. +-- +-- Uses INTEGER PRIMARY KEY AUTOINCREMENT as a surrogate key (not UUID) +-- for insert performance on high-volume log streams. +CREATE TABLE IF NOT EXISTS log_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, -- The task producing these logs. + line_index INTEGER NOT NULL, -- Zero-based line number within the task. + timestamp_ms INTEGER NOT NULL, -- Milliseconds since epoch for the log line. + message TEXT NOT NULL, -- Log message content. + level TEXT NOT NULL DEFAULT 'info', -- 'debug' | 'info' | 'warn' | 'error' + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +-- Speeds up paginated log retrieval: "get lines N..N+limit for task X". +CREATE INDEX IF NOT EXISTS idx_log_entries_task ON log_entries(task_id, line_index); + +-- ── Schedules ──────────────────────────────────────────────────────── +-- Cron-based job triggers. When enabled, the scheduler creates a new +-- job at the configured interval on the specified ref. +-- (Future feature -- not yet wired into the scheduler.) +CREATE TABLE IF NOT EXISTS schedules ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL REFERENCES projects(id), + cron_expression TEXT NOT NULL, -- Standard 5-field cron expression. + ref_name TEXT NOT NULL DEFAULT 'main', -- Git ref to evaluate. + enabled INTEGER NOT NULL DEFAULT 1, -- 1 = active, 0 = paused. + last_triggered_at TEXT, -- When the cron last fired. + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/crates/jupiter-db/migrations/20240102000000_account_password.sql b/crates/jupiter-db/migrations/20240102000000_account_password.sql new file mode 100644 index 0000000..c3a24ba --- /dev/null +++ b/crates/jupiter-db/migrations/20240102000000_account_password.sql @@ -0,0 +1,14 @@ +-- ======================================================================= +-- Add password-based authentication for accounts +-- ======================================================================= +-- +-- The initial schema only supported agent authentication via cluster +-- join tokens (bcrypt-hashed bearer tokens). This migration adds a +-- `password_hash` column to the `accounts` table so that human users +-- can also authenticate with a username + password (bcrypt-hashed). +-- +-- The column is nullable: accounts that authenticate exclusively via +-- forge OAuth (GitHub, Gitea, etc.) will leave it NULL. The auth +-- layer checks for NULL before attempting bcrypt verification. + +ALTER TABLE accounts ADD COLUMN password_hash TEXT; diff --git a/crates/jupiter-db/src/backend.rs b/crates/jupiter-db/src/backend.rs new file mode 100644 index 0000000..5e54f69 --- /dev/null +++ b/crates/jupiter-db/src/backend.rs @@ -0,0 +1,693 @@ +//! # StorageBackend -- the database abstraction trait +//! +//! Every server component that needs to persist or query data depends on +//! this trait rather than on a concrete database implementation. This +//! inversion allows: +//! +//! 1. Swapping SQLite for PostgreSQL via a feature flag. +//! 2. Using an in-memory SQLite database in integration tests. +//! 3. Eventually mocking the trait in unit tests. +//! +//! The trait surface is organised into sections that match the Hercules CI +//! data model. Each section corresponds to one or more SQL tables (see +//! the migration files for the full schema). +//! +//! ## Hercules CI pipeline overview +//! +//! ```text +//! webhook / push event +//! --> create Job (status: pending) +//! --> enqueue evaluation Task +//! --> agent dequeues & evaluates the flake +//! --> store Attributes + DerivationInfo +//! --> create or deduplicate Builds +//! --> enqueue build Tasks (one per unique derivation) +//! --> agents build; when all builds complete: +//! --> create & run Effects (side-effects like deploys) +//! --> job marked succeeded / failed +//! ``` + +use async_trait::async_trait; +use jupiter_api_types::{ + Account, AccountType, AgentHello, AgentSession, AttributeResult, AttributeType, Build, + BuildStatus, ClusterJoinToken, Effect, EffectStatus, ForgeType, Job, JobStatus, JobSummary, + LogEntry, Project, Repo, Secret, SecretCondition, StateFile, StateLock, TaskStatus, TaskType, +}; +use uuid::Uuid; + +use crate::error::Result; + +/// Async trait that abstracts all database operations for the Jupiter +/// server. +/// +/// Implementations must be `Send + Sync + 'static` so they can be shared +/// across Tokio tasks behind an `Arc`. +/// +/// All IDs are passed as raw [`Uuid`] values. The API layer is +/// responsible for wrapping/unwrapping the phantom-typed [`Id`] from +/// `jupiter-api-types`. +#[async_trait] +pub trait StorageBackend: Send + Sync + 'static { + // ── Initialization ─────────────────────────────────────────────── + + /// Run all pending sqlx migrations against the connected database. + /// + /// Called once at server startup. Migration files live in + /// `crates/jupiter-db/migrations/` and are embedded at compile time + /// by the `sqlx::migrate!` macro. + async fn run_migrations(&self) -> Result<()>; + + // ── Accounts ───────────────────────────────────────────────────── + // + // An Account is the top-level ownership entity. In Hercules CI an + // account can be a user or an organisation. Projects, join tokens, + // and agent sessions all belong to an account. + + /// Create a new account with the given display name and type. + /// + /// Returns `DbError::Sqlx` if the name violates the UNIQUE constraint. + async fn create_account(&self, name: &str, typ: AccountType) -> Result; + + /// Fetch a single account by its primary-key UUID. + async fn get_account(&self, id: Uuid) -> Result; + + /// Fetch a single account by its unique display name. + /// + /// Used during login and API-key resolution where the caller only + /// knows the account name. + async fn get_account_by_name(&self, name: &str) -> Result; + + /// Return the bcrypt-hashed password for the account, if one has been + /// set. Returns `Ok(None)` for accounts that authenticate exclusively + /// via forge OAuth or that have not yet set a password. + async fn get_account_password_hash(&self, name: &str) -> Result>; + + /// Set (or replace) the bcrypt-hashed password for an account. + /// + /// The hash is stored as an opaque string; the caller is responsible + /// for hashing with an appropriate cost factor before calling this + /// method. + async fn set_account_password_hash(&self, id: Uuid, password_hash: &str) -> Result<()>; + + /// List every account, ordered by creation time. + async fn list_accounts(&self) -> Result>; + + // ── Cluster Join Tokens ────────────────────────────────────────── + // + // When an `hercules-ci-agent` first connects it presents a bearer + // token. The server looks up the matching bcrypt hash in this table + // to authenticate the agent and associate it with an account. + // + // Tokens are one-way hashed (bcrypt) so a database leak does not + // expose credentials. + + /// Persist a new join token. + /// + /// `token_hash` is the bcrypt hash of the raw bearer token that was + /// shown to the admin at creation time. The raw token is never + /// stored. + async fn create_cluster_join_token( + &self, + account_id: Uuid, + name: &str, + token_hash: &str, + ) -> Result; + + /// List all join tokens belonging to an account (hash excluded). + async fn list_cluster_join_tokens(&self, account_id: Uuid) -> Result>; + + /// Retrieve the bcrypt hash for a specific token by its UUID. + /// + /// Used during agent authentication when the token ID is already + /// known. + async fn get_cluster_join_token_hash(&self, token_id: Uuid) -> Result; + + /// Delete (revoke) a cluster join token. + /// + /// Active agent sessions authenticated with this token are **not** + /// automatically terminated -- they remain valid until their next + /// re-authentication attempt. + async fn delete_cluster_join_token(&self, token_id: Uuid) -> Result<()>; + + /// Return all `(token_id, bcrypt_hash)` pairs for an account so the + /// authentication layer can try each hash against the presented bearer + /// token. + /// + /// This linear scan is acceptable because each account typically has + /// only a handful of join tokens. + async fn find_cluster_join_token_by_hash( + &self, + account_id: Uuid, + ) -> Result>; + + // ── Agent Sessions ─────────────────────────────────────────────── + // + // Each connected `hercules-ci-agent` has exactly one session row. + // The session records the agent's platform capabilities (e.g., + // `x86_64-linux`, `aarch64-darwin`), system features, and + // concurrency limit. The scheduler uses this information to match + // tasks to capable agents. + + /// Register a newly-connected agent. + /// + /// The [`AgentHello`] payload contains the agent's self-reported + /// capabilities (platforms, system features, concurrency, versions). + async fn create_agent_session( + &self, + agent_hello: &AgentHello, + account_id: Uuid, + ) -> Result; + + /// Fetch a single agent session by UUID. + async fn get_agent_session(&self, id: Uuid) -> Result; + + /// List all currently-registered agent sessions. + async fn list_agent_sessions(&self) -> Result>; + + /// Bump the `last_heartbeat` timestamp for a connected agent. + /// + /// The server uses heartbeat age to detect stale sessions (agents + /// that disconnected without a clean goodbye). + async fn update_agent_heartbeat(&self, id: Uuid) -> Result<()>; + + /// Remove an agent session (agent disconnected or timed out). + /// + /// Any tasks still assigned to this agent should be requeued + /// separately via [`requeue_agent_tasks`](Self::requeue_agent_tasks). + async fn delete_agent_session(&self, id: Uuid) -> Result<()>; + + /// Find all agent sessions whose `platforms` JSON array contains the + /// given platform string (e.g. `"x86_64-linux"`). + /// + /// Used by the scheduler to determine which agents can run a task + /// that requires a specific platform. + async fn get_active_agent_sessions_for_platform( + &self, + platform: &str, + ) -> Result>; + + // ── Repos ──────────────────────────────────────────────────────── + // + // A Repo is a mirror of a repository on an external forge (GitHub, + // Gitea, etc.). It stores the clone URL and default branch so the + // agent knows where to fetch code. Repos are unique per + // (forge, owner, name) triple. + + /// Register a repository from a forge. + async fn create_repo( + &self, + forge_id: Uuid, + owner: &str, + name: &str, + clone_url: &str, + default_branch: &str, + ) -> Result; + + /// Fetch a repository by its primary-key UUID. + async fn get_repo(&self, id: Uuid) -> Result; + + /// Look up a repository by its forge-side identity (forge + owner + name). + /// + /// Returns `None` if no matching repo has been registered yet. + /// Used during webhook processing to find or create the repo. + async fn find_repo( + &self, + forge_id: Uuid, + owner: &str, + name: &str, + ) -> Result>; + + /// List all repositories associated with a given forge. + async fn list_repos(&self, forge_id: Uuid) -> Result>; + + // ── Projects ───────────────────────────────────────────────────── + // + // A Project binds an Account to a Repo and serves as the grouping + // entity for jobs, state files, secrets, and schedules. This is + // the primary unit the user interacts with in the Hercules CI + // dashboard. + + /// Create a new project owned by `account_id` and backed by `repo_id`. + /// + /// Projects are enabled by default. Disabled projects ignore + /// incoming webhooks. + async fn create_project( + &self, + account_id: Uuid, + repo_id: Uuid, + name: &str, + ) -> Result; + + /// Fetch a project by primary key. + async fn get_project(&self, id: Uuid) -> Result; + + /// Fetch a project by its unique display name. + async fn get_project_by_name(&self, name: &str) -> Result; + + /// Toggle the `enabled` flag on a project and return the updated row. + /// + /// Disabled projects will not create new jobs when webhooks arrive. + async fn update_project(&self, id: Uuid, enabled: bool) -> Result; + + /// List all projects, ordered by creation time. + async fn list_projects(&self) -> Result>; + + /// Find the project (if any) that is linked to the given repo. + /// + /// At most one project can point to each repo. Used during webhook + /// processing to route an event to the correct project. + async fn find_project_by_repo(&self, repo_id: Uuid) -> Result>; + + // ── Jobs ───────────────────────────────────────────────────────── + // + // A Job represents a single CI run triggered by a push or pull + // request event. It progresses through: + // + // pending -> evaluating -> building -> running_effects -> succeeded / failed + // + // Each job belongs to exactly one project and is identified by a + // per-(project, ref) monotonically-increasing sequence number. + + /// Create a new job in `pending` status. + /// + /// Automatically assigns the next sequence number for the given + /// (project, ref) pair. The `forge_type`, `repo_owner`, and + /// `repo_name` are denormalized from the project's repo for + /// convenient display and webhook status reporting. + async fn create_job( + &self, + project_id: Uuid, + forge_type: ForgeType, + repo_owner: &str, + repo_name: &str, + ref_name: &str, + commit_sha: &str, + ) -> Result; + + /// Fetch a job by primary key. + async fn get_job(&self, id: Uuid) -> Result; + + /// Transition a job to the given status and bump `updated_at`. + async fn update_job_status(&self, id: Uuid, status: JobStatus) -> Result<()>; + + /// Paginated listing of jobs for a project, newest first. + /// + /// Returns `(summaries, total_count)` so the API can set pagination + /// headers. + async fn list_jobs_for_project( + &self, + project_id: Uuid, + page: u64, + per_page: u64, + ) -> Result<(Vec, u64)>; + + /// Return the most recent job for a (project, ref) pair, by sequence + /// number. + /// + /// Used to determine whether a new push supersedes an in-progress + /// job on the same branch. + async fn get_latest_job_for_ref( + &self, + project_id: Uuid, + ref_name: &str, + ) -> Result>; + + /// Compute the next sequence number for a (project, ref) pair. + /// + /// Sequence numbers start at 1 and monotonically increase. They are + /// used to order effects: an effect for sequence N will not run until + /// all effects for sequences < N on the same ref have completed. + async fn get_next_sequence_number( + &self, + project_id: Uuid, + ref_name: &str, + ) -> Result; + + // ── Task Queue ─────────────────────────────────────────────────── + // + // The task queue is a unified dispatch mechanism. Evaluation, build, + // and effect tasks all live in the same `task_queue` table. Each + // task optionally specifies a required `platform` (e.g., + // `x86_64-linux`) so the scheduler can route it to a capable agent. + // + // Tasks flow through: pending -> running -> succeeded / failed + // + // If an agent disconnects, its running tasks are requeued to pending + // so another agent can pick them up. + + /// Insert a new task into the queue in `pending` status. + /// + /// `platform` may be `None` for tasks that can run on any agent + /// (e.g., evaluation of platform-independent expressions). + /// `payload` is an opaque JSON blob whose schema depends on + /// `task_type`. + async fn enqueue_task( + &self, + job_id: Uuid, + task_type: TaskType, + platform: Option<&str>, + payload: &serde_json::Value, + ) -> Result; + + /// Atomically dequeue the oldest pending task that matches the given + /// platform. + /// + /// The task is moved to `running` status inside a transaction so that + /// concurrent agents cannot claim the same task. Returns `None` if + /// no matching task is available. + /// + /// `system_features` is accepted for future feature-matching but is + /// not yet used in the query. + async fn dequeue_task( + &self, + platform: &str, + system_features: &[String], + ) -> Result>; + + /// Update the status of a task and optionally record which agent + /// session is handling it. + async fn update_task_status( + &self, + task_id: Uuid, + status: TaskStatus, + agent_session_id: Option, + ) -> Result<()>; + + /// Retrieve a task's full metadata (id, type, status, payload). + async fn get_task( + &self, + task_id: Uuid, + ) -> Result<(Uuid, TaskType, TaskStatus, serde_json::Value)>; + + /// Reset all `running` tasks owned by the given agent session back + /// to `pending`. + /// + /// Called when an agent disconnects unexpectedly so that its + /// in-flight work is retried by another agent. Returns the list + /// of task IDs that were requeued. + async fn requeue_agent_tasks(&self, agent_session_id: Uuid) -> Result>; + + /// Look up which job a task belongs to. + /// + /// Used by the agent protocol handler to route task results back to + /// the originating job. + async fn get_task_job_id(&self, task_id: Uuid) -> Result; + + // ── Evaluations / Attributes ───────────────────────────────────── + // + // During evaluation the agent walks the flake's `herculesCI` output + // attribute tree. Each discovered attribute is recorded here, + // along with its derivation path (if it produces one) and type. + // + // DerivationInfo stores Nix-level metadata from `nix show-derivation` + // so the scheduler knows which platform a build needs without + // re-evaluating. + + /// Record a single attribute discovered during evaluation. + /// + /// `path` is the Nix attribute path as a list of segments (e.g., + /// `["herculesCI", "ciSystems", "x86_64-linux", "default"]`). + /// `derivation_path` is the `/nix/store/...drv` path, if this + /// attribute produces a derivation. + async fn store_attribute( + &self, + job_id: Uuid, + path: &[String], + derivation_path: Option<&str>, + typ: AttributeType, + error: Option<&str>, + ) -> Result<()>; + + /// Store Nix derivation metadata obtained from `nix show-derivation`. + /// + /// `platform` (e.g. `"x86_64-linux"`) and `required_system_features` + /// (e.g. `["kvm"]`) are used by the scheduler to match builds to + /// agents. `input_derivations` lists transitive build dependencies. + /// `outputs` is the JSON map of output names to store paths. + async fn store_derivation_info( + &self, + job_id: Uuid, + derivation_path: &str, + platform: &str, + required_system_features: &[String], + input_derivations: &[String], + outputs: &serde_json::Value, + ) -> Result<()>; + + /// Retrieve all attributes recorded for a job's evaluation. + async fn get_evaluation_attributes(&self, job_id: Uuid) -> Result>; + + /// Return every unique derivation path discovered during a job's + /// evaluation. + /// + /// Used after evaluation completes to create the corresponding + /// build records. + async fn get_derivation_paths_for_job(&self, job_id: Uuid) -> Result>; + + /// Look up the target platform for a given derivation path. + /// + /// Returns `None` if the derivation has not been recorded (e.g., it + /// was a dependency that was not evaluated in this job). The + /// scheduler calls this to decide which agent platform can build + /// the derivation. + async fn get_derivation_platform( + &self, + derivation_path: &str, + ) -> Result>; + + // ── Builds ─────────────────────────────────────────────────────── + // + // Builds are **deduplicated by derivation path**. If two different + // jobs (or even two different projects) need the same + // `/nix/store/...drv`, only one build record is created. The + // `build_jobs` join table tracks which jobs share a build so their + // statuses can all be updated when the build completes. + // + // Build lifecycle: pending -> building -> succeeded / failed / cancelled + + /// Insert a new build for `derivation_path`, or return the existing + /// build if one already exists (deduplication). + /// + /// Returns `(build_id, was_created)`. `was_created` is `false` when + /// the derivation was already known, meaning no new work needs to be + /// scheduled. + async fn create_or_get_build(&self, derivation_path: &str) -> Result<(Uuid, bool)>; + + /// Fetch a build by primary key. + async fn get_build(&self, id: Uuid) -> Result; + + /// Look up a build by its derivation path. + async fn get_build_by_drv_path(&self, derivation_path: &str) -> Result>; + + /// Transition a build's status and optionally record the building + /// agent. + /// + /// Automatically sets `started_at` when entering `Building` and + /// `completed_at` when entering a terminal status. + async fn update_build_status( + &self, + id: Uuid, + status: BuildStatus, + agent_session_id: Option, + ) -> Result<()>; + + /// Associate a build with a job (many-to-many). + /// + /// Silently succeeds if the link already exists (`INSERT OR IGNORE`). + async fn link_build_to_job(&self, build_id: Uuid, job_id: Uuid) -> Result<()>; + + /// Check whether every build linked to a job has reached a terminal + /// status (`succeeded`, `failed`, or `cancelled`). + /// + /// The job controller calls this after each build status update to + /// decide whether to advance the job to the effects phase. + async fn are_all_builds_complete(&self, job_id: Uuid) -> Result; + + // ── Effects ────────────────────────────────────────────────────── + // + // Effects are post-build side-effects (deploys, notifications, + // state-file updates, etc.) defined in the `herculesCI.onPush` + // output. They are serialised: for a given (project, ref), effects + // for sequence number N do not start until all effects for + // sequence < N have completed. This prevents overlapping deploys. + // + // Effect lifecycle: pending -> running -> succeeded / failed / cancelled + + /// Create a new effect record for a job. + async fn create_effect( + &self, + job_id: Uuid, + attribute_path: &[String], + derivation_path: &str, + ) -> Result; + + /// Fetch an effect by primary key. + async fn get_effect(&self, id: Uuid) -> Result; + + /// Look up an effect by its (job, attribute_path) pair. + /// + /// `attribute_path` is the JSON-serialized path string. + async fn get_effect_by_job_and_attr(&self, job_id: Uuid, attribute_path: &str) -> Result; + + /// List all effects associated with a job, ordered by creation time. + async fn get_effects_for_job(&self, job_id: Uuid) -> Result>; + + /// Transition an effect's status. + /// + /// Automatically sets `started_at` when entering `Running` and + /// `completed_at` when entering a terminal status. + async fn update_effect_status(&self, id: Uuid, status: EffectStatus) -> Result<()>; + + /// Check whether every effect for a job has reached a terminal status. + async fn are_all_effects_complete(&self, job_id: Uuid) -> Result; + + /// Check whether all effects from earlier sequence numbers on the + /// same (project, ref) have completed. + /// + /// Used to enforce the serialisation invariant: effects for a newer + /// push must wait until previous pushes' effects have finished. + /// This prevents concurrent deploys from the same branch. + async fn are_preceding_effects_done( + &self, + project_id: Uuid, + ref_name: &str, + sequence_number: i64, + ) -> Result; + + // ── State Files ────────────────────────────────────────────────── + // + // State files implement the Hercules CI `hci state` feature: a + // key-value store of versioned binary blobs scoped per project. + // Effects can read/write these files to persist data across CI runs + // (e.g., Terraform state, deployment manifests). + // + // Each write bumps the version counter and replaces the data. + // The version number enables optimistic-concurrency checks in + // higher-level code. + + /// Insert or update a state file. + /// + /// Uses `INSERT ... ON CONFLICT DO UPDATE` so that the first write + /// creates the row at version 1, and subsequent writes atomically + /// increment the version. + async fn put_state_file( + &self, + project_id: Uuid, + name: &str, + data: &[u8], + ) -> Result<()>; + + /// Retrieve the raw bytes of a state file. + /// + /// Returns `None` if the file has never been written. + async fn get_state_file( + &self, + project_id: Uuid, + name: &str, + ) -> Result>>; + + /// List all state files for a project (metadata only, no data blobs). + async fn list_state_files(&self, project_id: Uuid) -> Result>; + + // ── State Locks ────────────────────────────────────────────────── + // + // Distributed advisory locks with automatic lease expiry. Effects + // acquire a lock before reading/writing a state file to prevent + // concurrent modifications from parallel jobs. + // + // The UNIQUE(project_id, name) constraint on the `state_locks` + // table ensures mutual exclusion at the database level. Expired + // locks are cleaned up lazily (on acquire) and periodically via + // `cleanup_expired_locks`. + + /// Attempt to acquire a named lock for a project. + /// + /// First deletes any expired lock for the same (project, name) pair, + /// then tries `INSERT OR IGNORE`. Returns `DbError::Conflict` if + /// the lock is held by another owner and has not expired. + /// + /// `owner` is a free-form string identifying the holder (typically + /// the agent session ID or effect ID). `ttl_seconds` controls the + /// lease duration. + async fn acquire_lock( + &self, + project_id: Uuid, + name: &str, + owner: &str, + ttl_seconds: u64, + ) -> Result; + + /// Extend the lease of an existing lock. + /// + /// Useful for long-running effects that need to hold a lock beyond + /// the initial TTL without releasing and re-acquiring. + async fn renew_lock(&self, lock_id: Uuid, ttl_seconds: u64) -> Result; + + /// Explicitly release a lock before it expires. + async fn release_lock(&self, lock_id: Uuid) -> Result<()>; + + /// Delete all locks whose `expires_at` is in the past. + /// + /// Returns the number of expired locks removed. Called periodically + /// by a background janitor task. + async fn cleanup_expired_locks(&self) -> Result; + + // ── Secrets ────────────────────────────────────────────────────── + // + // Secrets are JSON blobs scoped to a project. They are delivered + // to the agent during effect execution when the `condition` matches + // (e.g., only on the default branch). + // + // The `data` column stores the secret payload as JSON text. + // At the Rust level it is wrapped in `Sensitive<_>` to prevent + // accidental logging. + + /// Create a new project secret. + /// + /// `data` is an opaque JSON value (typically `{"key": "value"}` + /// pairs). `condition` controls when the secret is available -- + /// e.g., only for pushes to the default branch. + async fn create_secret( + &self, + project_id: Uuid, + name: &str, + data: &serde_json::Value, + condition: &SecretCondition, + ) -> Result; + + /// List all secrets for a project (including their data). + /// + /// The caller is responsible for filtering based on `condition` + /// before sending secrets to an agent. + async fn get_secrets_for_project(&self, project_id: Uuid) -> Result>; + + /// Delete a secret by its UUID. + async fn delete_secret(&self, id: Uuid) -> Result<()>; + + // ── Log Entries ────────────────────────────────────────────────── + // + // Agents stream structured log lines while executing tasks. Each + // line has a zero-based index, a millisecond timestamp, a message, + // and a severity level. The dashboard uses these to display + // real-time build/effect logs. + + /// Batch-insert log lines for a task. + /// + /// Runs inside a transaction for atomicity. Idempotent if lines + /// with the same `(task_id, line_index)` are inserted again + /// (assuming the table allows it; currently no unique constraint + /// on the pair, so duplicates are possible if the agent retries). + async fn store_log_entries( + &self, + task_id: Uuid, + entries: &[LogEntry], + ) -> Result<()>; + + /// Retrieve a page of log entries for a task, ordered by line index. + async fn get_log_entries( + &self, + task_id: Uuid, + offset: u64, + limit: u64, + ) -> Result>; +} diff --git a/crates/jupiter-db/src/error.rs b/crates/jupiter-db/src/error.rs new file mode 100644 index 0000000..c53854b --- /dev/null +++ b/crates/jupiter-db/src/error.rs @@ -0,0 +1,58 @@ +//! # Database error types for jupiter-db +//! +//! Provides a unified [`DbError`] enum that every [`crate::backend::StorageBackend`] +//! method returns. The variants cover the four failure modes that callers +//! need to distinguish: +//! +//! - **Sqlx** -- low-level driver or connection-pool errors (timeouts, +//! constraint violations not otherwise mapped, etc.). +//! - **NotFound** -- the requested entity does not exist. The HTTP layer +//! typically maps this to `404 Not Found`. +//! - **Conflict** -- a uniqueness or locking constraint was violated +//! (e.g., trying to acquire a state lock that is already held). Maps +//! to `409 Conflict`. +//! - **Migration** -- schema migration failed on startup. Fatal. +//! - **Serialization** -- a JSON column could not be serialized or +//! deserialized (e.g., the `platforms` JSON array in `agent_sessions`). + +use thiserror::Error; + +/// Crate-level error type returned by every [`crate::backend::StorageBackend`] method. +/// +/// The variants carry enough context for the API layer to choose an +/// appropriate HTTP status code without inspecting error messages. +#[derive(Debug, Error)] +pub enum DbError { + /// A low-level sqlx driver error (connection failure, unexpected SQL + /// error, protocol parse issue, etc.). + #[error("database error: {0}")] + Sqlx(#[from] sqlx::Error), + + /// The requested entity was not found. + /// + /// `entity` is a human-readable table/concept name (e.g. `"account"`, + /// `"build"`). `id` is whatever key was used for the lookup. + #[error("not found: {entity} with id {id}")] + NotFound { entity: String, id: String }, + + /// A uniqueness or mutual-exclusion constraint was violated. + /// + /// Currently used by [`crate::backend::StorageBackend::acquire_lock`] + /// when the lock is already held by another owner. + #[error("conflict: {0}")] + Conflict(String), + + /// A sqlx migration failed. This is treated as fatal at startup. + #[error("migration error: {0}")] + Migration(#[from] sqlx::migrate::MigrateError), + + /// JSON serialization or deserialization failed for a column that + /// stores structured data (e.g., `platforms`, `system_features`, + /// `attribute_path`, `condition`). + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), +} + +/// Convenience alias used throughout the crate so that every function +/// signature can simply return `Result`. +pub type Result = std::result::Result; diff --git a/crates/jupiter-db/src/lib.rs b/crates/jupiter-db/src/lib.rs new file mode 100644 index 0000000..bf27606 --- /dev/null +++ b/crates/jupiter-db/src/lib.rs @@ -0,0 +1,46 @@ +//! # jupiter-db -- Persistence layer for Jupiter +//! +//! Jupiter is a self-hosted, wire-compatible replacement for +//! [hercules-ci.com](https://hercules-ci.com). This crate owns every +//! database interaction: schema migrations, CRUD operations, and the +//! task-queue that drives the eval-build-effects pipeline. +//! +//! ## Architecture +//! +//! All server components depend on the [`backend::StorageBackend`] async +//! trait rather than on a concrete database driver. Today the only +//! implementation is [`sqlite::SqliteBackend`] (the default), but the +//! trait is designed so that a PostgreSQL backend can be added behind a +//! feature flag without touching any calling code. +//! +//! ## Modules +//! +//! | Module | Purpose | +//! |-------------|---------| +//! | [`backend`] | Defines the `StorageBackend` trait -- the public contract. | +//! | [`error`] | Crate-level error and `Result` types. | +//! | [`sqlite`] | SQLite implementation of `StorageBackend` via sqlx. | +//! +//! ## Data model overview +//! +//! The schema mirrors the Hercules CI object model: +//! +//! ```text +//! Account +//! +-- ClusterJoinToken (agent authentication) +//! +-- Project +//! +-- Repo (forge-side repository reference) +//! +-- Job (one per push / PR event) +//! | +-- Attribute (evaluation output) +//! | +-- DerivationInfo (platform & inputs metadata) +//! | +-- Build (deduplicated by drv path) +//! | +-- Effect (post-build side-effects) +//! | +-- TaskQueue (unified dispatch to agents) +//! +-- StateFile (versioned binary blobs for `hci state`) +//! +-- StateLock (distributed lock with lease expiry) +//! +-- Secret (encrypted per-project secrets) +//! ``` + +pub mod backend; +pub mod error; +pub mod sqlite; diff --git a/crates/jupiter-db/src/sqlite.rs b/crates/jupiter-db/src/sqlite.rs new file mode 100644 index 0000000..a8a87f8 --- /dev/null +++ b/crates/jupiter-db/src/sqlite.rs @@ -0,0 +1,1902 @@ +//! # SQLite implementation of [`StorageBackend`] +//! +//! This is the default (and currently only) persistence backend for +//! Jupiter. It uses [sqlx](https://docs.rs/sqlx) with the `sqlite` +//! feature to talk to a local SQLite database file. +//! +//! ## Design decisions +//! +//! * **Runtime-checked queries** -- All SQL is written as plain string +//! literals via `sqlx::query(...)` (compile-time-*unchecked*). This is +//! intentional: the schema is managed by sqlx migrations, and +//! compile-time checking would require a live database at build time. +//! +//! * **WAL mode & foreign keys** -- Enabled at connection time for better +//! concurrent read performance and referential integrity. +//! +//! * **UUIDs as TEXT** -- SQLite has no native UUID type, so all IDs are +//! stored as `TEXT` and parsed/formatted through the helper functions +//! in this module. +//! +//! * **Datetimes as TEXT** -- Stored in `YYYY-MM-DD HH:MM:SS` format +//! (UTC). The [`parse_datetime`] helper is lenient and also accepts +//! ISO-8601 / RFC-3339 variants that SQLite or tests might produce. +//! +//! * **JSON columns** -- Several columns (`platforms`, `system_features`, +//! `attribute_path`, `condition`, `payload`, etc.) are stored as JSON +//! text and (de)serialized with `serde_json`. This keeps the schema +//! simple at the cost of not being indexable on individual elements. +//! +//! * **Connection pool** -- Capped at 5 connections. SQLite serialises +//! writes anyway, so a large pool would not help and would increase +//! lock contention. +//! +//! ## Module layout +//! +//! 1. [`SqliteBackend`] struct + constructor +//! 2. Private helper functions (UUID/datetime parsing, timestamp formatting) +//! 3. Private row-mapping functions (`row_to_*`) that convert a +//! [`SqliteRow`] into the corresponding `jupiter-api-types` struct +//! 4. The `#[async_trait] impl StorageBackend for SqliteBackend` block + +use async_trait::async_trait; +use chrono::{DateTime, NaiveDateTime, Utc}; +use jupiter_api_types::{ + Account, AccountType, AgentHello, AgentSession, AttributeResult, AttributeType, Build, + BuildStatus, ClusterJoinToken, Effect, EffectStatus, ForgeType, Id, Job, JobStatus, JobSummary, + LogEntry, Project, Repo, Secret, SecretCondition, Sensitive, StateFile, StateLock, + TaskStatus, TaskType, +}; +use sqlx::sqlite::{SqlitePoolOptions, SqliteRow}; +use sqlx::{Row, SqlitePool}; +use uuid::Uuid; + +use crate::backend::StorageBackend; +use crate::error::{DbError, Result}; + +/// SQLite-backed implementation of [`StorageBackend`]. +/// +/// Holds a shared [`SqlitePool`] that is cloned into each query. Create +/// one instance at server startup via [`SqliteBackend::new`] and share it +/// (typically inside an `Arc`) across all request handlers. +pub struct SqliteBackend { + /// The sqlx connection pool. All queries borrow from this pool. + pool: SqlitePool, +} + +impl SqliteBackend { + /// Open (or create) a SQLite database at the given URL. + /// + /// The URL follows the sqlx format: + /// - `sqlite:path/to/file.db` -- persistent file + /// - `sqlite::memory:` -- ephemeral in-memory database (tests) + /// + /// On startup the constructor: + /// 1. Creates parent directories if the path is a file and they do + /// not exist. + /// 2. Opens a connection pool capped at 5 connections. + /// 3. Enables WAL journal mode for better read concurrency. + /// 4. Enables foreign-key enforcement (off by default in SQLite). + pub async fn new(database_url: &str) -> Result { + // For SQLite, create the database file if the URL is a file path + if let Some(path) = database_url.strip_prefix("sqlite:") { + let path = path.trim_start_matches("//"); + if path != ":memory:" && !path.starts_with(":memory:") { + if let Some(parent) = std::path::Path::new(path).parent() { + std::fs::create_dir_all(parent).ok(); + } + } + } + + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect(database_url) + .await?; + + // WAL (Write-Ahead Logging) mode allows concurrent readers while + // a write is in progress, significantly improving throughput for + // the mixed read/write workload of a CI server. + sqlx::query("PRAGMA journal_mode=WAL") + .execute(&pool) + .await?; + // Foreign keys are disabled by default in SQLite; we rely on them + // for referential integrity (e.g., jobs -> projects, builds -> agent_sessions). + sqlx::query("PRAGMA foreign_keys=ON") + .execute(&pool) + .await?; + + Ok(Self { pool }) + } + + /// Borrow the underlying connection pool. + /// + /// Exposed for advanced use-cases (e.g., running raw queries in + /// integration tests). Production code should prefer the + /// [`StorageBackend`] trait methods. + pub fn pool(&self) -> &SqlitePool { + &self.pool + } +} + +// ── Helper functions ───────────────────────────────────────────────── +// +// These are private utilities that handle the impedance mismatch between +// SQLite's TEXT storage and Rust's strongly-typed domain model. + +/// Parse a UUID stored as a TEXT column. +/// +/// Wraps the parse error in a `DbError::Sqlx(Protocol(...))` so that +/// callers can use the `?` operator uniformly. +fn parse_uuid(s: &str) -> Result { + Uuid::parse_str(s) + .map_err(|e| DbError::Sqlx(sqlx::Error::Protocol(format!("invalid uuid: {e}")))) +} + +/// Parse a datetime TEXT column into a timezone-aware `DateTime`. +/// +/// SQLite's `datetime('now')` produces `YYYY-MM-DD HH:MM:SS`, but other +/// code paths may produce ISO-8601 variants. This function tries +/// several formats in order of likelihood so that we never fail on a +/// datetime that was legitimately written by us. +fn parse_datetime(s: &str) -> Result> { + // Most common: our own `now_str()` format + if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + return Ok(dt.and_utc()); + } + // ISO-8601 without fractional seconds + if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") { + return Ok(dt.and_utc()); + } + // ISO-8601 with fractional seconds (e.g., from serde_json roundtrip) + if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") { + return Ok(dt.and_utc()); + } + // Full RFC-3339 with timezone offset + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Ok(dt.with_timezone(&Utc)); + } + Err(DbError::Sqlx(sqlx::Error::Protocol(format!( + "invalid datetime: {s}" + )))) +} + +/// Parse an optional datetime TEXT column. +/// +/// `NULL` columns come through as `None`, which maps to `Ok(None)`. +fn parse_optional_datetime(s: Option) -> Result>> { + match s { + Some(s) => Ok(Some(parse_datetime(&s)?)), + None => Ok(None), + } +} + +/// Parse an optional UUID TEXT column (`NULL` -> `Ok(None)`). +fn parse_optional_uuid(s: Option) -> Result> { + match s { + Some(s) => Ok(Some(parse_uuid(&s)?)), + None => Ok(None), + } +} + +/// Format the current UTC time as `YYYY-MM-DD HH:MM:SS` for insertion +/// into TEXT datetime columns. +fn now_str() -> String { + Utc::now().format("%Y-%m-%d %H:%M:%S").to_string() +} + +/// Parse a string into an enum that implements `FromStr`. +/// +/// All `jupiter-api-types` enums (e.g., `JobStatus`, `TaskType`, +/// `BuildStatus`) implement this convention. The wrapper converts the +/// `String` error into our `DbError` type. +fn parse_enum>(s: &str) -> Result { + s.parse::() + .map_err(|e| DbError::Sqlx(sqlx::Error::Protocol(e))) +} + +// ── Row mapping functions ──────────────────────────────────────────── +// +// Each `row_to_*` function converts a raw `SqliteRow` into the +// corresponding API type. They are kept as free functions (rather than +// methods) so that they can be passed to `Iterator::map` concisely. +// +// Every function follows the same pattern: +// 1. Extract TEXT columns by name. +// 2. Parse UUIDs, datetimes, enums, and JSON via the helpers above. +// 3. Assemble and return the domain struct. + +/// Map a row from the `accounts` table to an [`Account`]. +fn row_to_account(row: &SqliteRow) -> Result { + let id_str: String = row.get("id"); + let account_type_str: String = row.get("account_type"); + + Ok(Account { + id: Id::from_uuid(parse_uuid(&id_str)?), + name: row.get("name"), + typ: parse_enum(&account_type_str)?, + }) +} + +/// Map a row from the `cluster_join_tokens` table to a [`ClusterJoinToken`]. +/// +/// Note: the `token_hash` column is intentionally **not** included in +/// the returned struct -- it is only fetched by dedicated hash-lookup +/// methods. +fn row_to_cluster_join_token(row: &SqliteRow) -> Result { + let id_str: String = row.get("id"); + let account_id_str: String = row.get("account_id"); + let created_at_str: String = row.get("created_at"); + + Ok(ClusterJoinToken { + id: Id::from_uuid(parse_uuid(&id_str)?), + account_id: Id::from_uuid(parse_uuid(&account_id_str)?), + name: row.get("name"), + created_at: parse_datetime(&created_at_str)?, + }) +} + +/// Map a row from the `agent_sessions` table to an [`AgentSession`]. +/// +/// The `platforms` and `system_features` columns are JSON arrays stored +/// as TEXT; they are deserialized into `Vec`. The session's +/// `agent_id` is set equal to its `id` (the agent does not yet have a +/// separate identity). +fn row_to_agent_session(row: &SqliteRow) -> Result { + let id_str: String = row.get("id"); + let platforms_json: String = row.get("platforms"); + let features_json: String = row.get("system_features"); + let connected_at_str: String = row.get("connected_at"); + let concurrency_val: i32 = row.get("concurrency"); + + let id_uuid = parse_uuid(&id_str)?; + + Ok(AgentSession { + id: Id::from_uuid(id_uuid), + // agent_id mirrors the session id; agents do not have a separate + // persistent identity in the current data model. + agent_id: Id::from_uuid(id_uuid), + hostname: row.get("hostname"), + platforms: serde_json::from_str(&platforms_json)?, + system_features: serde_json::from_str(&features_json)?, + concurrency: concurrency_val as u32, + connected_at: parse_datetime(&connected_at_str)?, + }) +} + +/// Map a row from the `repos` table to a [`Repo`]. +fn row_to_repo(row: &SqliteRow) -> Result { + let id_str: String = row.get("id"); + let forge_id_str: String = row.get("forge_id"); + + Ok(Repo { + id: Id::from_uuid(parse_uuid(&id_str)?), + forge_id: Id::from_uuid(parse_uuid(&forge_id_str)?), + owner: row.get("owner"), + name: row.get("name"), + clone_url: row.get("clone_url"), + default_branch: row.get("default_branch"), + }) +} + +/// Map a row from the `projects` table to a [`Project`]. +/// +/// SQLite stores booleans as INTEGER (0/1); we convert accordingly. +fn row_to_project(row: &SqliteRow) -> Result { + let id_str: String = row.get("id"); + let account_id_str: String = row.get("account_id"); + let repo_id_str: String = row.get("repo_id"); + let enabled_int: i32 = row.get("enabled"); + + Ok(Project { + id: Id::from_uuid(parse_uuid(&id_str)?), + account_id: Id::from_uuid(parse_uuid(&account_id_str)?), + repo_id: Id::from_uuid(parse_uuid(&repo_id_str)?), + name: row.get("name"), + enabled: enabled_int != 0, + }) +} + +/// Map a row from the `jobs` table to a [`Job`]. +/// +/// Jobs carry denormalized forge/repo metadata so the API layer can +/// render them without additional joins. +fn row_to_job(row: &SqliteRow) -> Result { + let id_str: String = row.get("id"); + let project_id_str: String = row.get("project_id"); + let forge_type_str: String = row.get("forge_type"); + let status_str: String = row.get("status"); + let created_at_str: String = row.get("created_at"); + let updated_at_str: String = row.get("updated_at"); + let seq: i64 = row.get("sequence_number"); + + Ok(Job { + id: Id::from_uuid(parse_uuid(&id_str)?), + project_id: Id::from_uuid(parse_uuid(&project_id_str)?), + forge_type: parse_enum(&forge_type_str)?, + repo_owner: row.get("repo_owner"), + repo_name: row.get("repo_name"), + ref_name: row.get("ref_name"), + commit_sha: row.get("commit_sha"), + status: parse_enum(&status_str)?, + sequence_number: seq as u64, + created_at: parse_datetime(&created_at_str)?, + updated_at: parse_datetime(&updated_at_str)?, + }) +} + +/// Map a row from a lightweight jobs query to a [`JobSummary`]. +/// +/// Unlike [`row_to_job`] this only requires the columns selected by +/// `list_jobs_for_project`: `id`, `ref_name`, `commit_sha`, `status`, +/// `created_at`. The `project_id` is passed in from the caller rather +/// than read from the row. +fn row_to_job_summary(row: &SqliteRow, project_id: &Id) -> Result { + let id_str: String = row.get("id"); + let status_str: String = row.get("status"); + let created_at_str: String = row.get("created_at"); + + Ok(JobSummary { + id: Id::from_uuid(parse_uuid(&id_str)?), + project_id: project_id.clone(), + ref_name: row.get("ref_name"), + commit_sha: row.get("commit_sha"), + status: parse_enum(&status_str)?, + created_at: parse_datetime(&created_at_str)?, + }) +} + +/// Map a row from the `builds` table to a [`Build`]. +/// +/// Nullable timestamp/agent columns are handled via [`parse_optional_datetime`] +/// and [`parse_optional_uuid`]. +fn row_to_build(row: &SqliteRow) -> Result { + let id_str: String = row.get("id"); + let status_str: String = row.get("status"); + let agent_session_id: Option = row.get("agent_session_id"); + let started_at: Option = row.get("started_at"); + let completed_at: Option = row.get("completed_at"); + + Ok(Build { + id: Id::from_uuid(parse_uuid(&id_str)?), + derivation_path: row.get("derivation_path"), + status: parse_enum(&status_str)?, + started_at: parse_optional_datetime(started_at)?, + completed_at: parse_optional_datetime(completed_at)?, + agent_session_id: parse_optional_uuid(agent_session_id)?.map(Id::from_uuid), + }) +} + +/// Map a row from the `effects` table to an [`Effect`]. +/// +/// The `attribute_path` column is a JSON array of strings (e.g., +/// `["onPush", "deploy"]`). +fn row_to_effect(row: &SqliteRow) -> Result { + let id_str: String = row.get("id"); + let job_id_str: String = row.get("job_id"); + let attr_path_json: String = row.get("attribute_path"); + let status_str: String = row.get("status"); + let started_at: Option = row.get("started_at"); + let completed_at: Option = row.get("completed_at"); + + Ok(Effect { + id: Id::from_uuid(parse_uuid(&id_str)?), + job_id: Id::from_uuid(parse_uuid(&job_id_str)?), + attribute_path: serde_json::from_str(&attr_path_json)?, + derivation_path: row.get("derivation_path"), + status: parse_enum(&status_str)?, + started_at: parse_optional_datetime(started_at)?, + completed_at: parse_optional_datetime(completed_at)?, + }) +} + +/// Map a row from the `state_files` table to a [`StateFile`]. +/// +/// Only metadata is returned (name, version, size, timestamps). +/// The actual binary `data` blob is fetched separately by +/// [`StorageBackend::get_state_file`]. +fn row_to_state_file(row: &SqliteRow) -> Result { + let project_id_str: String = row.get("project_id"); + let updated_at_str: String = row.get("updated_at"); + let version: i64 = row.get("version"); + let size_bytes: i64 = row.get("size_bytes"); + + Ok(StateFile { + name: row.get("name"), + project_id: Id::from_uuid(parse_uuid(&project_id_str)?), + version: version as u64, + size_bytes: size_bytes as u64, + updated_at: parse_datetime(&updated_at_str)?, + }) +} + +/// Map a row from the `state_locks` table to a [`StateLock`]. +fn row_to_state_lock(row: &SqliteRow) -> Result { + let id_str: String = row.get("id"); + let project_id_str: String = row.get("project_id"); + let expires_at_str: String = row.get("expires_at"); + let created_at_str: String = row.get("created_at"); + + Ok(StateLock { + id: Id::from_uuid(parse_uuid(&id_str)?), + project_id: Id::from_uuid(parse_uuid(&project_id_str)?), + name: row.get("name"), + owner: row.get("owner"), + expires_at: parse_datetime(&expires_at_str)?, + created_at: parse_datetime(&created_at_str)?, + }) +} + +/// Map a row from the `secrets` table to a [`Secret`]. +/// +/// The `data` column is wrapped in [`Sensitive`] to prevent accidental +/// logging. The `condition` column is a JSON-serialized +/// [`SecretCondition`] enum (e.g., `"always"` or `{"branch":"main"}`). +fn row_to_secret(row: &SqliteRow) -> Result { + let id_str: String = row.get("id"); + let project_id_str: String = row.get("project_id"); + let data_str: String = row.get("data"); + let condition_str: String = row.get("condition"); + + Ok(Secret { + id: Id::from_uuid(parse_uuid(&id_str)?), + project_id: Id::from_uuid(parse_uuid(&project_id_str)?), + name: row.get("name"), + data: Sensitive::new(serde_json::from_str(&data_str)?), + condition: serde_json::from_str(&condition_str)?, + }) +} + +// ── StorageBackend impl ────────────────────────────────────────────── + +#[async_trait] +impl StorageBackend for SqliteBackend { + // ── Initialization ─────────────────────────────────────────────── + + async fn run_migrations(&self) -> Result<()> { + sqlx::migrate!("./migrations").run(&self.pool).await?; + tracing::info!("database migrations completed"); + Ok(()) + } + + // ── Accounts ───────────────────────────────────────────────────── + + async fn create_account(&self, name: &str, typ: AccountType) -> Result { + let id = Uuid::new_v4(); + let now = now_str(); + let type_str = typ.to_string(); + + sqlx::query( + "INSERT INTO accounts (id, name, account_type, created_at) VALUES (?1, ?2, ?3, ?4)", + ) + .bind(id.to_string()) + .bind(name) + .bind(&type_str) + .bind(&now) + .execute(&self.pool) + .await?; + + Ok(Account { + id: Id::from_uuid(id), + name: name.to_string(), + typ, + }) + } + + async fn get_account(&self, id: Uuid) -> Result { + let row = sqlx::query("SELECT * FROM accounts WHERE id = ?1") + .bind(id.to_string()) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::NotFound { + entity: "account".into(), + id: id.to_string(), + })?; + row_to_account(&row) + } + + async fn get_account_by_name(&self, name: &str) -> Result { + let row = sqlx::query("SELECT * FROM accounts WHERE name = ?1") + .bind(name) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::NotFound { + entity: "account".into(), + id: name.to_string(), + })?; + row_to_account(&row) + } + + async fn get_account_password_hash(&self, name: &str) -> Result> { + let row = sqlx::query("SELECT password_hash FROM accounts WHERE name = ?1") + .bind(name) + .fetch_optional(&self.pool) + .await?; + Ok(row.and_then(|r| r.get::, _>("password_hash"))) + } + + async fn set_account_password_hash(&self, id: Uuid, password_hash: &str) -> Result<()> { + let result = sqlx::query("UPDATE accounts SET password_hash = ?1 WHERE id = ?2") + .bind(password_hash) + .bind(id.to_string()) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::NotFound { + entity: "account".into(), + id: id.to_string(), + }); + } + Ok(()) + } + + async fn list_accounts(&self) -> Result> { + let rows = sqlx::query("SELECT * FROM accounts ORDER BY created_at") + .fetch_all(&self.pool) + .await?; + rows.iter().map(row_to_account).collect() + } + + // ── Cluster Join Tokens ────────────────────────────────────────── + + async fn create_cluster_join_token( + &self, + account_id: Uuid, + name: &str, + token_hash: &str, + ) -> Result { + let id = Uuid::new_v4(); + let now = now_str(); + + sqlx::query( + "INSERT INTO cluster_join_tokens (id, account_id, name, token_hash, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + ) + .bind(id.to_string()) + .bind(account_id.to_string()) + .bind(name) + .bind(token_hash) + .bind(&now) + .execute(&self.pool) + .await?; + + Ok(ClusterJoinToken { + id: Id::from_uuid(id), + account_id: Id::from_uuid(account_id), + name: name.to_string(), + created_at: parse_datetime(&now)?, + }) + } + + async fn list_cluster_join_tokens(&self, account_id: Uuid) -> Result> { + let rows = sqlx::query( + "SELECT * FROM cluster_join_tokens WHERE account_id = ?1 ORDER BY created_at", + ) + .bind(account_id.to_string()) + .fetch_all(&self.pool) + .await?; + rows.iter().map(row_to_cluster_join_token).collect() + } + + async fn get_cluster_join_token_hash(&self, token_id: Uuid) -> Result { + let row = sqlx::query("SELECT token_hash FROM cluster_join_tokens WHERE id = ?1") + .bind(token_id.to_string()) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::NotFound { + entity: "cluster_join_token".into(), + id: token_id.to_string(), + })?; + Ok(row.get("token_hash")) + } + + async fn delete_cluster_join_token(&self, token_id: Uuid) -> Result<()> { + let result = sqlx::query("DELETE FROM cluster_join_tokens WHERE id = ?1") + .bind(token_id.to_string()) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::NotFound { + entity: "cluster_join_token".into(), + id: token_id.to_string(), + }); + } + Ok(()) + } + + async fn find_cluster_join_token_by_hash( + &self, + account_id: Uuid, + ) -> Result> { + // Fetch all token hashes for the account so the auth layer can + // bcrypt-verify the presented bearer token against each one. + let rows = + sqlx::query("SELECT id, token_hash FROM cluster_join_tokens WHERE account_id = ?1") + .bind(account_id.to_string()) + .fetch_all(&self.pool) + .await?; + rows.iter() + .map(|row| { + let id_str: String = row.get("id"); + let hash: String = row.get("token_hash"); + Ok((parse_uuid(&id_str)?, hash)) + }) + .collect() + } + + // ── Agent Sessions ─────────────────────────────────────────────── + + async fn create_agent_session( + &self, + agent_hello: &AgentHello, + account_id: Uuid, + ) -> Result { + let id = Uuid::new_v4(); + let now = now_str(); + // Serialize list fields to JSON for storage in TEXT columns. + let platforms_json = serde_json::to_string(&agent_hello.platforms)?; + let features_json = serde_json::to_string(&agent_hello.system_features)?; + + sqlx::query( + "INSERT INTO agent_sessions (id, account_id, hostname, platforms, system_features, concurrency, agent_version, nix_version, connected_at, last_heartbeat) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + ) + .bind(id.to_string()) + .bind(account_id.to_string()) + .bind(&agent_hello.hostname) + .bind(&platforms_json) + .bind(&features_json) + .bind(agent_hello.concurrency as i64) + .bind(&agent_hello.agent_version) + .bind(&agent_hello.nix_version) + .bind(&now) + .bind(&now) + .execute(&self.pool) + .await?; + + Ok(AgentSession { + id: Id::from_uuid(id), + agent_id: Id::from_uuid(id), + hostname: agent_hello.hostname.clone(), + platforms: agent_hello.platforms.clone(), + system_features: agent_hello.system_features.clone(), + concurrency: agent_hello.concurrency, + connected_at: parse_datetime(&now)?, + }) + } + + async fn get_agent_session(&self, id: Uuid) -> Result { + let row = sqlx::query("SELECT * FROM agent_sessions WHERE id = ?1") + .bind(id.to_string()) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::NotFound { + entity: "agent_session".into(), + id: id.to_string(), + })?; + row_to_agent_session(&row) + } + + async fn list_agent_sessions(&self) -> Result> { + let rows = sqlx::query("SELECT * FROM agent_sessions ORDER BY connected_at") + .fetch_all(&self.pool) + .await?; + rows.iter().map(row_to_agent_session).collect() + } + + async fn update_agent_heartbeat(&self, id: Uuid) -> Result<()> { + let now = now_str(); + let result = sqlx::query("UPDATE agent_sessions SET last_heartbeat = ?1 WHERE id = ?2") + .bind(&now) + .bind(id.to_string()) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::NotFound { + entity: "agent_session".into(), + id: id.to_string(), + }); + } + Ok(()) + } + + async fn delete_agent_session(&self, id: Uuid) -> Result<()> { + let result = sqlx::query("DELETE FROM agent_sessions WHERE id = ?1") + .bind(id.to_string()) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::NotFound { + entity: "agent_session".into(), + id: id.to_string(), + }); + } + Ok(()) + } + + async fn get_active_agent_sessions_for_platform( + &self, + platform: &str, + ) -> Result> { + // The `platforms` column stores a JSON array like `["x86_64-linux"]`. + // We use a LIKE pattern to check containment. This is imprecise + // (e.g., "linux" would match "x86_64-linux") but acceptable + // because platform strings are well-structured Nix system tuples. + let like_pattern = format!("%\"{platform}\"%"); + let rows = sqlx::query( + "SELECT * FROM agent_sessions WHERE platforms LIKE ?1 ORDER BY connected_at", + ) + .bind(&like_pattern) + .fetch_all(&self.pool) + .await?; + rows.iter().map(row_to_agent_session).collect() + } + + // ── Repos ──────────────────────────────────────────────────────── + + async fn create_repo( + &self, + forge_id: Uuid, + owner: &str, + name: &str, + clone_url: &str, + default_branch: &str, + ) -> Result { + let id = Uuid::new_v4(); + let now = now_str(); + + sqlx::query( + "INSERT INTO repos (id, forge_id, owner, name, clone_url, default_branch, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(id.to_string()) + .bind(forge_id.to_string()) + .bind(owner) + .bind(name) + .bind(clone_url) + .bind(default_branch) + .bind(&now) + .execute(&self.pool) + .await?; + + Ok(Repo { + id: Id::from_uuid(id), + forge_id: Id::from_uuid(forge_id), + owner: owner.to_string(), + name: name.to_string(), + clone_url: clone_url.to_string(), + default_branch: default_branch.to_string(), + }) + } + + async fn get_repo(&self, id: Uuid) -> Result { + let row = sqlx::query("SELECT * FROM repos WHERE id = ?1") + .bind(id.to_string()) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::NotFound { + entity: "repo".into(), + id: id.to_string(), + })?; + row_to_repo(&row) + } + + async fn find_repo( + &self, + forge_id: Uuid, + owner: &str, + name: &str, + ) -> Result> { + let row = sqlx::query( + "SELECT * FROM repos WHERE forge_id = ?1 AND owner = ?2 AND name = ?3", + ) + .bind(forge_id.to_string()) + .bind(owner) + .bind(name) + .fetch_optional(&self.pool) + .await?; + match row { + Some(r) => Ok(Some(row_to_repo(&r)?)), + None => Ok(None), + } + } + + async fn list_repos(&self, forge_id: Uuid) -> Result> { + let rows = sqlx::query("SELECT * FROM repos WHERE forge_id = ?1 ORDER BY created_at") + .bind(forge_id.to_string()) + .fetch_all(&self.pool) + .await?; + rows.iter().map(row_to_repo).collect() + } + + // ── Projects ───────────────────────────────────────────────────── + + async fn create_project( + &self, + account_id: Uuid, + repo_id: Uuid, + name: &str, + ) -> Result { + let id = Uuid::new_v4(); + let now = now_str(); + + // Projects are enabled (1) by default. + sqlx::query( + "INSERT INTO projects (id, account_id, repo_id, name, enabled, created_at) \ + VALUES (?1, ?2, ?3, ?4, 1, ?5)", + ) + .bind(id.to_string()) + .bind(account_id.to_string()) + .bind(repo_id.to_string()) + .bind(name) + .bind(&now) + .execute(&self.pool) + .await?; + + Ok(Project { + id: Id::from_uuid(id), + account_id: Id::from_uuid(account_id), + repo_id: Id::from_uuid(repo_id), + name: name.to_string(), + enabled: true, + }) + } + + async fn get_project(&self, id: Uuid) -> Result { + let row = sqlx::query("SELECT * FROM projects WHERE id = ?1") + .bind(id.to_string()) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::NotFound { + entity: "project".into(), + id: id.to_string(), + })?; + row_to_project(&row) + } + + async fn get_project_by_name(&self, name: &str) -> Result { + let row = sqlx::query("SELECT * FROM projects WHERE name = ?1") + .bind(name) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::NotFound { + entity: "project".into(), + id: name.to_string(), + })?; + row_to_project(&row) + } + + async fn update_project(&self, id: Uuid, enabled: bool) -> Result { + // SQLite booleans are stored as INTEGER 0/1. + let enabled_int: i32 = if enabled { 1 } else { 0 }; + let result = sqlx::query("UPDATE projects SET enabled = ?1 WHERE id = ?2") + .bind(enabled_int) + .bind(id.to_string()) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::NotFound { + entity: "project".into(), + id: id.to_string(), + }); + } + // Re-fetch to return the full updated row. + self.get_project(id).await + } + + async fn list_projects(&self) -> Result> { + let rows = sqlx::query("SELECT * FROM projects ORDER BY created_at") + .fetch_all(&self.pool) + .await?; + rows.iter().map(row_to_project).collect() + } + + async fn find_project_by_repo(&self, repo_id: Uuid) -> Result> { + let row = sqlx::query("SELECT * FROM projects WHERE repo_id = ?1") + .bind(repo_id.to_string()) + .fetch_optional(&self.pool) + .await?; + match row { + Some(r) => Ok(Some(row_to_project(&r)?)), + None => Ok(None), + } + } + + // ── Jobs ───────────────────────────────────────────────────────── + + async fn create_job( + &self, + project_id: Uuid, + forge_type: ForgeType, + repo_owner: &str, + repo_name: &str, + ref_name: &str, + commit_sha: &str, + ) -> Result { + let id = Uuid::new_v4(); + let now = now_str(); + // Obtain a monotonically-increasing sequence number scoped to + // (project, ref). This is used to order effects and detect + // superseded jobs. + let seq = self.get_next_sequence_number(project_id, ref_name).await?; + + sqlx::query( + "INSERT INTO jobs (id, project_id, forge_type, repo_owner, repo_name, ref_name, commit_sha, status, sequence_number, created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', ?8, ?9, ?10)", + ) + .bind(id.to_string()) + .bind(project_id.to_string()) + .bind(forge_type.to_string()) + .bind(repo_owner) + .bind(repo_name) + .bind(ref_name) + .bind(commit_sha) + .bind(seq) + .bind(&now) + .bind(&now) + .execute(&self.pool) + .await?; + + let created_at = parse_datetime(&now)?; + + Ok(Job { + id: Id::from_uuid(id), + project_id: Id::from_uuid(project_id), + forge_type, + repo_owner: repo_owner.to_string(), + repo_name: repo_name.to_string(), + ref_name: ref_name.to_string(), + commit_sha: commit_sha.to_string(), + status: JobStatus::Pending, + sequence_number: seq as u64, + created_at, + updated_at: created_at, + }) + } + + async fn get_job(&self, id: Uuid) -> Result { + let row = sqlx::query("SELECT * FROM jobs WHERE id = ?1") + .bind(id.to_string()) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::NotFound { + entity: "job".into(), + id: id.to_string(), + })?; + row_to_job(&row) + } + + async fn update_job_status(&self, id: Uuid, status: JobStatus) -> Result<()> { + let now = now_str(); + let result = + sqlx::query("UPDATE jobs SET status = ?1, updated_at = ?2 WHERE id = ?3") + .bind(status.to_string()) + .bind(&now) + .bind(id.to_string()) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::NotFound { + entity: "job".into(), + id: id.to_string(), + }); + } + Ok(()) + } + + async fn list_jobs_for_project( + &self, + project_id: Uuid, + page: u64, + per_page: u64, + ) -> Result<(Vec, u64)> { + // Two queries: one for total count (pagination headers), one + // for the page of results. + let count_row = sqlx::query("SELECT COUNT(*) as cnt FROM jobs WHERE project_id = ?1") + .bind(project_id.to_string()) + .fetch_one(&self.pool) + .await?; + let total: i64 = count_row.get("cnt"); + + // Pages are 1-based; convert to a zero-based OFFSET. + let offset = (page.saturating_sub(1)) * per_page; + let rows = sqlx::query( + "SELECT id, ref_name, commit_sha, status, created_at \ + FROM jobs WHERE project_id = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3", + ) + .bind(project_id.to_string()) + .bind(per_page as i64) + .bind(offset as i64) + .fetch_all(&self.pool) + .await?; + + let pid: Id = Id::from_uuid(project_id); + let summaries: Result> = + rows.iter().map(|r| row_to_job_summary(r, &pid)).collect(); + Ok((summaries?, total as u64)) + } + + async fn get_latest_job_for_ref( + &self, + project_id: Uuid, + ref_name: &str, + ) -> Result> { + let row = sqlx::query( + "SELECT * FROM jobs WHERE project_id = ?1 AND ref_name = ?2 ORDER BY sequence_number DESC LIMIT 1", + ) + .bind(project_id.to_string()) + .bind(ref_name) + .fetch_optional(&self.pool) + .await?; + match row { + Some(r) => Ok(Some(row_to_job(&r)?)), + None => Ok(None), + } + } + + async fn get_next_sequence_number( + &self, + project_id: Uuid, + ref_name: &str, + ) -> Result { + // COALESCE handles the case where no jobs exist yet for this + // (project, ref) -- starts at 0, so the first sequence is 1. + let row = sqlx::query( + "SELECT COALESCE(MAX(sequence_number), 0) as max_seq FROM jobs WHERE project_id = ?1 AND ref_name = ?2", + ) + .bind(project_id.to_string()) + .bind(ref_name) + .fetch_one(&self.pool) + .await?; + let max_seq: i64 = row.get("max_seq"); + Ok(max_seq + 1) + } + + // ── Task Queue ─────────────────────────────────────────────────── + + async fn enqueue_task( + &self, + job_id: Uuid, + task_type: TaskType, + platform: Option<&str>, + payload: &serde_json::Value, + ) -> Result { + let id = Uuid::new_v4(); + let now = now_str(); + let payload_str = serde_json::to_string(payload)?; + + sqlx::query( + "INSERT INTO task_queue (id, job_id, task_type, status, platform, payload, created_at, updated_at) \ + VALUES (?1, ?2, ?3, 'pending', ?4, ?5, ?6, ?7)", + ) + .bind(id.to_string()) + .bind(job_id.to_string()) + .bind(task_type.to_string()) + .bind(platform) + .bind(&payload_str) + .bind(&now) + .bind(&now) + .execute(&self.pool) + .await?; + + Ok(id) + } + + async fn dequeue_task( + &self, + platform: &str, + _system_features: &[String], + ) -> Result> { + // Use a transaction to atomically select and mark the task as + // 'running'. This prevents two agents from claiming the same + // task. Tasks with `platform IS NULL` can run on any agent, + // so they match every dequeue request. + let mut tx = self.pool.begin().await?; + + let row = sqlx::query( + "SELECT id, task_type, payload FROM task_queue \ + WHERE status = 'pending' AND (platform IS NULL OR platform = ?1) \ + ORDER BY created_at ASC LIMIT 1", + ) + .bind(platform) + .fetch_optional(&mut *tx) + .await?; + + let row = match row { + Some(r) => r, + None => { + tx.commit().await?; + return Ok(None); + } + }; + + let id_str: String = row.get("id"); + let task_type_str: String = row.get("task_type"); + let payload_str: String = row.get("payload"); + let now = now_str(); + + // Mark the task as running so no other agent can claim it. + sqlx::query("UPDATE task_queue SET status = 'running', updated_at = ?1 WHERE id = ?2") + .bind(&now) + .bind(&id_str) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + let task_id = parse_uuid(&id_str)?; + let task_type: TaskType = parse_enum(&task_type_str)?; + let payload: serde_json::Value = serde_json::from_str(&payload_str)?; + + Ok(Some((task_id, task_type, payload))) + } + + async fn update_task_status( + &self, + task_id: Uuid, + status: TaskStatus, + agent_session_id: Option, + ) -> Result<()> { + let now = now_str(); + let result = sqlx::query( + "UPDATE task_queue SET status = ?1, agent_session_id = ?2, updated_at = ?3 WHERE id = ?4", + ) + .bind(status.to_string()) + .bind(agent_session_id.map(|u| u.to_string())) + .bind(&now) + .bind(task_id.to_string()) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::NotFound { + entity: "task".into(), + id: task_id.to_string(), + }); + } + Ok(()) + } + + async fn get_task( + &self, + task_id: Uuid, + ) -> Result<(Uuid, TaskType, TaskStatus, serde_json::Value)> { + let row = + sqlx::query("SELECT id, task_type, status, payload FROM task_queue WHERE id = ?1") + .bind(task_id.to_string()) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::NotFound { + entity: "task".into(), + id: task_id.to_string(), + })?; + + let id_str: String = row.get("id"); + let task_type_str: String = row.get("task_type"); + let status_str: String = row.get("status"); + let payload_str: String = row.get("payload"); + + Ok(( + parse_uuid(&id_str)?, + parse_enum(&task_type_str)?, + parse_enum(&status_str)?, + serde_json::from_str(&payload_str)?, + )) + } + + async fn requeue_agent_tasks(&self, agent_session_id: Uuid) -> Result> { + let now = now_str(); + + // First collect the IDs so we can return them to the caller + // (for logging / notification purposes). + let rows = sqlx::query( + "SELECT id FROM task_queue WHERE agent_session_id = ?1 AND status = 'running'", + ) + .bind(agent_session_id.to_string()) + .fetch_all(&self.pool) + .await?; + + let ids: Vec = rows.iter().map(|r| r.get::("id")).collect(); + let uuids: Result> = ids.iter().map(|s| parse_uuid(s)).collect(); + let uuids = uuids?; + + // Reset all running tasks for this agent back to pending and + // clear the agent assignment so another agent can pick them up. + sqlx::query( + "UPDATE task_queue SET status = 'pending', agent_session_id = NULL, updated_at = ?1 \ + WHERE agent_session_id = ?2 AND status = 'running'", + ) + .bind(&now) + .bind(agent_session_id.to_string()) + .execute(&self.pool) + .await?; + + Ok(uuids) + } + + async fn get_task_job_id(&self, task_id: Uuid) -> Result { + let row = sqlx::query("SELECT job_id FROM task_queue WHERE id = ?1") + .bind(task_id.to_string()) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::NotFound { + entity: "task".into(), + id: task_id.to_string(), + })?; + parse_uuid(&row.get::("job_id")) + } + + // ── Evaluations / Attributes ───────────────────────────────────── + + async fn store_attribute( + &self, + job_id: Uuid, + path: &[String], + derivation_path: Option<&str>, + typ: AttributeType, + error: Option<&str>, + ) -> Result<()> { + let id = Uuid::new_v4().to_string(); + let now = now_str(); + // The attribute path is stored as a JSON array of strings so it + // can be reconstructed exactly (preserving segment boundaries). + let path_json = serde_json::to_string(path)?; + + sqlx::query( + "INSERT INTO attributes (id, job_id, path, derivation_path, attribute_type, error, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + ) + .bind(&id) + .bind(job_id.to_string()) + .bind(&path_json) + .bind(derivation_path) + .bind(typ.to_string()) + .bind(error) + .bind(&now) + .execute(&self.pool) + .await?; + + Ok(()) + } + + async fn store_derivation_info( + &self, + job_id: Uuid, + derivation_path: &str, + platform: &str, + required_system_features: &[String], + input_derivations: &[String], + outputs: &serde_json::Value, + ) -> Result<()> { + let id = Uuid::new_v4().to_string(); + let now = now_str(); + // Serialize array/object fields to JSON TEXT columns. + let features_json = serde_json::to_string(required_system_features)?; + let inputs_json = serde_json::to_string(input_derivations)?; + let outputs_str = serde_json::to_string(outputs)?; + + sqlx::query( + "INSERT INTO derivation_info (id, job_id, derivation_path, platform, required_system_features, input_derivations, outputs, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + ) + .bind(&id) + .bind(job_id.to_string()) + .bind(derivation_path) + .bind(platform) + .bind(&features_json) + .bind(&inputs_json) + .bind(&outputs_str) + .bind(&now) + .execute(&self.pool) + .await?; + + Ok(()) + } + + async fn get_evaluation_attributes(&self, job_id: Uuid) -> Result> { + let rows = sqlx::query( + "SELECT path, derivation_path, attribute_type, error FROM attributes WHERE job_id = ?1 ORDER BY created_at", + ) + .bind(job_id.to_string()) + .fetch_all(&self.pool) + .await?; + + rows.iter() + .map(|row| { + let path_json: String = row.get("path"); + let attr_type_str: String = row.get("attribute_type"); + Ok(AttributeResult { + path: serde_json::from_str(&path_json)?, + derivation_path: row.get("derivation_path"), + typ: parse_enum(&attr_type_str)?, + error: row.get("error"), + }) + }) + .collect() + } + + async fn get_derivation_paths_for_job(&self, job_id: Uuid) -> Result> { + // SELECT DISTINCT to avoid duplicates when multiple attributes + // share the same derivation (e.g., different output paths of + // the same derivation). + let rows = sqlx::query( + "SELECT DISTINCT derivation_path FROM attributes WHERE job_id = ?1 AND derivation_path IS NOT NULL", + ) + .bind(job_id.to_string()) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .iter() + .map(|r| r.get::("derivation_path")) + .collect()) + } + + async fn get_derivation_platform( + &self, + derivation_path: &str, + ) -> Result> { + let row = sqlx::query( + "SELECT platform FROM derivation_info WHERE derivation_path = ?1 LIMIT 1", + ) + .bind(derivation_path) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|r| r.get::("platform"))) + } + + // ── Builds ─────────────────────────────────────────────────────── + + async fn create_or_get_build(&self, derivation_path: &str) -> Result<(Uuid, bool)> { + let new_id = Uuid::new_v4().to_string(); + let now = now_str(); + + // INSERT OR IGNORE: if a build for this derivation_path already + // exists (UNIQUE constraint on derivation_path), the insert is + // silently skipped. We then SELECT the existing row. + let result = sqlx::query( + "INSERT OR IGNORE INTO builds (id, derivation_path, status, created_at) \ + VALUES (?1, ?2, 'pending', ?3)", + ) + .bind(&new_id) + .bind(derivation_path) + .bind(&now) + .execute(&self.pool) + .await?; + + // rows_affected == 0 means the insert was ignored (build already exists). + let was_created = result.rows_affected() > 0; + + // Fetch the canonical ID (might be ours or the pre-existing one). + let row = sqlx::query("SELECT id FROM builds WHERE derivation_path = ?1") + .bind(derivation_path) + .fetch_one(&self.pool) + .await?; + + let id_str: String = row.get("id"); + Ok((parse_uuid(&id_str)?, was_created)) + } + + async fn get_build(&self, id: Uuid) -> Result { + let row = sqlx::query("SELECT * FROM builds WHERE id = ?1") + .bind(id.to_string()) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::NotFound { + entity: "build".into(), + id: id.to_string(), + })?; + row_to_build(&row) + } + + async fn get_build_by_drv_path(&self, derivation_path: &str) -> Result> { + let row = sqlx::query("SELECT * FROM builds WHERE derivation_path = ?1") + .bind(derivation_path) + .fetch_optional(&self.pool) + .await?; + match row { + Some(r) => Ok(Some(row_to_build(&r)?)), + None => Ok(None), + } + } + + async fn update_build_status( + &self, + id: Uuid, + status: BuildStatus, + agent_session_id: Option, + ) -> Result<()> { + let now = now_str(); + // Set started_at when the build transitions to Building. + let started = if status == BuildStatus::Building { + Some(now.clone()) + } else { + None + }; + // Set completed_at when the build reaches any terminal state. + let completed = if status == BuildStatus::Succeeded + || status == BuildStatus::Failed + || status == BuildStatus::Cancelled + { + Some(now.clone()) + } else { + None + }; + + // COALESCE preserves existing timestamps -- we never overwrite + // started_at once it has been set, even if update_build_status + // is called multiple times. + let result = sqlx::query( + "UPDATE builds SET status = ?1, agent_session_id = ?2, \ + started_at = COALESCE(?3, started_at), \ + completed_at = COALESCE(?4, completed_at) \ + WHERE id = ?5", + ) + .bind(status.to_string()) + .bind(agent_session_id.map(|u| u.to_string())) + .bind(&started) + .bind(&completed) + .bind(id.to_string()) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::NotFound { + entity: "build".into(), + id: id.to_string(), + }); + } + Ok(()) + } + + async fn link_build_to_job(&self, build_id: Uuid, job_id: Uuid) -> Result<()> { + // INSERT OR IGNORE: the (build_id, job_id) composite primary key + // ensures we do not create duplicate links. + sqlx::query("INSERT OR IGNORE INTO build_jobs (build_id, job_id) VALUES (?1, ?2)") + .bind(build_id.to_string()) + .bind(job_id.to_string()) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn are_all_builds_complete(&self, job_id: Uuid) -> Result { + // Count builds linked to this job that are NOT in a terminal + // state. If the count is zero, all builds are done. + let row = sqlx::query( + "SELECT COUNT(*) as cnt FROM build_jobs bj \ + JOIN builds b ON bj.build_id = b.id \ + WHERE bj.job_id = ?1 AND b.status NOT IN ('succeeded', 'failed', 'cancelled')", + ) + .bind(job_id.to_string()) + .fetch_one(&self.pool) + .await?; + let incomplete: i64 = row.get("cnt"); + Ok(incomplete == 0) + } + + // ── Effects ────────────────────────────────────────────────────── + + async fn create_effect( + &self, + job_id: Uuid, + attribute_path: &[String], + derivation_path: &str, + ) -> Result { + let id = Uuid::new_v4(); + let now = now_str(); + let attr_path_json = serde_json::to_string(attribute_path)?; + + sqlx::query( + "INSERT INTO effects (id, job_id, attribute_path, derivation_path, status, created_at) \ + VALUES (?1, ?2, ?3, ?4, 'pending', ?5)", + ) + .bind(id.to_string()) + .bind(job_id.to_string()) + .bind(&attr_path_json) + .bind(derivation_path) + .bind(&now) + .execute(&self.pool) + .await?; + + Ok(id) + } + + async fn get_effect(&self, id: Uuid) -> Result { + let row = sqlx::query("SELECT * FROM effects WHERE id = ?1") + .bind(id.to_string()) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::NotFound { + entity: "effect".into(), + id: id.to_string(), + })?; + row_to_effect(&row) + } + + async fn get_effect_by_job_and_attr(&self, job_id: Uuid, attribute_path: &str) -> Result { + let row = sqlx::query( + "SELECT * FROM effects WHERE job_id = ?1 AND attribute_path = ?2 LIMIT 1", + ) + .bind(job_id.to_string()) + .bind(attribute_path) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::NotFound { + entity: "effect".into(), + id: format!("job={} attr={}", job_id, attribute_path), + })?; + row_to_effect(&row) + } + + async fn get_effects_for_job(&self, job_id: Uuid) -> Result> { + let rows = sqlx::query("SELECT * FROM effects WHERE job_id = ?1 ORDER BY created_at") + .bind(job_id.to_string()) + .fetch_all(&self.pool) + .await?; + rows.iter().map(row_to_effect).collect() + } + + async fn update_effect_status(&self, id: Uuid, status: EffectStatus) -> Result<()> { + let now = now_str(); + // Same COALESCE pattern as update_build_status: set started_at + // on Running, completed_at on terminal states, and never + // overwrite a previously-set timestamp. + let started = if status == EffectStatus::Running { + Some(now.clone()) + } else { + None + }; + let completed = if status == EffectStatus::Succeeded + || status == EffectStatus::Failed + || status == EffectStatus::Cancelled + { + Some(now.clone()) + } else { + None + }; + + let result = sqlx::query( + "UPDATE effects SET status = ?1, \ + started_at = COALESCE(?2, started_at), \ + completed_at = COALESCE(?3, completed_at) \ + WHERE id = ?4", + ) + .bind(status.to_string()) + .bind(&started) + .bind(&completed) + .bind(id.to_string()) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::NotFound { + entity: "effect".into(), + id: id.to_string(), + }); + } + Ok(()) + } + + async fn are_all_effects_complete(&self, job_id: Uuid) -> Result { + let row = sqlx::query( + "SELECT COUNT(*) as cnt FROM effects \ + WHERE job_id = ?1 AND status NOT IN ('succeeded', 'failed', 'cancelled')", + ) + .bind(job_id.to_string()) + .fetch_one(&self.pool) + .await?; + let incomplete: i64 = row.get("cnt"); + Ok(incomplete == 0) + } + + async fn are_preceding_effects_done( + &self, + project_id: Uuid, + ref_name: &str, + sequence_number: i64, + ) -> Result { + // Join effects to their jobs to find effects from earlier + // sequence numbers on the same (project, ref). If any are + // still in-progress, the caller must wait before starting + // the current job's effects. + let row = sqlx::query( + "SELECT COUNT(*) as cnt FROM effects e \ + JOIN jobs j ON e.job_id = j.id \ + WHERE j.project_id = ?1 AND j.ref_name = ?2 AND j.sequence_number < ?3 \ + AND e.status NOT IN ('succeeded', 'failed', 'cancelled')", + ) + .bind(project_id.to_string()) + .bind(ref_name) + .bind(sequence_number) + .fetch_one(&self.pool) + .await?; + let incomplete: i64 = row.get("cnt"); + Ok(incomplete == 0) + } + + // ── State Files ────────────────────────────────────────────────── + + async fn put_state_file( + &self, + project_id: Uuid, + name: &str, + data: &[u8], + ) -> Result<()> { + let now = now_str(); + let size = data.len() as i64; + + // Upsert: first write creates at version 1; subsequent writes + // atomically bump the version counter. The ON CONFLICT clause + // targets the composite primary key (project_id, name). + sqlx::query( + "INSERT INTO state_files (project_id, name, data, version, size_bytes, updated_at) \ + VALUES (?1, ?2, ?3, 1, ?4, ?5) \ + ON CONFLICT(project_id, name) DO UPDATE SET \ + data = excluded.data, \ + version = state_files.version + 1, \ + size_bytes = excluded.size_bytes, \ + updated_at = excluded.updated_at", + ) + .bind(project_id.to_string()) + .bind(name) + .bind(data) + .bind(size) + .bind(&now) + .execute(&self.pool) + .await?; + + Ok(()) + } + + async fn get_state_file( + &self, + project_id: Uuid, + name: &str, + ) -> Result>> { + let row = sqlx::query( + "SELECT data FROM state_files WHERE project_id = ?1 AND name = ?2", + ) + .bind(project_id.to_string()) + .bind(name) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|r| r.get::, _>("data"))) + } + + async fn list_state_files(&self, project_id: Uuid) -> Result> { + // Deliberately excludes the `data` BLOB column to avoid loading + // potentially large payloads into memory for a listing operation. + let rows = sqlx::query( + "SELECT project_id, name, version, size_bytes, updated_at \ + FROM state_files WHERE project_id = ?1 ORDER BY name", + ) + .bind(project_id.to_string()) + .fetch_all(&self.pool) + .await?; + rows.iter().map(row_to_state_file).collect() + } + + // ── State Locks ────────────────────────────────────────────────── + + async fn acquire_lock( + &self, + project_id: Uuid, + name: &str, + owner: &str, + ttl_seconds: u64, + ) -> Result { + let now = now_str(); + + // Lazily clean up any expired lock for this (project, name) so + // the INSERT below can succeed even if a previous holder crashed + // without releasing. + sqlx::query( + "DELETE FROM state_locks WHERE project_id = ?1 AND name = ?2 AND expires_at < ?3", + ) + .bind(project_id.to_string()) + .bind(name) + .bind(&now) + .execute(&self.pool) + .await?; + + let id = Uuid::new_v4(); + let expires_at = (Utc::now() + chrono::Duration::seconds(ttl_seconds as i64)) + .format("%Y-%m-%d %H:%M:%S") + .to_string(); + + // INSERT OR IGNORE: if a non-expired lock still exists (UNIQUE + // constraint on (project_id, name)), the insert is silently + // skipped and rows_affected will be 0. + let result = sqlx::query( + "INSERT OR IGNORE INTO state_locks (id, project_id, name, owner, expires_at, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ) + .bind(id.to_string()) + .bind(project_id.to_string()) + .bind(name) + .bind(owner) + .bind(&expires_at) + .bind(&now) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + // Another owner holds a non-expired lock. + return Err(DbError::Conflict(format!( + "lock already held for project {project_id}, name {name}" + ))); + } + + Ok(StateLock { + id: Id::from_uuid(id), + project_id: Id::from_uuid(project_id), + name: name.to_string(), + owner: owner.to_string(), + expires_at: parse_datetime(&expires_at)?, + created_at: parse_datetime(&now)?, + }) + } + + async fn renew_lock(&self, lock_id: Uuid, ttl_seconds: u64) -> Result { + let expires_at = (Utc::now() + chrono::Duration::seconds(ttl_seconds as i64)) + .format("%Y-%m-%d %H:%M:%S") + .to_string(); + + let result = sqlx::query("UPDATE state_locks SET expires_at = ?1 WHERE id = ?2") + .bind(&expires_at) + .bind(lock_id.to_string()) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(DbError::NotFound { + entity: "state_lock".into(), + id: lock_id.to_string(), + }); + } + + // Re-fetch the full row to return the updated lock. + let row = sqlx::query("SELECT * FROM state_locks WHERE id = ?1") + .bind(lock_id.to_string()) + .fetch_one(&self.pool) + .await?; + row_to_state_lock(&row) + } + + async fn release_lock(&self, lock_id: Uuid) -> Result<()> { + let result = sqlx::query("DELETE FROM state_locks WHERE id = ?1") + .bind(lock_id.to_string()) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::NotFound { + entity: "state_lock".into(), + id: lock_id.to_string(), + }); + } + Ok(()) + } + + async fn cleanup_expired_locks(&self) -> Result { + let now = now_str(); + let result = sqlx::query("DELETE FROM state_locks WHERE expires_at < ?1") + .bind(&now) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + + // ── Secrets ────────────────────────────────────────────────────── + + async fn create_secret( + &self, + project_id: Uuid, + name: &str, + data: &serde_json::Value, + condition: &SecretCondition, + ) -> Result { + let id = Uuid::new_v4(); + let now = now_str(); + let data_str = serde_json::to_string(data)?; + let condition_str = serde_json::to_string(condition)?; + + sqlx::query( + "INSERT INTO secrets (id, project_id, name, data, condition, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ) + .bind(id.to_string()) + .bind(project_id.to_string()) + .bind(name) + .bind(&data_str) + .bind(&condition_str) + .bind(&now) + .execute(&self.pool) + .await?; + + Ok(id) + } + + async fn get_secrets_for_project(&self, project_id: Uuid) -> Result> { + let rows = sqlx::query("SELECT * FROM secrets WHERE project_id = ?1 ORDER BY name") + .bind(project_id.to_string()) + .fetch_all(&self.pool) + .await?; + rows.iter().map(row_to_secret).collect() + } + + async fn delete_secret(&self, id: Uuid) -> Result<()> { + let result = sqlx::query("DELETE FROM secrets WHERE id = ?1") + .bind(id.to_string()) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(DbError::NotFound { + entity: "secret".into(), + id: id.to_string(), + }); + } + Ok(()) + } + + // ── Log Entries ────────────────────────────────────────────────── + + async fn store_log_entries( + &self, + task_id: Uuid, + entries: &[LogEntry], + ) -> Result<()> { + if entries.is_empty() { + return Ok(()); + } + + // Wrap the batch in a transaction so either all lines are + // persisted or none are (atomicity for streaming log uploads). + let mut tx = self.pool.begin().await?; + let now = now_str(); + let task_id_str = task_id.to_string(); + + for entry in entries { + sqlx::query( + "INSERT INTO log_entries (task_id, line_index, timestamp_ms, message, level, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ) + .bind(&task_id_str) + .bind(entry.i as i64) + .bind(entry.ms) + .bind(&entry.msg) + .bind(entry.level.to_string()) + .bind(&now) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + Ok(()) + } + + async fn get_log_entries( + &self, + task_id: Uuid, + offset: u64, + limit: u64, + ) -> Result> { + let rows = sqlx::query( + "SELECT line_index, timestamp_ms, message, level FROM log_entries \ + WHERE task_id = ?1 ORDER BY line_index ASC LIMIT ?2 OFFSET ?3", + ) + .bind(task_id.to_string()) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(&self.pool) + .await?; + + rows.iter() + .map(|row| { + let line_index: i64 = row.get("line_index"); + let level_str: String = row.get("level"); + Ok(LogEntry { + i: line_index as u64, + ms: row.get::("timestamp_ms"), + msg: row.get("message"), + level: parse_enum(&level_str)?, + }) + }) + .collect() + } +} diff --git a/crates/jupiter-forge/Cargo.toml b/crates/jupiter-forge/Cargo.toml new file mode 100644 index 0000000..de2ac68 --- /dev/null +++ b/crates/jupiter-forge/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "jupiter-forge" +version.workspace = true +edition.workspace = true + +[dependencies] +jupiter-api-types = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +tracing = { workspace = true } +hmac = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } diff --git a/crates/jupiter-forge/src/error.rs b/crates/jupiter-forge/src/error.rs new file mode 100644 index 0000000..99b55d1 --- /dev/null +++ b/crates/jupiter-forge/src/error.rs @@ -0,0 +1,65 @@ +//! Error types for the forge integration layer. +//! +//! [`ForgeError`] is the single error enum shared by all forge providers +//! (GitHub, Gitea, Radicle). It covers the full range of failure modes that +//! can occur during webhook verification, payload parsing, and outbound API +//! calls. +//! +//! The enum uses [`thiserror`] for ergonomic `Display` / `Error` derivation +//! and provides automatic `From` conversions for the two most common +//! underlying error types: [`reqwest::Error`] (HTTP client failures) and +//! [`serde_json::Error`] (JSON deserialization failures). + +use thiserror::Error; + +/// Unified error type for all forge operations. +/// +/// Each variant maps to a distinct failure category so that callers in the +/// Jupiter server can decide how to respond (e.g. return HTTP 401 for +/// `InvalidSignature`, HTTP 400 for `ParseError`, HTTP 502 for `ApiError`). +#[derive(Debug, Error)] +pub enum ForgeError { + /// The webhook signature was structurally valid but did not match the + /// expected HMAC. The server should reject the request with HTTP 401. + /// + /// Note: this variant is distinct from `verify_webhook` returning + /// `Ok(false)`. `Ok(false)` means the signature was absent or wrong; + /// `InvalidSignature` signals a structural problem detected during + /// verification (e.g. the HMAC could not be initialized). + #[error("invalid webhook signature")] + InvalidSignature, + + /// The webhook carried an event type that this provider does not handle + /// and considers an error (as opposed to returning `Ok(None)` for + /// silently ignored events). + #[error("unsupported event type: {0}")] + UnsupportedEvent(String), + + /// The webhook payload could not be deserialized or was missing required + /// fields. Also used for malformed signature headers (e.g. a GitHub + /// signature without the `sha256=` prefix). + #[error("parse error: {0}")] + ParseError(String), + + /// An outbound API call to the forge succeeded at the HTTP level but + /// returned a non-success status code (4xx / 5xx). The string contains + /// the status code and response body for diagnostics. + #[error("API error: {0}")] + ApiError(String), + + /// The underlying HTTP client (reqwest) encountered a transport-level + /// error (DNS failure, timeout, TLS error, etc.). + #[error("HTTP error: {0}")] + HttpError(#[from] reqwest::Error), + + /// JSON serialization or deserialization failed. Automatically converted + /// from `serde_json::Error`. + #[error("JSON error: {0}")] + JsonError(#[from] serde_json::Error), + + /// The provider was asked to perform an operation that requires + /// configuration it does not have. For example, calling + /// `poll_changes` on a Radicle provider that is in webhook mode. + #[error("not configured: {0}")] + NotConfigured(String), +} diff --git a/crates/jupiter-forge/src/gitea.rs b/crates/jupiter-forge/src/gitea.rs new file mode 100644 index 0000000..b1d3cbc --- /dev/null +++ b/crates/jupiter-forge/src/gitea.rs @@ -0,0 +1,441 @@ +//! Gitea / Forgejo forge provider for Jupiter CI. +//! +//! This module implements the [`ForgeProvider`] trait for Gitea (and its +//! community fork Forgejo), handling: +//! +//! - **Webhook verification** using HMAC-SHA256 with the `X-Gitea-Signature` +//! header. Unlike GitHub, Gitea sends the raw hex digest **without** a +//! `sha256=` prefix. The constant-time comparison logic is otherwise +//! identical to the GitHub provider. +//! +//! - **Webhook parsing** for `push` and `pull_request` events (via the +//! `X-Gitea-Event` header). Unrecognized event types are silently ignored. +//! +//! - **Commit status reporting** via `POST /api/v1/repos/{owner}/{repo}/statuses/{sha}`. +//! +//! - **Repository listing** via `GET /api/v1/repos/search`. +//! +//! ## Authentication Model +//! +//! Gitea uses **personal access tokens** (or OAuth2 tokens) for API +//! authentication. These are passed in the `Authorization: token ` +//! header -- note that Gitea uses the literal word `token` rather than +//! `Bearer` as the scheme. +//! +//! ## Differences from GitHub +//! +//! | Aspect | GitHub | Gitea | +//! |-----------------------|---------------------------------|----------------------------------| +//! | Signature header | `X-Hub-Signature-256` | `X-Gitea-Signature` | +//! | Signature format | `sha256=` | `` (no prefix) | +//! | Auth header | `Authorization: Bearer ` | `Authorization: token ` | +//! | API path prefix | `/repos/...` | `/api/v1/repos/...` | +//! | PR sync action name | `"synchronize"` | `"synchronized"` (extra "d") | +//! | User field name | Always `login` | `login` or `username` (varies) | +//! | PR ref fields | Always present | Optional (may be `null`) | +//! +//! The `"synchronize"` vs `"synchronized"` difference is a notable pitfall: +//! GitHub uses `"synchronize"` (no trailing "d") while Gitea uses +//! `"synchronized"` (with trailing "d"). Both mean "new commits were pushed +//! to the PR branch." + +use async_trait::async_trait; +use hmac::{Hmac, Mac}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use tracing::{debug, warn}; + +use crate::error::ForgeError; +use crate::{ForgeProvider, RawForgeEvent}; +use jupiter_api_types::{CommitStatus, CommitStatusUpdate, ForgeType, PullRequestAction}; + +/// Type alias for HMAC-SHA256, used for webhook signature verification. +type HmacSha256 = Hmac; + +// --------------------------------------------------------------------------- +// Internal serde types for Gitea webhook payloads +// --------------------------------------------------------------------------- +// +// Gitea's webhook payloads are similar to GitHub's but differ in several +// structural details (see module-level docs). These structs capture only the +// fields Jupiter needs. + +/// Payload for Gitea `push` events. +#[derive(Debug, Deserialize)] +struct GiteaPushPayload { + #[serde(rename = "ref")] + git_ref: String, + before: String, + after: String, + repository: GiteaRepo, + sender: GiteaUser, +} + +/// Repository object embedded in Gitea webhook payloads. +#[derive(Debug, Deserialize)] +struct GiteaRepo { + owner: GiteaUser, + name: String, + #[allow(dead_code)] + clone_url: Option, +} + +/// User object in Gitea payloads. +/// +/// Gitea is inconsistent about which field it populates: some payloads use +/// `login`, others use `username`. The [`name()`](GiteaUser::name) helper +/// tries both, falling back to `"unknown"`. +#[derive(Debug, Deserialize)] +struct GiteaUser { + login: Option, + username: Option, +} + +impl GiteaUser { + /// Extract a usable display name, preferring `login` over `username`. + /// + /// Gitea populates different fields depending on the API version and + /// context, so we try both. + fn name(&self) -> String { + self.login + .as_deref() + .or(self.username.as_deref()) + .unwrap_or("unknown") + .to_string() + } +} + +/// Payload for Gitea `pull_request` events. +#[derive(Debug, Deserialize)] +struct GiteaPRPayload { + action: String, + number: u64, + pull_request: GiteaPR, + repository: GiteaRepo, +} + +/// The `pull_request` object inside a Gitea PR event. +#[derive(Debug, Deserialize)] +struct GiteaPR { + head: GiteaPRRef, + base: GiteaPRRef, +} + +/// A ref endpoint (head or base) of a Gitea pull request. +/// +/// Unlike GitHub where `sha` and `ref` are always present, Gitea may return +/// `null` for these fields in some edge cases (e.g. deleted branches), so +/// they are `Option`. The `label` field serves as a fallback for +/// `ref_name` in the base ref. +#[derive(Debug, Deserialize)] +struct GiteaPRRef { + sha: Option, + #[serde(rename = "ref")] + ref_name: Option, + label: Option, +} + +/// Request body for `POST /api/v1/repos/{owner}/{repo}/statuses/{sha}`. +/// +/// The field names and semantics mirror GitHub's status API, which Gitea +/// intentionally replicates for compatibility. +#[derive(Debug, Serialize)] +struct GiteaStatusRequest { + state: String, + context: String, + description: String, + #[serde(skip_serializing_if = "Option::is_none")] + target_url: Option, +} + +/// Minimal repo item from the Gitea search API response. +#[derive(Debug, Deserialize)] +struct GiteaRepoListItem { + owner: GiteaUser, + name: String, + clone_url: String, +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +/// Gitea / Forgejo forge provider. +/// +/// Implements [`ForgeProvider`] for self-hosted Gitea and Forgejo instances. +/// +/// # Fields +/// +/// - `base_url` -- The instance URL (e.g. `https://gitea.example.com`), used +/// as the prefix for all API calls (`/api/v1/...`). +/// - `api_token` -- A personal access token or OAuth2 token, sent via +/// `Authorization: token `. +/// - `webhook_secret` -- Shared HMAC-SHA256 secret for verifying incoming +/// webhooks. +/// - `client` -- A reusable `reqwest::Client` for connection pooling. +pub struct GiteaProvider { + /// Instance base URL (no trailing slash). + base_url: String, + /// Personal access token for API authentication. + api_token: String, + /// Shared HMAC secret for webhook verification. + webhook_secret: String, + /// Reusable HTTP client. + client: Client, +} + +impl GiteaProvider { + /// Create a new Gitea provider. + /// + /// # Parameters + /// + /// * `base_url` -- The Gitea instance URL, e.g. `https://gitea.example.com`. + /// A trailing slash is stripped automatically. + /// * `api_token` -- Personal access token or OAuth2 token for API + /// authentication. + /// * `webhook_secret` -- Shared secret string configured in the Gitea + /// webhook settings, used for HMAC-SHA256 verification. + pub fn new(base_url: String, api_token: String, webhook_secret: String) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + api_token, + webhook_secret, + client: Client::new(), + } + } + + /// Map Jupiter's [`CommitStatus`] enum to the string values expected by + /// the Gitea commit status API. + fn gitea_status_string(status: CommitStatus) -> &'static str { + match status { + CommitStatus::Pending => "pending", + CommitStatus::Success => "success", + CommitStatus::Failure => "failure", + CommitStatus::Error => "error", + } + } + + /// Convert a Gitea PR action string to the internal [`PullRequestAction`]. + /// + /// Note the spelling difference: Gitea uses `"synchronized"` (with a + /// trailing "d") while GitHub uses `"synchronize"` (without). Both map + /// to [`PullRequestAction::Synchronize`] internally. + fn parse_pr_action(action: &str) -> Option { + match action { + "opened" => Some(PullRequestAction::Opened), + "synchronized" => Some(PullRequestAction::Synchronize), + "reopened" => Some(PullRequestAction::Reopened), + "closed" => Some(PullRequestAction::Closed), + _ => None, + } + } +} + +#[async_trait] +impl ForgeProvider for GiteaProvider { + fn forge_type(&self) -> ForgeType { + ForgeType::Gitea + } + + /// Verify a Gitea webhook using HMAC-SHA256. + /// + /// Gitea sends the `X-Gitea-Signature` header containing the **raw hex + /// HMAC-SHA256 digest** (no `sha256=` prefix, unlike GitHub). This is + /// the key protocol difference in signature format between the two forges. + /// + /// The constant-time comparison logic is identical to the GitHub provider: + /// byte-wise XOR with OR accumulation to prevent timing attacks. + fn verify_webhook( + &self, + signature_header: Option<&str>, + body: &[u8], + ) -> Result { + let hex_sig = match signature_header { + Some(h) => h, + None => return Ok(false), + }; + + // Gitea sends the raw hex HMAC-SHA256 (no "sha256=" prefix). + let mut mac = HmacSha256::new_from_slice(self.webhook_secret.as_bytes()) + .map_err(|e| ForgeError::ParseError(format!("HMAC init error: {e}")))?; + mac.update(body); + let result = hex::encode(mac.finalize().into_bytes()); + + if result.len() != hex_sig.len() { + return Ok(false); + } + let equal = result + .as_bytes() + .iter() + .zip(hex_sig.as_bytes()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)); + Ok(equal == 0) + } + + /// Parse a Gitea webhook payload into a [`RawForgeEvent`]. + /// + /// Recognized `X-Gitea-Event` values: + /// + /// - `"push"` -- produces [`RawForgeEvent::Push`]. + /// - `"pull_request"` -- produces [`RawForgeEvent::PullRequest`] for + /// `opened`, `synchronized`, `reopened`, and `closed` actions. + /// + /// Gitea PR payloads have optional `sha` and `ref` fields (they can be + /// `null` for deleted branches), so this method handles `None` values + /// gracefully by defaulting to empty strings. The `base_ref` falls back + /// to the `label` field if `ref_name` is absent. + fn parse_webhook( + &self, + event_type: &str, + body: &[u8], + ) -> Result, ForgeError> { + match event_type { + "push" => { + let payload: GiteaPushPayload = serde_json::from_slice(body)?; + debug!( + repo = %payload.repository.name, + git_ref = %payload.git_ref, + "parsed Gitea push event" + ); + Ok(Some(RawForgeEvent::Push { + repo_owner: payload.repository.owner.name(), + repo_name: payload.repository.name, + git_ref: payload.git_ref, + before: payload.before, + after: payload.after, + sender: payload.sender.name(), + })) + } + "pull_request" => { + let payload: GiteaPRPayload = serde_json::from_slice(body)?; + let action = match Self::parse_pr_action(&payload.action) { + Some(a) => a, + None => { + debug!(action = %payload.action, "ignoring Gitea PR action"); + return Ok(None); + } + }; + let head_sha = payload + .pull_request + .head + .sha + .unwrap_or_default(); + let base_ref = payload + .pull_request + .base + .ref_name + .or(payload.pull_request.base.label) + .unwrap_or_default(); + + Ok(Some(RawForgeEvent::PullRequest { + repo_owner: payload.repository.owner.name(), + repo_name: payload.repository.name, + action, + pr_number: payload.number, + head_sha, + base_ref, + })) + } + other => { + debug!(event = %other, "ignoring unhandled Gitea event type"); + Ok(None) + } + } + } + + /// Report a commit status to Gitea via `POST /api/v1/repos/{owner}/{repo}/statuses/{sha}`. + /// + /// Gitea's status API is modeled after GitHub's, so the request body + /// is structurally identical. The key difference is the authentication + /// header: Gitea uses `Authorization: token ` rather than + /// `Authorization: Bearer `. + async fn set_commit_status( + &self, + repo_owner: &str, + repo_name: &str, + commit_sha: &str, + status: &CommitStatusUpdate, + ) -> Result<(), ForgeError> { + let url = format!( + "{}/api/v1/repos/{}/{}/statuses/{}", + self.base_url, repo_owner, repo_name, commit_sha, + ); + + let body = GiteaStatusRequest { + state: Self::gitea_status_string(status.status).to_string(), + context: status.context.clone(), + description: status.description.clone().unwrap_or_default(), + target_url: status.target_url.clone(), + }; + + let resp = self + .client + .post(&url) + .header("Authorization", format!("token {}", self.api_token)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status_code = resp.status(); + let text = resp.text().await.unwrap_or_default(); + warn!(%status_code, body = %text, "Gitea status API error"); + return Err(ForgeError::ApiError(format!( + "Gitea API returned {status_code}: {text}" + ))); + } + + Ok(()) + } + + /// Return the clone URL for a Gitea repository. + /// + /// Unlike GitHub (which embeds the access token in the URL), Gitea clone + /// URLs are plain HTTPS. Authentication for git operations is expected + /// to be handled out-of-band by the agent (e.g. via `.netrc`, + /// `credential.helper`, or an `http.extraheader` git config entry). + async fn clone_url( + &self, + repo_owner: &str, + repo_name: &str, + ) -> Result { + Ok(format!( + "{}/{}/{}.git", + self.base_url, repo_owner, repo_name, + )) + } + + /// List repositories accessible to the authenticated Gitea user. + /// + /// Uses `GET /api/v1/repos/search?limit=50` to fetch repositories. + /// The `limit=50` parameter increases the page size from the default + /// (typically 20). + /// + /// Note: This does not yet handle pagination; users with more than 50 + /// accessible repositories will only see the first page. + async fn list_repos(&self) -> Result, ForgeError> { + let url = format!("{}/api/v1/repos/search?limit=50", self.base_url); + let resp = self + .client + .get(&url) + .header("Authorization", format!("token {}", self.api_token)) + .send() + .await?; + + if !resp.status().is_success() { + let status_code = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(ForgeError::ApiError(format!( + "Gitea API returned {status_code}: {text}" + ))); + } + + let repos: Vec = resp.json().await?; + Ok(repos + .into_iter() + .map(|r| (r.owner.name(), r.name, r.clone_url)) + .collect()) + } +} diff --git a/crates/jupiter-forge/src/github.rs b/crates/jupiter-forge/src/github.rs new file mode 100644 index 0000000..e9efc1a --- /dev/null +++ b/crates/jupiter-forge/src/github.rs @@ -0,0 +1,496 @@ +//! GitHub / GitHub Enterprise forge provider for Jupiter CI. +//! +//! This module implements the [`ForgeProvider`] trait for GitHub, handling: +//! +//! - **Webhook verification** using HMAC-SHA256 with the `X-Hub-Signature-256` +//! header. GitHub sends signatures in `sha256=` format; the provider +//! strips the prefix, computes the expected HMAC, and performs constant-time +//! comparison to prevent timing attacks. +//! +//! - **Webhook parsing** for `push` and `pull_request` events (via the +//! `X-GitHub-Event` header). Other event types (e.g. `star`, `fork`, +//! `issue_comment`) are silently ignored by returning `Ok(None)`. +//! +//! - **Commit status reporting** via `POST /repos/{owner}/{repo}/statuses/{sha}`. +//! This causes GitHub to show Jupiter CI results as status checks on PRs and +//! commits. +//! +//! - **Authenticated clone URLs** using the `x-access-token` scheme that GitHub +//! App installation tokens require. +//! +//! - **Repository listing** via the GitHub App installation API +//! (`GET /installation/repositories`). +//! +//! ## Authentication Model +//! +//! GitHub Apps authenticate in two stages: +//! +//! 1. The App signs a JWT with its RSA private key to identify itself. +//! 2. The JWT is exchanged for a short-lived **installation token** scoped to +//! the repositories the App has been installed on. +//! +//! Currently, the provider accepts a pre-minted installation token directly +//! (the `api_token` field). The `app_id` and `private_key_pem` fields are +//! stored for future automatic token rotation. +//! +//! ## GitHub Enterprise Support +//! +//! The [`GitHubProvider::with_api_base`] builder method allows pointing the +//! provider at a GitHub Enterprise instance by overriding the default +//! `https://api.github.com` base URL. + +use async_trait::async_trait; +use hmac::{Hmac, Mac}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use tracing::{debug, warn}; + +use crate::error::ForgeError; +use crate::{ForgeProvider, RawForgeEvent}; +use jupiter_api_types::{CommitStatus, CommitStatusUpdate, ForgeType, PullRequestAction}; + +/// Type alias for HMAC-SHA256, used for webhook signature verification. +type HmacSha256 = Hmac; + +// --------------------------------------------------------------------------- +// Internal serde types for GitHub webhook payloads +// --------------------------------------------------------------------------- +// +// These structs mirror the relevant subset of GitHub's webhook JSON schemas. +// Only the fields that Jupiter needs are deserialized; everything else is +// silently ignored by serde. + +/// Payload for `push` events. +#[derive(Debug, Deserialize)] +struct GitHubPushPayload { + /// Full git ref, e.g. `refs/heads/main` or `refs/tags/v1.0`. + #[serde(rename = "ref")] + git_ref: String, + /// SHA before the push (all-zeros for newly created refs). + before: String, + /// SHA after the push. + after: String, + repository: GitHubRepo, + sender: GitHubUser, +} + +/// Repository object embedded in webhook payloads. +#[derive(Debug, Deserialize)] +struct GitHubRepo { + owner: GitHubRepoOwner, + name: String, + #[allow(dead_code)] + clone_url: Option, +} + +/// The `owner` sub-object inside a repository payload. +#[derive(Debug, Deserialize)] +struct GitHubRepoOwner { + login: String, +} + +/// User object (sender) embedded in webhook payloads. +#[derive(Debug, Deserialize)] +struct GitHubUser { + login: String, +} + +/// Payload for `pull_request` events. +#[derive(Debug, Deserialize)] +struct GitHubPRPayload { + /// The action that triggered this event (e.g. "opened", "synchronize"). + action: String, + /// Pull request number. + number: u64, + pull_request: GitHubPR, + repository: GitHubRepo, +} + +/// The `pull_request` object inside the PR event payload. +#[derive(Debug, Deserialize)] +struct GitHubPR { + head: GitHubPRRef, + base: GitHubPRRef, +} + +/// A ref endpoint (head or base) of a pull request. +#[derive(Debug, Deserialize)] +struct GitHubPRRef { + /// The commit SHA at this ref. + sha: String, + /// Branch name. + #[serde(rename = "ref")] + ref_name: String, +} + +/// Request body for `POST /repos/{owner}/{repo}/statuses/{sha}`. +/// +/// Maps to the GitHub REST API "Create a commit status" endpoint. +/// The `target_url` field is optional and links back to the Jupiter +/// build page for the evaluation. +#[derive(Debug, Serialize)] +struct GitHubStatusRequest { + /// One of: `"pending"`, `"success"`, `"failure"`, `"error"`. + state: String, + /// A label that identifies this status (e.g. `"jupiter-ci/eval"`). + context: String, + /// Human-readable description of the status. + description: String, + /// Optional URL linking to the Jupiter build details page. + #[serde(skip_serializing_if = "Option::is_none")] + target_url: Option, +} + +/// Minimal repo item returned by `GET /installation/repositories`. +#[derive(Debug, Deserialize)] +struct GitHubRepoListItem { + owner: GitHubRepoOwner, + name: String, + clone_url: String, +} + +/// Wrapper for the paginated response from `GET /installation/repositories`. +#[derive(Debug, Deserialize)] +struct GitHubInstallationReposResponse { + repositories: Vec, +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +/// GitHub App forge provider. +/// +/// Implements [`ForgeProvider`] for GitHub.com and GitHub Enterprise. +/// +/// # Fields +/// +/// - `api_base` -- Base URL for API requests (default: `https://api.github.com`). +/// Overridden via [`with_api_base`](GitHubProvider::with_api_base) for +/// GitHub Enterprise. +/// - `app_id` / `private_key_pem` -- GitHub App credentials, reserved for +/// future automatic JWT-based token rotation. +/// - `webhook_secret` -- The shared secret configured in the GitHub webhook +/// settings, used to compute the expected HMAC-SHA256 digest. +/// - `api_token` -- An installation access token (or personal access token) +/// used as a Bearer token for all outbound API requests. +/// - `client` -- A reusable `reqwest::Client` for connection pooling. +pub struct GitHubProvider { + /// Base URL for GitHub API requests (no trailing slash). + api_base: String, + /// GitHub App ID (reserved for future JWT-based token minting). + #[allow(dead_code)] + app_id: u64, + /// PEM-encoded RSA private key for the GitHub App (reserved for future + /// JWT-based token minting). + #[allow(dead_code)] + private_key_pem: String, + /// Shared HMAC secret for webhook signature verification. + webhook_secret: String, + /// Bearer token used for all outbound GitHub API calls. + api_token: String, + /// Reusable HTTP client with connection pooling. + client: Client, +} + +impl GitHubProvider { + /// Create a new GitHub provider targeting `https://api.github.com`. + /// + /// # Parameters + /// + /// * `app_id` -- GitHub App ID (numeric). Currently stored for future + /// JWT-based token rotation; not used in API calls yet. + /// * `private_key_pem` -- PEM-encoded RSA private key for the GitHub App. + /// Stored for future JWT minting; not used directly yet. + /// * `webhook_secret` -- The shared secret string configured in the + /// GitHub webhook settings. Used to compute and verify HMAC-SHA256 + /// signatures on incoming webhook payloads. + /// * `api_token` -- A valid GitHub installation access token (or personal + /// access token) used as a `Bearer` token for outbound API calls + /// (status updates, repo listing, etc.). + pub fn new( + app_id: u64, + private_key_pem: String, + webhook_secret: String, + api_token: String, + ) -> Self { + Self { + api_base: "https://api.github.com".to_string(), + app_id, + private_key_pem, + webhook_secret, + api_token, + client: Client::new(), + } + } + + /// Builder method: override the API base URL. + /// + /// Use this for GitHub Enterprise Server instances or integration tests + /// with a mock server. The trailing slash is stripped automatically. + /// + /// # Example + /// + /// ```ignore + /// let provider = GitHubProvider::new(app_id, key, secret, token) + /// .with_api_base("https://github.corp.example.com/api/v3".into()); + /// ``` + pub fn with_api_base(mut self, base: String) -> Self { + self.api_base = base.trim_end_matches('/').to_string(); + self + } + + // -- helpers -- + + /// Map Jupiter's [`CommitStatus`] enum to the string values that the + /// GitHub REST API expects in the `state` field of a status request. + fn github_status_string(status: CommitStatus) -> &'static str { + match status { + CommitStatus::Pending => "pending", + CommitStatus::Success => "success", + CommitStatus::Failure => "failure", + CommitStatus::Error => "error", + } + } + + /// Convert a GitHub PR action string to the internal [`PullRequestAction`] + /// enum. + /// + /// Returns `None` for actions Jupiter does not act on (e.g. `"labeled"`, + /// `"assigned"`), which causes `parse_webhook` to return `Ok(None)` and + /// silently skip the event. + fn parse_pr_action(action: &str) -> Option { + match action { + "opened" => Some(PullRequestAction::Opened), + "synchronize" => Some(PullRequestAction::Synchronize), + "reopened" => Some(PullRequestAction::Reopened), + "closed" => Some(PullRequestAction::Closed), + _ => None, + } + } +} + +#[async_trait] +impl ForgeProvider for GitHubProvider { + fn forge_type(&self) -> ForgeType { + ForgeType::GitHub + } + + /// Verify a GitHub webhook using HMAC-SHA256. + /// + /// GitHub sends the `X-Hub-Signature-256` header with format + /// `sha256=`. This method: + /// + /// 1. Returns `Ok(false)` if the header is absent (webhook has no secret + /// configured, or the request is forged). + /// 2. Strips the `sha256=` prefix -- returns `Err(ParseError)` if missing. + /// 3. Computes HMAC-SHA256 over the raw body using the shared + /// `webhook_secret`. + /// 4. Compares the computed and received hex digests using **constant-time + /// XOR accumulation** to prevent timing side-channel attacks. + /// + /// The constant-time comparison works by XOR-ing each pair of bytes and + /// OR-ing the results into an accumulator. If any byte differs, the + /// accumulator becomes non-zero. This avoids early-exit behavior that + /// would leak information about how many leading bytes match. + fn verify_webhook( + &self, + signature_header: Option<&str>, + body: &[u8], + ) -> Result { + let sig_header = match signature_header { + Some(h) => h, + None => return Ok(false), + }; + + // GitHub sends "sha256=" -- strip the prefix. + let hex_sig = sig_header + .strip_prefix("sha256=") + .ok_or_else(|| ForgeError::ParseError("missing sha256= prefix".into()))?; + + let mut mac = HmacSha256::new_from_slice(self.webhook_secret.as_bytes()) + .map_err(|e| ForgeError::ParseError(format!("HMAC init error: {e}")))?; + mac.update(body); + let result = hex::encode(mac.finalize().into_bytes()); + + // Constant-time comparison: XOR each byte pair and OR into accumulator. + // If lengths differ the signatures cannot match (and the length itself + // is not secret -- it is always 64 hex chars for SHA-256). + if result.len() != hex_sig.len() { + return Ok(false); + } + let equal = result + .as_bytes() + .iter() + .zip(hex_sig.as_bytes()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)); + Ok(equal == 0) + } + + /// Parse a GitHub webhook payload into a [`RawForgeEvent`]. + /// + /// Recognized `X-GitHub-Event` values: + /// + /// - `"push"` -- branch/tag push; produces [`RawForgeEvent::Push`]. + /// - `"pull_request"` -- PR lifecycle; produces [`RawForgeEvent::PullRequest`] + /// for `opened`, `synchronize`, `reopened`, and `closed` actions. + /// Other PR actions (e.g. `labeled`, `assigned`) return `Ok(None)`. + /// + /// All other event types are silently ignored (`Ok(None)`). + fn parse_webhook( + &self, + event_type: &str, + body: &[u8], + ) -> Result, ForgeError> { + match event_type { + "push" => { + let payload: GitHubPushPayload = serde_json::from_slice(body)?; + debug!( + repo = %payload.repository.name, + git_ref = %payload.git_ref, + "parsed GitHub push event" + ); + Ok(Some(RawForgeEvent::Push { + repo_owner: payload.repository.owner.login, + repo_name: payload.repository.name, + git_ref: payload.git_ref, + before: payload.before, + after: payload.after, + sender: payload.sender.login, + })) + } + "pull_request" => { + let payload: GitHubPRPayload = serde_json::from_slice(body)?; + let action = match Self::parse_pr_action(&payload.action) { + Some(a) => a, + None => { + debug!(action = %payload.action, "ignoring PR action"); + return Ok(None); + } + }; + Ok(Some(RawForgeEvent::PullRequest { + repo_owner: payload.repository.owner.login, + repo_name: payload.repository.name, + action, + pr_number: payload.number, + head_sha: payload.pull_request.head.sha, + base_ref: payload.pull_request.base.ref_name, + })) + } + other => { + debug!(event = %other, "ignoring unhandled GitHub event type"); + Ok(None) + } + } + } + + /// Report a commit status to GitHub via `POST /repos/{owner}/{repo}/statuses/{sha}`. + /// + /// This makes Jupiter CI results appear as status checks on pull requests + /// and commit pages in the GitHub UI. The request uses Bearer + /// authentication with the installation token and includes the + /// `application/vnd.github+json` Accept header as recommended by the + /// GitHub REST API documentation. + async fn set_commit_status( + &self, + repo_owner: &str, + repo_name: &str, + commit_sha: &str, + status: &CommitStatusUpdate, + ) -> Result<(), ForgeError> { + let url = format!( + "{}/repos/{}/{}/statuses/{}", + self.api_base, repo_owner, repo_name, commit_sha, + ); + + let body = GitHubStatusRequest { + state: Self::github_status_string(status.status).to_string(), + context: status.context.clone(), + description: status.description.clone().unwrap_or_default(), + target_url: status.target_url.clone(), + }; + + let resp = self + .client + .post(&url) + .bearer_auth(&self.api_token) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "jupiter-ci") + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status_code = resp.status(); + let text = resp.text().await.unwrap_or_default(); + warn!(%status_code, body = %text, "GitHub status API error"); + return Err(ForgeError::ApiError(format!( + "GitHub API returned {status_code}: {text}" + ))); + } + + Ok(()) + } + + /// Return an authenticated HTTPS clone URL for a GitHub repository. + /// + /// GitHub App installation tokens authenticate via a special username: + /// `x-access-token`. The resulting URL has the form: + /// + /// ```text + /// https://x-access-token:@github.com//.git + /// ``` + /// + /// Hercules agents use this URL directly with `git clone` -- no additional + /// credential helper configuration is needed. + /// + /// Note: For GitHub Enterprise the URL host would need to be derived from + /// `api_base`; the current implementation hardcodes `github.com`. + async fn clone_url( + &self, + repo_owner: &str, + repo_name: &str, + ) -> Result { + Ok(format!( + "https://x-access-token:{}@github.com/{}/{}.git", + self.api_token, repo_owner, repo_name, + )) + } + + /// List repositories accessible to the GitHub App installation. + /// + /// Calls `GET /installation/repositories` which returns all repos the + /// App has been granted access to. The response is mapped to + /// `(owner, name, clone_url)` tuples. + /// + /// Note: This does not yet handle pagination; installations with more + /// than 30 repositories (GitHub's default page size) will only return + /// the first page. + async fn list_repos(&self) -> Result, ForgeError> { + let url = format!("{}/installation/repositories", self.api_base); + let resp = self + .client + .get(&url) + .bearer_auth(&self.api_token) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "jupiter-ci") + .send() + .await?; + + if !resp.status().is_success() { + let status_code = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(ForgeError::ApiError(format!( + "GitHub API returned {status_code}: {text}" + ))); + } + + let body: GitHubInstallationReposResponse = resp.json().await?; + Ok(body + .repositories + .into_iter() + .map(|r| (r.owner.login, r.name, r.clone_url)) + .collect()) + } +} diff --git a/crates/jupiter-forge/src/lib.rs b/crates/jupiter-forge/src/lib.rs new file mode 100644 index 0000000..2a0423c --- /dev/null +++ b/crates/jupiter-forge/src/lib.rs @@ -0,0 +1,281 @@ +//! # jupiter-forge -- Forge Integration Layer for Jupiter CI +//! +//! This crate is the bridge between Jupiter (a self-hosted, wire-compatible +//! replacement for [hercules-ci.com](https://hercules-ci.com)) and the external +//! code forges (GitHub, Gitea, Radicle) where source code lives. +//! +//! ## Architectural Role +//! +//! In the Hercules CI model, the server must: +//! +//! 1. **Receive webhooks** when code changes (pushes, pull requests, patches). +//! 2. **Verify** the webhook is authentic (HMAC signatures or trusted transport). +//! 3. **Parse** the forge-specific JSON payload into a common internal event. +//! 4. **Report CI status** back to the forge so that PR/patch checks reflect the +//! build outcome (pending / success / failure). +//! 5. **Provide authenticated clone URLs** so that Hercules agents can fetch +//! the source code. +//! +//! This crate encapsulates steps 1-5 behind the [`ForgeProvider`] trait. The +//! Jupiter server holds a registry of providers keyed by [`ForgeType`]; when an +//! HTTP request arrives at the webhook endpoint, the server inspects headers to +//! determine the forge, looks up the matching provider, calls +//! [`ForgeProvider::verify_webhook`] and then [`ForgeProvider::parse_webhook`]. +//! +//! ## Why `RawForgeEvent` Instead of Database Types? +//! +//! Webhook payloads identify repositories with forge-native names (e.g. +//! `owner/repo` on GitHub, an RID on Radicle) rather than Jupiter database +//! UUIDs. [`RawForgeEvent`] preserves these raw identifiers so the crate +//! stays independent of any database layer. The server resolves raw events +//! into fully-typed `jupiter_api_types::ForgeEvent` objects with DB-backed +//! entity references before scheduling evaluations. +//! +//! ## Protocol Differences Between Forges +//! +//! | Aspect | GitHub | Gitea | Radicle | +//! |----------------------|---------------------------------|-------------------------------|--------------------------------| +//! | **Signature header** | `X-Hub-Signature-256` | `X-Gitea-Signature` | None (trusted local transport) | +//! | **Signature format** | `sha256=` (prefixed) | Raw hex HMAC-SHA256 | N/A | +//! | **Event header** | `X-GitHub-Event` | `X-Gitea-Event` | CI broker message type | +//! | **Auth model** | GitHub App (installation token) | Personal access token | Local node identity | +//! | **Clone URL** | `https://x-access-token:@github.com/...` | `https:////.git` | `rad://` | +//! | **Repo identifier** | `(owner, name)` | `(owner, name)` | RID string | +//! | **PR concept** | Pull Request | Pull Request | Patch (with revisions) | +//! +//! ## Submodules +//! +//! - [`error`] -- shared error enum for all forge operations. +//! - [`github`] -- GitHub / GitHub Enterprise provider. +//! - [`gitea`] -- Gitea / Forgejo provider. +//! - [`radicle`] -- Radicle provider (webhook and polling modes). + +pub mod error; +pub mod gitea; +pub mod github; +pub mod radicle; + +use async_trait::async_trait; +use jupiter_api_types::{CommitStatusUpdate, ForgeType, PullRequestAction}; + +/// A raw forge event carrying repository-identifying information (owner/name or RID) +/// rather than a resolved database UUID. +/// +/// The server layer resolves these into `jupiter_api_types::ForgeEvent` by +/// looking up the repository in the Jupiter database. +/// +/// # Design Rationale +/// +/// This enum intentionally uses `String` fields (not database IDs) so that the +/// forge crate has zero coupling to the database schema. Each forge backend +/// populates the variant that matches the incoming webhook: +/// +/// - **GitHub / Gitea**: emit [`Push`](RawForgeEvent::Push) or +/// [`PullRequest`](RawForgeEvent::PullRequest) with `repo_owner` and +/// `repo_name` strings extracted from the JSON payload. +/// - **Radicle**: emits [`PatchUpdated`](RawForgeEvent::PatchUpdated) with the +/// Radicle RID (e.g. `rad:z2...`) and patch/revision identifiers, or +/// [`Push`](RawForgeEvent::Push) with the RID in the `repo_name` field and +/// an empty `repo_owner` (Radicle has no owner concept). +#[derive(Debug, Clone)] +pub enum RawForgeEvent { + /// A branch or tag was pushed. + /// + /// Emitted by all three forges. For Radicle push events the `repo_owner` + /// is empty and `repo_name` holds the RID. + Push { + /// Repository owner login (empty for Radicle). + repo_owner: String, + /// Repository name, or the Radicle RID for Radicle push events. + repo_name: String, + /// Full git ref, e.g. `refs/heads/main`. + git_ref: String, + /// Commit SHA before the push (all-zeros for new branches). + before: String, + /// Commit SHA after the push. + after: String, + /// Login / node-ID of the user who pushed. + sender: String, + }, + /// A pull request was opened, synchronized, reopened, or closed. + /// + /// Used by GitHub and Gitea. Radicle uses [`PatchUpdated`](RawForgeEvent::PatchUpdated) + /// instead, since its collaboration model is patch-based rather than + /// branch-based. + PullRequest { + /// Repository owner login. + repo_owner: String, + /// Repository name. + repo_name: String, + /// The action that triggered this event (opened, synchronize, etc.). + action: PullRequestAction, + /// Pull request number. + pr_number: u64, + /// SHA of the head commit on the PR branch. + head_sha: String, + /// Name of the base branch the PR targets. + base_ref: String, + }, + /// A Radicle patch was created or updated with a new revision. + /// + /// Radicle's collaboration model uses "patches" instead of pull requests. + /// Each patch can have multiple revisions (analogous to force-pushing a + /// PR branch). This event fires for both new patches and new revisions + /// on existing patches. + PatchUpdated { + /// The Radicle Repository ID (e.g. `rad:z2...`). + repo_rid: String, + /// The unique patch identifier. + patch_id: String, + /// The specific revision within the patch. + revision_id: String, + /// The commit SHA at the tip of this revision. + head_sha: String, + }, +} + +/// Trait implemented by each forge backend (GitHub, Gitea, Radicle). +/// +/// The Jupiter server maintains a `HashMap>` +/// registry. When a webhook request arrives, the server: +/// +/// 1. Determines the [`ForgeType`] from request headers. +/// 2. Looks up the corresponding `ForgeProvider`. +/// 3. Calls [`verify_webhook`](ForgeProvider::verify_webhook) to authenticate +/// the request. +/// 4. Calls [`parse_webhook`](ForgeProvider::parse_webhook) to extract a +/// [`RawForgeEvent`]. +/// 5. Later calls [`set_commit_status`](ForgeProvider::set_commit_status) to +/// report evaluation results back to the forge. +/// +/// # Sync vs Async Methods +/// +/// Webhook verification and parsing are **synchronous** because they operate +/// purely on in-memory data (HMAC computation + JSON deserialization). Methods +/// that perform HTTP requests to the forge API (`set_commit_status`, +/// `clone_url`, `list_repos`) are **async**. +/// +/// # Thread Safety +/// +/// Providers must be `Send + Sync` so they can be shared across the async +/// task pool in the server. All mutable state (e.g. HTTP clients) is +/// internally synchronized by `reqwest::Client`. +#[async_trait] +pub trait ForgeProvider: Send + Sync { + /// Returns the [`ForgeType`] discriminant for this provider. + /// + /// Used by the server to route incoming webhooks to the correct provider + /// implementation. + fn forge_type(&self) -> ForgeType; + + /// Verify the authenticity of an incoming webhook request. + /// + /// Each forge uses a different mechanism: + /// + /// - **GitHub**: HMAC-SHA256 with a `sha256=` hex prefix in the + /// `X-Hub-Signature-256` header. + /// - **Gitea**: HMAC-SHA256 with raw hex in the `X-Gitea-Signature` header. + /// - **Radicle**: No signature -- Radicle CI broker connections are trusted + /// local transport, so this always returns `Ok(true)`. + /// + /// Both GitHub and Gitea implementations use **constant-time comparison** + /// (byte-wise XOR accumulation) to prevent timing side-channel attacks + /// that could allow an attacker to iteratively guess a valid signature. + /// + /// # Parameters + /// + /// * `signature_header` -- the raw value of the forge's signature header, + /// or `None` if the header was absent. A missing header causes GitHub + /// and Gitea to return `Ok(false)`. + /// * `body` -- the raw HTTP request body bytes used as HMAC input. + /// + /// # Returns + /// + /// * `Ok(true)` -- signature is valid. + /// * `Ok(false)` -- signature is invalid or missing. + /// * `Err(ForgeError)` -- the signature header was malformed or HMAC + /// initialization failed. + fn verify_webhook( + &self, + signature_header: Option<&str>, + body: &[u8], + ) -> Result; + + /// Parse a verified webhook payload into a [`RawForgeEvent`]. + /// + /// This should only be called **after** [`verify_webhook`](ForgeProvider::verify_webhook) + /// returns `Ok(true)`. + /// + /// # Parameters + /// + /// * `event_type` -- the value of the forge's event-type header: + /// - GitHub: `X-GitHub-Event` (e.g. `"push"`, `"pull_request"`). + /// - Gitea: `X-Gitea-Event` (e.g. `"push"`, `"pull_request"`). + /// - Radicle: CI broker message type (e.g. `"patch"`, `"push"`). + /// * `body` -- the raw JSON request body. + /// + /// # Returns + /// + /// * `Ok(Some(event))` -- a recognized, actionable event. + /// * `Ok(None)` -- a valid but uninteresting event that Jupiter does not + /// act on (e.g. GitHub "star" or "fork" events, or unhandled PR actions + /// like "labeled"). + /// * `Err(ForgeError)` -- the payload could not be parsed. + fn parse_webhook( + &self, + event_type: &str, + body: &[u8], + ) -> Result, error::ForgeError>; + + /// Report a commit status back to the forge so that PR checks / patch + /// status reflect the Jupiter CI evaluation result. + /// + /// This is the primary feedback mechanism: when an agent finishes + /// evaluating a jobset, the server calls this method to set the commit + /// to `pending`, `success`, or `failure`. On GitHub and Gitea this + /// creates a "status check" visible in the PR UI. On Radicle it posts + /// to the `radicle-httpd` status API. + /// + /// # Parameters + /// + /// * `repo_owner` -- repository owner (ignored for Radicle). + /// * `repo_name` -- repository name, or RID for Radicle. + /// * `commit_sha` -- the full commit SHA to attach the status to. + /// * `status` -- the status payload (state, context, description, URL). + async fn set_commit_status( + &self, + repo_owner: &str, + repo_name: &str, + commit_sha: &str, + status: &CommitStatusUpdate, + ) -> Result<(), error::ForgeError>; + + /// Return an authenticated clone URL that a Hercules agent can use to + /// fetch the repository. + /// + /// The URL format varies by forge: + /// + /// - **GitHub**: `https://x-access-token:@github.com//.git` + /// -- uses the special `x-access-token` username that GitHub App + /// installation tokens require. + /// - **Gitea**: `https:////.git` -- authentication is + /// handled out-of-band (e.g. via git credential helpers or `.netrc`). + /// - **Radicle**: `rad://` -- the native Radicle protocol URL; the + /// local Radicle node handles authentication via its cryptographic + /// identity. + async fn clone_url( + &self, + repo_owner: &str, + repo_name: &str, + ) -> Result; + + /// List all repositories accessible through this forge connection. + /// + /// Returns a vec of `(owner, name, clone_url)` tuples. For Radicle the + /// `owner` is always an empty string since Radicle repositories are + /// identified solely by their RID. + /// + /// Used during initial setup and periodic sync to discover which + /// repositories should be tracked by Jupiter. + async fn list_repos(&self) -> Result, error::ForgeError>; +} diff --git a/crates/jupiter-forge/src/radicle.rs b/crates/jupiter-forge/src/radicle.rs new file mode 100644 index 0000000..f4d928a --- /dev/null +++ b/crates/jupiter-forge/src/radicle.rs @@ -0,0 +1,506 @@ +//! Radicle forge provider for Jupiter CI. +//! +//! [Radicle](https://radicle.xyz) is a peer-to-peer code collaboration +//! network. Unlike GitHub and Gitea, Radicle has no central server -- code +//! is replicated across nodes using a gossip protocol. This creates +//! fundamental differences in how Jupiter integrates with Radicle compared +//! to the other forges: +//! +//! - **No webhook signatures**: Radicle CI broker events arrive over trusted +//! local transport (typically a Unix socket or localhost HTTP), so there is +//! no HMAC verification. [`verify_webhook`](ForgeProvider::verify_webhook) +//! always returns `Ok(true)`. +//! +//! - **Repository identity**: Repos are identified by a Radicle ID (RID, e.g. +//! `rad:z2...`) rather than an `(owner, name)` pair. The `repo_owner` +//! field is always empty in events from this provider. +//! +//! - **Patches instead of PRs**: Radicle uses "patches" as its collaboration +//! primitive. Each patch can have multiple revisions (analogous to +//! force-pushing a PR branch). This provider emits +//! [`RawForgeEvent::PatchUpdated`] rather than `PullRequest`. +//! +//! - **Clone URLs**: Radicle repos use the `rad://` protocol, not HTTPS. +//! Agents must have the `rad` CLI and a local Radicle node configured. +//! +//! ## Two Operating Modes +//! +//! The provider supports two modes, selected via [`RadicleMode`]: +//! +//! ### `RadicleMode::CiBroker` (Webhook Mode) +//! +//! In this mode, the `radicle-ci-broker` component pushes events to Jupiter +//! as HTTP POST requests. The provider parses these using +//! [`parse_webhook`](ForgeProvider::parse_webhook) with event types like +//! `"patch"`, `"patch_updated"`, `"patch_created"`, or `"push"`. +//! +//! ### `RadicleMode::HttpdPolling` (Polling Mode) +//! +//! In this mode, Jupiter periodically calls [`poll_changes`](RadicleProvider::poll_changes) +//! to query the `radicle-httpd` REST API for new or updated patches. The +//! caller maintains a `HashMap` of `"rid/patch/id" -> last-seen-oid` +//! entries. The provider compares the current state against this map and +//! returns events for any changes detected. +//! +//! Polling is useful when `radicle-ci-broker` is not available, at the cost +//! of higher latency (changes are detected at the poll interval rather than +//! immediately). + +use std::collections::HashMap; + +use async_trait::async_trait; +use reqwest::Client; +use serde::Deserialize; +use tracing::{debug, warn}; + +use crate::error::ForgeError; +use crate::{ForgeProvider, RawForgeEvent}; +use jupiter_api_types::{CommitStatus, CommitStatusUpdate, ForgeType, RadicleMode}; + +// --------------------------------------------------------------------------- +// Internal serde types for Radicle httpd API responses +// --------------------------------------------------------------------------- +// +// These structs model the subset of the `radicle-httpd` REST API that Jupiter +// needs for polling mode and status reporting. + +/// A repository as returned by `GET /api/v1/repos` on `radicle-httpd`. +#[derive(Debug, Deserialize)] +struct RadicleRepo { + /// The Radicle Repository ID (e.g. `rad:z2...`). + rid: String, + /// Human-readable repository name. + name: String, + #[allow(dead_code)] + description: Option, +} + +/// A patch (Radicle's equivalent of a pull request) from the httpd API. +#[derive(Debug, Deserialize)] +struct RadiclePatch { + /// Unique patch identifier. + id: String, + #[allow(dead_code)] + title: String, + #[allow(dead_code)] + state: RadiclePatchState, + /// Ordered list of revisions; the last entry is the most recent. + revisions: Vec, +} + +/// A single revision within a Radicle patch. +/// +/// Each revision represents a complete rewrite of the patch (analogous to +/// force-pushing a PR branch). The `oid` is the git commit SHA at the +/// tip of that revision. +#[derive(Debug, Deserialize)] +struct RadicleRevision { + /// Revision identifier. + id: String, + /// Git object ID (commit SHA) at the tip of this revision. + oid: String, +} + +/// The state sub-object of a Radicle patch (e.g. "open", "merged", "closed"). +#[derive(Debug, Deserialize)] +struct RadiclePatchState { + #[allow(dead_code)] + status: String, +} + +/// Webhook payload from the Radicle CI broker or a custom webhook sender. +/// +/// All fields are optional because Radicle uses a single payload structure +/// for multiple event types. Patch events populate `rid`, `patch_id`, +/// `revision_id`, and `commit`. Push events populate `rid`, `git_ref`, +/// `before`, `commit`, and `sender`. +#[derive(Debug, Deserialize)] +struct RadicleBrokerPayload { + /// The repository RID, e.g. `rad:z2...`. + rid: Option, + /// Patch ID (present for patch events). + patch_id: Option, + /// Revision ID within the patch (present for patch events). + revision_id: Option, + /// The commit SHA at HEAD. + commit: Option, + /// Full git ref (present for push events), e.g. `refs/heads/main`. + #[serde(rename = "ref")] + git_ref: Option, + /// Previous commit SHA (present for push events). + before: Option, + /// Sender node ID or identity (present for push events). + sender: Option, +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +/// Radicle forge provider for Jupiter CI. +/// +/// Supports two operating modes: +/// +/// - **`CiBroker`** (webhook mode): receives JSON events pushed by +/// `radicle-ci-broker` over trusted local HTTP. +/// - **`HttpdPolling`** (poll mode): periodically queries `radicle-httpd` +/// for new patches and compares against last-seen state. +/// +/// # Fields +/// +/// - `httpd_url` -- Base URL of the `radicle-httpd` instance (e.g. +/// `http://localhost:8080`). Used for API calls in both modes. +/// - `node_id` -- The local Radicle node identity. Reserved for future use +/// (e.g. authenticating status updates). +/// - `mode` -- The active operating mode ([`RadicleMode`]). +/// - `poll_interval_secs` -- Seconds between poll cycles (only meaningful in +/// `HttpdPolling` mode; the actual scheduling is done by the caller). +/// - `client` -- Reusable `reqwest::Client` for connection pooling. +pub struct RadicleProvider { + /// Base URL of the `radicle-httpd` REST API (no trailing slash). + httpd_url: String, + /// Local Radicle node ID (reserved for future authenticated operations). + #[allow(dead_code)] + node_id: String, + /// Operating mode: webhook (`CiBroker`) or poll (`HttpdPolling`). + mode: RadicleMode, + /// Poll interval in seconds (only used in `HttpdPolling` mode). + #[allow(dead_code)] + poll_interval_secs: u64, + /// Reusable HTTP client. + client: Client, +} + +impl RadicleProvider { + /// Create a new Radicle provider. + /// + /// # Parameters + /// + /// * `httpd_url` -- Base URL of the local `radicle-httpd` instance, e.g. + /// `http://localhost:8080`. A trailing slash is stripped automatically. + /// This URL is used for: + /// - Fetching repos and patches during polling (`/api/v1/repos`). + /// - Posting commit statuses (`/api/v1/repos/{rid}/statuses/{sha}`). + /// * `node_id` -- The local Radicle node identity string. Currently + /// stored but not used; will be needed for future authenticated + /// operations. + /// * `mode` -- [`RadicleMode::CiBroker`] for webhook-driven operation or + /// [`RadicleMode::HttpdPolling`] for periodic polling. + /// * `poll_interval_secs` -- How often (in seconds) the server should + /// call [`poll_changes`](RadicleProvider::poll_changes). Only + /// meaningful in `HttpdPolling` mode; the actual timer is managed by + /// the server, not this provider. + pub fn new( + httpd_url: String, + node_id: String, + mode: RadicleMode, + poll_interval_secs: u64, + ) -> Self { + Self { + httpd_url: httpd_url.trim_end_matches('/').to_string(), + node_id, + mode, + poll_interval_secs, + client: Client::new(), + } + } + + /// Poll `radicle-httpd` for new or updated patches. + /// + /// This is the core of `HttpdPolling` mode. The caller maintains a + /// persistent map of `"rid/patch/id" -> last-seen-oid` entries and passes + /// it in as `known_refs`. The method: + /// + /// 1. Fetches all repos from `radicle-httpd`. + /// 2. For each repo, fetches all patches. + /// 3. For each patch, looks at the **latest revision** (the last element + /// in the `revisions` array). + /// 4. Compares the revision's `oid` against the `known_refs` map. + /// 5. If the oid is different (or absent from the map), emits a + /// [`RawForgeEvent::PatchUpdated`]. + /// + /// The caller is responsible for updating `known_refs` with the returned + /// events after processing them. + /// + /// # Errors + /// + /// Returns `Err(ForgeError::NotConfigured)` if called on a provider in + /// `CiBroker` mode, since polling is not applicable when events are + /// pushed by the broker. + /// + /// # Parameters + /// + /// * `known_refs` -- Map of `"rid/patch/id"` keys to last-known commit + /// SHA values. An empty map means all patches will be reported as new. + pub async fn poll_changes( + &self, + known_refs: &HashMap, + ) -> Result, ForgeError> { + if self.mode != RadicleMode::HttpdPolling { + return Err(ForgeError::NotConfigured( + "poll_changes called but provider is in webhook mode".into(), + )); + } + + let repos = self.fetch_repos().await?; + let mut events = Vec::new(); + + for repo in &repos { + // Check patches for each repo. + let patches = self.fetch_patches(&repo.rid).await?; + for patch in patches { + if let Some(rev) = patch.revisions.last() { + let key = format!("{}/patch/{}", repo.rid, patch.id); + let is_new = known_refs + .get(&key) + .map(|prev| prev != &rev.oid) + .unwrap_or(true); + if is_new { + events.push(RawForgeEvent::PatchUpdated { + repo_rid: repo.rid.clone(), + patch_id: patch.id.clone(), + revision_id: rev.id.clone(), + head_sha: rev.oid.clone(), + }); + } + } + } + } + + Ok(events) + } + + // -- internal helpers --------------------------------------------------- + + /// Fetch the list of all repositories known to `radicle-httpd`. + /// + /// Calls `GET /api/v1/repos`. No authentication is required because + /// `radicle-httpd` runs locally and trusts all connections. + async fn fetch_repos(&self) -> Result, ForgeError> { + let url = format!("{}/api/v1/repos", self.httpd_url); + let resp = self.client.get(&url).send().await?; + if !resp.status().is_success() { + let status_code = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(ForgeError::ApiError(format!( + "radicle-httpd returned {status_code}: {text}" + ))); + } + let repos: Vec = resp.json().await?; + Ok(repos) + } + + /// Fetch all patches for a given repository. + /// + /// Calls `GET /api/v1/repos/{rid}/patches`. Returns patches in all + /// states (open, merged, closed) so that the polling logic can detect + /// new revisions even on previously-seen patches. + async fn fetch_patches(&self, rid: &str) -> Result, ForgeError> { + let url = format!("{}/api/v1/repos/{}/patches", self.httpd_url, rid); + let resp = self.client.get(&url).send().await?; + if !resp.status().is_success() { + let status_code = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(ForgeError::ApiError(format!( + "radicle-httpd returned {status_code}: {text}" + ))); + } + let patches: Vec = resp.json().await?; + Ok(patches) + } + + /// Map Jupiter's [`CommitStatus`] enum to the string values expected by + /// the `radicle-httpd` status API. + fn radicle_status_string(status: CommitStatus) -> &'static str { + match status { + CommitStatus::Pending => "pending", + CommitStatus::Success => "success", + CommitStatus::Failure => "failure", + CommitStatus::Error => "error", + } + } +} + +#[async_trait] +impl ForgeProvider for RadicleProvider { + fn forge_type(&self) -> ForgeType { + ForgeType::Radicle + } + + /// Radicle webhook verification is a **no-op** that always returns + /// `Ok(true)`. + /// + /// Unlike GitHub and Gitea, Radicle CI broker events arrive over trusted + /// local transport (e.g. a localhost HTTP connection or a Unix socket). + /// There is no shared secret or HMAC signature because the threat model + /// assumes the broker and Jupiter run on the same machine or within a + /// trusted network boundary. + /// + /// If Jupiter is exposed to untrusted networks, access to the Radicle + /// webhook endpoint should be restricted at the network layer (firewall, + /// reverse proxy allowlist, etc.). + fn verify_webhook( + &self, + _signature_header: Option<&str>, + _body: &[u8], + ) -> Result { + Ok(true) + } + + /// Parse a Radicle CI broker webhook payload. + /// + /// Recognized event types: + /// + /// - `"patch"`, `"patch_updated"`, `"patch_created"` -- a Radicle patch + /// was created or received a new revision. Produces + /// [`RawForgeEvent::PatchUpdated`]. The `rid`, `patch_id`, and + /// `commit` fields are required; `revision_id` defaults to empty. + /// + /// - `"push"` -- a ref was updated on a Radicle repository. Produces + /// [`RawForgeEvent::Push`] with `repo_owner` set to empty (Radicle + /// has no owner concept) and `repo_name` set to the RID. The `git_ref` + /// defaults to `"refs/heads/main"` and `before` defaults to 40 zeros + /// (indicating a new ref) if not present in the payload. + /// + /// All other event types are silently ignored. + fn parse_webhook( + &self, + event_type: &str, + body: &[u8], + ) -> Result, ForgeError> { + match event_type { + "patch" | "patch_updated" | "patch_created" => { + let payload: RadicleBrokerPayload = serde_json::from_slice(body)?; + let rid = payload.rid.ok_or_else(|| { + ForgeError::ParseError("missing rid in Radicle patch event".into()) + })?; + let patch_id = payload.patch_id.ok_or_else(|| { + ForgeError::ParseError("missing patch_id in Radicle patch event".into()) + })?; + let revision_id = payload.revision_id.unwrap_or_default(); + let head_sha = payload.commit.ok_or_else(|| { + ForgeError::ParseError("missing commit in Radicle patch event".into()) + })?; + + debug!(%rid, %patch_id, "parsed Radicle patch event"); + Ok(Some(RawForgeEvent::PatchUpdated { + repo_rid: rid, + patch_id, + revision_id, + head_sha, + })) + } + "push" => { + let payload: RadicleBrokerPayload = serde_json::from_slice(body)?; + let rid = payload.rid.ok_or_else(|| { + ForgeError::ParseError("missing rid in Radicle push event".into()) + })?; + let git_ref = payload.git_ref.unwrap_or_else(|| "refs/heads/main".into()); + let before = payload.before.unwrap_or_else(|| "0".repeat(40)); + let after = payload.commit.unwrap_or_default(); + let sender = payload.sender.unwrap_or_default(); + + debug!(%rid, %git_ref, "parsed Radicle push event"); + Ok(Some(RawForgeEvent::Push { + repo_owner: String::new(), + repo_name: rid, + git_ref, + before, + after, + sender, + })) + } + other => { + debug!(event = %other, "ignoring unhandled Radicle event type"); + Ok(None) + } + } + } + + /// Report a commit status to `radicle-httpd` via + /// `POST /api/v1/repos/{rid}/statuses/{sha}`. + /// + /// For Radicle, the `repo_owner` parameter is ignored (Radicle repos have + /// no owner) and `repo_name` is expected to contain the RID. + /// + /// No authentication headers are sent because `radicle-httpd` runs + /// locally and trusts all connections. + async fn set_commit_status( + &self, + _repo_owner: &str, + repo_name: &str, + commit_sha: &str, + status: &CommitStatusUpdate, + ) -> Result<(), ForgeError> { + // repo_name is the RID for Radicle repos. + let url = format!( + "{}/api/v1/repos/{}/statuses/{}", + self.httpd_url, repo_name, commit_sha, + ); + + let body = serde_json::json!({ + "state": Self::radicle_status_string(status.status), + "context": status.context, + "description": status.description, + "target_url": status.target_url, + }); + + let resp = self.client.post(&url).json(&body).send().await?; + + if !resp.status().is_success() { + let status_code = resp.status(); + let text = resp.text().await.unwrap_or_default(); + warn!(%status_code, body = %text, "radicle-httpd status API error"); + return Err(ForgeError::ApiError(format!( + "radicle-httpd returned {status_code}: {text}" + ))); + } + + Ok(()) + } + + /// Return a `rad://` clone URL for a Radicle repository. + /// + /// Radicle uses its own protocol (`rad://`) for cloning. The agent + /// machine must have the `rad` CLI installed and a local Radicle node + /// running to resolve and fetch from this URL. + /// + /// If the RID already starts with `rad:`, it is returned as-is. + /// Otherwise, the `rad://` prefix is prepended. The `repo_owner` + /// parameter is ignored (Radicle has no owner concept). + async fn clone_url( + &self, + _repo_owner: &str, + repo_name: &str, + ) -> Result { + // For Radicle the "repo_name" is the RID. + // Ensure it has the rad:// prefix. + if repo_name.starts_with("rad:") { + Ok(repo_name.to_string()) + } else { + Ok(format!("rad://{repo_name}")) + } + } + + /// List all repositories known to the local `radicle-httpd` instance. + /// + /// Returns `(owner, name, clone_url)` tuples where: + /// - `owner` is always an empty string (Radicle has no owner concept). + /// - `name` is the human-readable repository name. + /// - `clone_url` is the `rad://` URL derived from the RID. + async fn list_repos(&self) -> Result, ForgeError> { + let repos = self.fetch_repos().await?; + Ok(repos + .into_iter() + .map(|r| { + let clone_url = if r.rid.starts_with("rad:") { + r.rid.clone() + } else { + format!("rad://{}", r.rid) + }; + // Radicle has no "owner"; use empty string. + (String::new(), r.name, clone_url) + }) + .collect()) + } +} diff --git a/crates/jupiter-scheduler/Cargo.toml b/crates/jupiter-scheduler/Cargo.toml new file mode 100644 index 0000000..39d0b98 --- /dev/null +++ b/crates/jupiter-scheduler/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "jupiter-scheduler" +version.workspace = true +edition.workspace = true + +[dependencies] +jupiter-api-types = { workspace = true } +jupiter-db = { workspace = true } +jupiter-forge = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +anyhow = { workspace = true } diff --git a/crates/jupiter-scheduler/src/engine.rs b/crates/jupiter-scheduler/src/engine.rs new file mode 100644 index 0000000..f2b0dc9 --- /dev/null +++ b/crates/jupiter-scheduler/src/engine.rs @@ -0,0 +1,1327 @@ +//! The scheduler engine -- the beating heart of Jupiter. +//! +//! [`SchedulerEngine`] is a single-threaded event loop that runs as a background +//! tokio task and processes [`SchedulerEvent`]s arriving over a bounded `mpsc` +//! channel (capacity: 1000). Producers include: +//! +//! - **Forge webhook handlers** -- send [`SchedulerEvent::ForgeEvent`] when a push, +//! pull request, or patch update arrives. +//! - **Agent WebSocket handler** -- sends evaluation results (`AttributeDiscovered`, +//! `DerivationInfoReceived`, `EvaluationComplete`, `EvaluationFailed`), build +//! results (`BuildComplete`), and effect results (`EffectComplete`). +//! - **REST API handlers** -- send management events (`RerunJob`, `CancelJob`, +//! `RetryBuild`, `CancelBuild`, `CancelEffect`). +//! - **Agent lifecycle manager** -- sends [`SchedulerEvent::AgentDisconnected`] +//! when a WebSocket connection drops. +//! +//! ## Job lifecycle driven by events +//! +//! ```text +//! ForgeEvent +//! | +//! v +//! Job created (Pending -> Evaluating) +//! | +//! +--- AttributeDiscovered (stored to DB, zero or more) +//! +--- DerivationInfoReceived (stored to DB, zero or more) +//! | +//! v +//! EvaluationComplete +//! | +//! +-- No derivations? --> check_and_dispatch_effects --> Succeeded / RunningEffects +//! | +//! +-- Has derivations? --> Building +//! | +//! +--- BuildComplete (per derivation, deduplicated by drv path) +//! | +//! v +//! All builds done? --> notify_builds_complete --> check_and_dispatch_effects +//! | +//! +-- No effects? --> Succeeded +//! +-- Has effects? --> RunningEffects +//! | +//! +--- EffectComplete (per effect) +//! | +//! v +//! All effects done? --> Succeeded +//! ``` +//! +//! ## Error handling +//! +//! The event loop never panics on handler errors. Each event is processed +//! independently; if a handler returns `Err`, the error is logged via `tracing` +//! and the loop continues with the next event. +//! +//! ## Concurrency guarantees +//! +//! - The engine itself is single-threaded (one `&mut self` receiver on the channel), +//! so no internal locking is needed. +//! - All shared state lives in the database (accessed through `StorageBackend`), +//! which provides its own transactional guarantees. +//! - Effects within a job are dispatched concurrently (all enqueued at once). +//! - Effects between jobs on the same `(project_id, ref_name)` are serialized via +//! `sequence_number`: a job's effects only run after all preceding jobs' effects +//! on that ref have completed. +//! - Agent disconnection triggers `requeue_agent_tasks`, returning all in-flight +//! tasks to `Pending` so they can be claimed by another agent. + +use std::sync::Arc; +use tokio::sync::mpsc; +use uuid::Uuid; +use tracing::{info, warn, error}; +use jupiter_api_types::{ + AttributeType, BuildStatus, CommitStatus, CommitStatusUpdate, EffectStatus, + ForgeType, Job, JobStatus, TaskType, +}; +use jupiter_db::backend::StorageBackend; +use jupiter_forge::{ForgeProvider, RawForgeEvent}; +use crate::error::{SchedulerError, Result}; + +/// Events that the scheduler engine can process. +/// +/// Each variant represents a discrete state change or user action that the +/// engine must react to. Events are sent over an `mpsc` channel by webhook +/// handlers, WebSocket handlers, and REST endpoints. +/// +/// # Event sources +/// +/// | Variant | Typical producer | +/// |--------------------------|-------------------------------| +/// | `ForgeEvent` | Webhook handler | +/// | `EvaluationComplete` | Agent (via WebSocket) | +/// | `EvaluationFailed` | Agent (via WebSocket) | +/// | `AttributeDiscovered` | Agent (via WebSocket) | +/// | `DerivationInfoReceived` | Agent (via WebSocket) | +/// | `BuildComplete` | Agent (via WebSocket) | +/// | `EffectComplete` | Agent (via WebSocket) | +/// | `AgentDisconnected` | WebSocket lifecycle handler | +/// | `RerunJob` | REST API / dashboard | +/// | `CancelJob` | REST API / dashboard | +/// | `RetryBuild` | REST API / dashboard | +/// | `CancelBuild` | REST API / dashboard | +/// | `CancelEffect` | REST API / dashboard | +#[derive(Debug)] +pub enum SchedulerEvent { + /// A forge webhook produced a raw event (push, pull request, patch update). + /// + /// The scheduler will look up the matching repository and project in the + /// database, create a new `Job`, transition it to `Evaluating`, enqueue an + /// `EvaluateTask` for an agent to pick up, and report `Pending` commit + /// status back to the forge. + ForgeEvent { + /// The UUID of the forge configuration that received the webhook. + forge_id: Uuid, + /// The parsed webhook payload. + event: RawForgeEvent, + }, + + /// An agent finished evaluating the `herculesCI` flake attribute for a job. + /// + /// Triggers the transition from `Evaluating` to `Building` (if derivations + /// were discovered) or directly to the effects check (if none were found). + EvaluationComplete { + /// The job whose evaluation finished. + job_id: Uuid, + /// The evaluation task that completed. Currently unused but retained + /// for logging and future audit trail support. + task_id: Uuid, + }, + + /// An evaluation task failed (e.g., Nix evaluation error, missing flake + /// attribute, agent crash during evaluation). + /// + /// Transitions the job to `ErrorEvaluating` and reports `Failure` commit + /// status to the forge. + EvaluationFailed { + /// The job whose evaluation failed. + job_id: Uuid, + /// The failed evaluation task. + task_id: Uuid, + /// Human-readable error message from the agent. + error: String, + }, + + /// An agent discovered an attribute while walking the `herculesCI` flake + /// output during evaluation. + /// + /// Each attribute (e.g., `ciNix.x86_64-linux.default`, an effect, a + /// derivation) is stored in the database for later use during the build + /// and effects phases. + AttributeDiscovered { + /// The job being evaluated. + job_id: Uuid, + /// The attribute path segments (e.g., `["ciNix", "x86_64-linux", "default"]`). + path: Vec, + /// If this attribute refers to a Nix derivation, its store path + /// (e.g., `/nix/store/abc...-hello.drv`). + derivation_path: Option, + /// The type of attribute (derivation, effect, etc.). + typ: AttributeType, + /// If the attribute itself had an evaluation error, the message is + /// stored here. The job can still continue with other attributes. + error: Option, + }, + + /// An agent reported detailed derivation metadata discovered during + /// evaluation. + /// + /// This information is essential for the build phase: the `platform` field + /// determines which agent can build the derivation, `input_derivations` + /// captures the dependency graph, and `outputs` records the expected store + /// paths. + DerivationInfoReceived { + /// The job being evaluated. + job_id: Uuid, + /// The Nix store path of the derivation (e.g., `/nix/store/...-foo.drv`). + derivation_path: String, + /// The Nix system platform (e.g., `x86_64-linux`, `aarch64-darwin`). + /// Used to route build tasks to agents with matching architecture. + platform: String, + /// Required system features (e.g., `kvm`, `big-parallel`) that the + /// building agent must advertise. + required_system_features: Vec, + /// Store paths of derivations that this derivation depends on + /// (immediate inputs only). + input_derivations: Vec, + /// JSON object mapping output names to their expected store paths + /// (e.g., `{"out": "/nix/store/...-foo"}`). + outputs: serde_json::Value, + }, + + /// An agent completed building a derivation. + /// + /// On success, the scheduler checks whether all builds for linked jobs are + /// done, and if so, advances those jobs to the effects phase. On failure, + /// the build is marked as `Failed` and linked jobs will observe the failure + /// when their build completion is checked. + BuildComplete { + /// The UUID of the build record. + build_id: Uuid, + /// The derivation store path that was built. + derivation_path: String, + /// Whether the build succeeded. + success: bool, + }, + + /// An agent completed running an effect. + /// + /// On success, the scheduler checks whether all effects for the job are + /// done; if so, the job transitions to `Succeeded`. On failure, the job + /// transitions immediately to `Failed`. + EffectComplete { + /// The UUID of the effect record. + effect_id: Uuid, + /// The job that owns this effect. + job_id: Uuid, + /// Whether the effect succeeded. + success: bool, + }, + + /// An agent's WebSocket connection dropped. + /// + /// All tasks currently assigned to this agent session are returned to + /// `Pending` state so another agent can claim them. This provides + /// resilience against agent crashes, network partitions, and graceful + /// agent restarts. + AgentDisconnected { + /// The session UUID of the disconnected agent. Each WebSocket + /// connection gets a unique session ID, so reconnecting agents get + /// a fresh session. + agent_session_id: Uuid, + }, + + /// A user requested re-running a job from scratch. + /// + /// The job is reset to `Evaluating` and a fresh evaluation task is + /// enqueued. Previous build and effect records are not deleted (they + /// serve as an audit trail). + RerunJob { + /// The job to re-run. + job_id: Uuid, + }, + + /// A user requested cancellation of a job. + /// + /// The job transitions to `Cancelled`. In-flight tasks are not + /// force-killed but their results will be ignored. + CancelJob { + /// The job to cancel. + job_id: Uuid, + }, + + /// A user requested retrying a failed build. + /// + /// The build status is reset to `Pending` so it re-enters the task queue. + RetryBuild { + /// The build to retry. + build_id: Uuid, + }, + + /// A user requested cancellation of a build. + /// + /// The build transitions to `Cancelled`. + CancelBuild { + /// The build to cancel. + build_id: Uuid, + }, + + /// A user requested cancellation of an effect. + /// + /// The effect transitions to `Cancelled`. + CancelEffect { + /// The effect to cancel. + effect_id: Uuid, + /// The job that owns this effect. + job_id: Uuid, + }, +} + +/// The scheduler engine drives jobs through the Hercules CI pipeline. +/// +/// `SchedulerEngine` is generic over a [`StorageBackend`] to allow different +/// database implementations (PostgreSQL, SQLite, in-memory for tests). It owns +/// the receiving half of the event channel and holds shared references to the +/// database and forge providers. +/// +/// # Lifecycle +/// +/// 1. Create with [`SchedulerEngine::new`], which allocates the `mpsc` channel. +/// 2. Clone [`event_sender`](SchedulerEngine::event_sender) handles and +/// distribute them to webhook handlers, WebSocket handlers, and REST +/// endpoints. +/// 3. Call [`run`](SchedulerEngine::run) to start the event loop. This +/// consumes `self` and runs until all senders are dropped. +/// +/// # State machine transitions +/// +/// The engine implements the following job state transitions: +/// +/// | Current state | Event | Next state | +/// |------------------|--------------------------|------------------| +/// | (new) | `ForgeEvent` | `Evaluating` | +/// | `Evaluating` | `EvaluationComplete` | `Building` * | +/// | `Evaluating` | `EvaluationFailed` | `ErrorEvaluating`| +/// | `Building` | All builds complete | `RunningEffects` or `Succeeded` ** | +/// | `RunningEffects` | `EffectComplete` (fail) | `Failed` | +/// | `RunningEffects` | All effects complete | `Succeeded` | +/// | Any | `CancelJob` | `Cancelled` | +/// | Any terminal | `RerunJob` | `Evaluating` | +/// +/// \* If no derivations were discovered, skips `Building` and goes straight +/// to the effects check. +/// +/// \*\* If no effects exist, transitions directly to `Succeeded`. +/// +/// # Concurrency notes +/// +/// - The engine processes events sequentially (single `&mut self` receiver), +/// so handler methods do not need internal synchronization. +/// - All persistent state is in the database; the engine is stateless beyond +/// its DB and forge handles. +/// - IFD (import-from-derivation) support requires agents to have at least 2 +/// concurrent task slots on `x86_64-linux` to avoid deadlock between the +/// evaluator and the IFD builder. +pub struct SchedulerEngine { + /// Shared handle to the database backend, used for all persistent state. + db: Arc, + /// Registered forge providers (GitHub, Gitea, Radicle, etc.), each + /// identified by a UUID matching the forge configuration in the database. + /// Used to report commit status back to the forge. + forges: Arc)>>, + /// Sending half of the event channel. Kept here so [`event_sender`] can + /// clone it. Also holds a strong reference to prevent the channel from + /// closing prematurely. + event_tx: mpsc::Sender, + /// Receiving half of the event channel. Consumed by [`run`]. + event_rx: mpsc::Receiver, +} + +impl SchedulerEngine { + /// Create a new scheduler engine. + /// + /// Allocates a bounded `mpsc` channel with a capacity of 1000 events. + /// The channel provides backpressure: if the scheduler falls behind, + /// producers (webhook/WebSocket/REST handlers) will await until there + /// is room in the channel, preventing unbounded memory growth. + /// + /// # Arguments + /// + /// * `db` -- Shared database handle implementing [`StorageBackend`]. + /// * `forges` -- List of `(forge_id, provider)` pairs for commit status + /// reporting. + pub fn new( + db: Arc, + forges: Arc)>>, + ) -> Self { + let (event_tx, event_rx) = mpsc::channel(1000); + Self { + db, + forges, + event_tx, + event_rx, + } + } + + /// Get a cloneable sender handle for submitting events to the scheduler. + /// + /// Distribute clones of this sender to all subsystems that need to + /// communicate with the scheduler (webhook handlers, WebSocket handlers, + /// REST endpoints, agent lifecycle managers). + /// + /// The channel is bounded (capacity 1000), so sending will `.await` if + /// the scheduler is backlogged. + pub fn event_sender(&self) -> mpsc::Sender { + self.event_tx.clone() + } + + /// Run the scheduler event loop until the channel closes. + /// + /// This method consumes `self` and blocks the current tokio task. It + /// processes events sequentially: each event is fully handled before + /// the next one is dequeued. + /// + /// The loop terminates when all [`mpsc::Sender`] handles (including the + /// one stored in `self.event_tx`) are dropped, which typically happens + /// during server shutdown. + /// + /// Errors from individual event handlers are logged but do **not** stop + /// the loop -- the scheduler is designed to be resilient against + /// transient failures. + pub async fn run(mut self) { + info!("Scheduler engine started"); + while let Some(event) = self.event_rx.recv().await { + if let Err(e) = self.handle_event(event).await { + error!("Scheduler error: {}", e); + } + } + info!("Scheduler engine stopped"); + } + + /// Dispatch a single event to the appropriate handler. + /// + /// This is the central routing function. Each [`SchedulerEvent`] variant + /// maps to exactly one handler method. The match is exhaustive so adding + /// a new event variant produces a compile error until a handler is added. + async fn handle_event(&self, event: SchedulerEvent) -> Result<()> { + match event { + SchedulerEvent::ForgeEvent { forge_id, event } => { + self.handle_forge_event(forge_id, event).await?; + } + SchedulerEvent::EvaluationComplete { job_id, task_id } => { + self.handle_evaluation_complete(job_id, task_id).await?; + } + SchedulerEvent::EvaluationFailed { + job_id, + task_id, + error: err_msg, + } => { + self.handle_evaluation_failed(job_id, task_id, &err_msg) + .await?; + } + SchedulerEvent::AttributeDiscovered { + job_id, + path, + derivation_path, + typ, + error: attr_error, + } => { + self.handle_attribute_discovered( + job_id, + &path, + derivation_path.as_deref(), + typ, + attr_error.as_deref(), + ) + .await?; + } + SchedulerEvent::DerivationInfoReceived { + job_id, + derivation_path, + platform, + required_system_features, + input_derivations, + outputs, + } => { + self.handle_derivation_info( + job_id, + &derivation_path, + &platform, + &required_system_features, + &input_derivations, + &outputs, + ) + .await?; + } + SchedulerEvent::BuildComplete { + build_id, + derivation_path, + success, + } => { + self.handle_build_complete(build_id, &derivation_path, success) + .await?; + } + SchedulerEvent::EffectComplete { + effect_id, + job_id, + success, + } => { + self.handle_effect_complete(effect_id, job_id, success) + .await?; + } + SchedulerEvent::AgentDisconnected { agent_session_id } => { + self.handle_agent_disconnect(agent_session_id).await?; + } + SchedulerEvent::RerunJob { job_id } => { + self.handle_rerun_job(job_id).await?; + } + SchedulerEvent::CancelJob { job_id } => { + self.handle_cancel_job(job_id).await?; + } + SchedulerEvent::RetryBuild { build_id } => { + self.handle_retry_build(build_id).await?; + } + SchedulerEvent::CancelBuild { build_id } => { + self.handle_cancel_build(build_id).await?; + } + SchedulerEvent::CancelEffect { effect_id, job_id } => { + self.handle_cancel_effect(effect_id, job_id).await?; + } + } + Ok(()) + } + + // ── Forge events ──────────────────────────────────────────────── + + /// Handle a forge webhook event (push, pull request, or patch update). + /// + /// This is the entry point for the entire CI pipeline. The handler: + /// + /// 1. Extracts repository coordinates and commit SHA from the raw event. + /// - **Push**: uses the target ref and `after` SHA. + /// - **PullRequest**: synthesizes a `refs/pull/{N}/head` ref. + /// - **PatchUpdated** (Radicle): synthesizes a `patches/{id}` ref. + /// 2. Looks up the repository in the database by `(forge_id, owner, name)`. + /// If not found, the event is silently ignored (the repo is not tracked). + /// 3. Finds the project linked to the repository. Disabled projects are + /// also silently ignored. + /// 4. Creates a new `Job` record in the database. + /// 5. Transitions the job to `Evaluating`. + /// 6. Enqueues an `EvaluateTask` with the repo clone URL, ref, and SHA so + /// an agent can pick it up and evaluate the `herculesCI` flake attribute. + /// 7. Reports `Pending` commit status to the forge so the PR / commit page + /// shows that CI has started. + /// + /// # State transition + /// + /// ```text + /// (new) --> Pending --> Evaluating + /// ``` + async fn handle_forge_event( + &self, + forge_id: Uuid, + event: RawForgeEvent, + ) -> Result<()> { + // Extract repo coordinates and commit SHA from the forge-specific + // event payload. Each forge type (GitHub, Gitea, Radicle) has its own + // webhook format, but they all boil down to these four fields. + let (repo_owner, repo_name, ref_name, commit_sha) = match &event { + RawForgeEvent::Push { + repo_owner, + repo_name, + git_ref, + after, + .. + } => ( + repo_owner.clone(), + repo_name.clone(), + git_ref.clone(), + after.clone(), + ), + RawForgeEvent::PullRequest { + repo_owner, + repo_name, + head_sha, + pr_number, + .. + } => ( + repo_owner.clone(), + repo_name.clone(), + format!("refs/pull/{}/head", pr_number), + head_sha.clone(), + ), + RawForgeEvent::PatchUpdated { + repo_rid, + head_sha, + patch_id, + .. + } => ( + repo_rid.clone(), + String::new(), + format!("patches/{}", patch_id), + head_sha.clone(), + ), + }; + + // Look up the repository in the database. Webhooks may fire for repos + // that are not tracked by Jupiter (e.g., the forge is configured + // org-wide), so a missing repo is not an error. + let repo = self + .db + .find_repo(forge_id, &repo_owner, &repo_name) + .await + .map_err(SchedulerError::Db)?; + + let repo = match repo { + Some(r) => r, + None => { + info!( + "No repo found for {}/{} on forge {}, ignoring", + repo_owner, repo_name, forge_id + ); + return Ok(()); + } + }; + + // Find the project linked to this repo. A repo without a project, or + // with a disabled project, is silently skipped. + let repo_uuid: Uuid = repo.id.clone().into(); + let project = self + .db + .find_project_by_repo(repo_uuid) + .await + .map_err(SchedulerError::Db)?; + + let project = match project { + Some(p) if p.enabled => p, + Some(_) => { + info!("Project for repo {} is disabled, ignoring", repo.id); + return Ok(()); + } + None => { + info!("No project found for repo {}, ignoring", repo.id); + return Ok(()); + } + }; + + // Determine the forge type (GitHub, Gitea, etc.) from the provider + // that received this webhook. Falls back to GitHub if not found + // (should not happen in practice). + let forge_type = self + .forges + .iter() + .find(|(id, _)| *id == forge_id) + .map(|(_, f)| f.forge_type()) + .unwrap_or(ForgeType::GitHub); + + // Create a new Job record in the database. The job starts in Pending + // state and is immediately transitioned to Evaluating below. + let project_uuid: Uuid = project.id.clone().into(); + let job = self + .db + .create_job( + project_uuid, + forge_type, + &repo_owner, + &repo_name, + &ref_name, + &commit_sha, + ) + .await + .map_err(SchedulerError::Db)?; + + info!( + "Created job {} for {}/{} ref={} sha={}", + job.id, repo_owner, repo_name, ref_name, commit_sha + ); + + let job_uuid: Uuid = job.id.into(); + + // Transition: Pending -> Evaluating + self.db + .update_job_status(job_uuid, JobStatus::Evaluating) + .await + .map_err(SchedulerError::Db)?; + + // Enqueue an evaluation task for an agent to pick up. The task payload + // contains everything the agent needs to clone the repo and evaluate + // the herculesCI flake attribute. + let eval_payload = serde_json::json!({ + "jobId": job_uuid.to_string(), + "projectName": project.name, + "refName": ref_name, + "commitSha": commit_sha, + "repoCloneUrl": repo.clone_url, + }); + + let _task_id = self + .db + .enqueue_task(job_uuid, TaskType::Evaluate, None, &eval_payload) + .await + .map_err(SchedulerError::Db)?; + + // Report "Pending" status to the forge so GitHub/Gitea PRs show that + // CI is in progress. This appears as a yellow dot / "pending" check. + self.report_commit_status( + forge_id, + &repo_owner, + &repo_name, + &commit_sha, + CommitStatus::Pending, + "Evaluation started", + ) + .await; + + Ok(()) + } + + // ── Evaluation ────────────────────────────────────────────────── + + /// Handle successful completion of a job's evaluation phase. + /// + /// After the agent finishes evaluating the `herculesCI` flake attribute, + /// this handler retrieves all derivation paths that were discovered + /// (stored earlier via `AttributeDiscovered` / `DerivationInfoReceived` + /// events) and creates build tasks for each one. + /// + /// ## Build deduplication + /// + /// Builds are deduplicated by derivation store path. If two jobs (or even + /// the same job via multiple attributes) reference the same `.drv`, only + /// one build record is created. Both jobs are linked to the shared build + /// via a join table (`link_build_to_job`). + /// + /// ## Platform routing + /// + /// Each build task carries an optional `platform` field (e.g., + /// `x86_64-linux`) derived from the derivation info stored during + /// evaluation. The task queue uses this to route the task to an agent + /// with a matching system architecture. + /// + /// ## State transition + /// + /// ```text + /// Evaluating --> Building (if derivations exist) + /// Evaluating --> RunningEffects (if no derivations, but effects exist) + /// Evaluating --> Succeeded (if no derivations and no effects) + /// ``` + async fn handle_evaluation_complete( + &self, + job_id: Uuid, + _task_id: Uuid, + ) -> Result<()> { + info!("Evaluation complete for job {}", job_id); + + // Retrieve all derivation store paths discovered during evaluation. + // These were persisted by handle_derivation_info as + // DerivationInfoReceived events arrived from the agent. + let drv_paths = self + .db + .get_derivation_paths_for_job(job_id) + .await + .map_err(SchedulerError::Db)?; + + if drv_paths.is_empty() { + info!( + "No derivations found for job {}, moving to effects check", + job_id + ); + return self.check_and_dispatch_effects(job_id).await; + } + + // Transition: Evaluating -> Building + self.db + .update_job_status(job_id, JobStatus::Building) + .await + .map_err(SchedulerError::Db)?; + + // Create build tasks for each derivation, deduplicated by store path. + // `create_or_get_build` returns (build_id, was_created) -- if the + // build already exists (e.g., shared with another job), we only link + // it without creating a duplicate task. + for drv_path in &drv_paths { + let (build_id, was_created) = self + .db + .create_or_get_build(drv_path) + .await + .map_err(SchedulerError::Db)?; + + // Link this build to the current job (many-to-many relationship). + self.db + .link_build_to_job(build_id, job_id) + .await + .map_err(SchedulerError::Db)?; + + // Only enqueue a build task if we just created the build record. + // If it already existed, it's either already building or already + // built, and the job will observe its final status later. + if was_created { + let build_payload = serde_json::json!({ + "buildId": build_id.to_string(), + "derivationPath": drv_path, + }); + + // Look up the platform from derivation info stored during + // evaluation. This determines which agent architecture can + // build this derivation. + let platform = self + .db + .get_derivation_platform(drv_path) + .await + .map_err(SchedulerError::Db)?; + + self.db + .enqueue_task( + job_id, + TaskType::Build, + platform.as_deref(), + &build_payload, + ) + .await + .map_err(SchedulerError::Db)?; + } + } + + Ok(()) + } + + /// Handle a failed evaluation. + /// + /// Transitions the job to `ErrorEvaluating` and reports `Failure` commit + /// status to the forge so the PR / commit page shows a red X. + /// + /// # State transition + /// + /// ```text + /// Evaluating --> ErrorEvaluating + /// ``` + async fn handle_evaluation_failed( + &self, + job_id: Uuid, + _task_id: Uuid, + err_msg: &str, + ) -> Result<()> { + warn!("Evaluation failed for job {}: {}", job_id, err_msg); + self.db + .update_job_status(job_id, JobStatus::ErrorEvaluating) + .await + .map_err(SchedulerError::Db)?; + + let job = self.db.get_job(job_id).await.map_err(SchedulerError::Db)?; + self.report_commit_status_for_job(&job, CommitStatus::Failure, "Evaluation failed") + .await; + + Ok(()) + } + + /// Persist an attribute discovered by the agent during evaluation. + /// + /// The agent walks the `herculesCI` flake attribute tree and sends an + /// `AttributeDiscovered` event for each node. These are stored in the + /// database and later used to determine which derivations need building + /// and which attributes are effects. + /// + /// This handler performs no state transitions -- it is purely a data + /// accumulation step during the `Evaluating` phase. + async fn handle_attribute_discovered( + &self, + job_id: Uuid, + path: &[String], + derivation_path: Option<&str>, + typ: AttributeType, + attr_error: Option<&str>, + ) -> Result<()> { + self.db + .store_attribute(job_id, path, derivation_path, typ, attr_error) + .await + .map_err(SchedulerError::Db)?; + Ok(()) + } + + /// Persist derivation metadata reported by the agent during evaluation. + /// + /// For each derivation discovered in the flake attribute tree, the agent + /// sends detailed metadata including: + /// + /// - **platform** -- the Nix system string (e.g., `x86_64-linux`), used + /// to route build tasks to the correct agent. + /// - **required_system_features** -- features the building machine must + /// have (e.g., `kvm` for NixOS VM tests). + /// - **input_derivations** -- immediate build-time dependencies. + /// - **outputs** -- expected output store paths. + /// + /// This is a data accumulation step; no state transitions occur. + async fn handle_derivation_info( + &self, + job_id: Uuid, + derivation_path: &str, + platform: &str, + required_system_features: &[String], + input_derivations: &[String], + outputs: &serde_json::Value, + ) -> Result<()> { + self.db + .store_derivation_info( + job_id, + derivation_path, + platform, + required_system_features, + input_derivations, + outputs, + ) + .await + .map_err(SchedulerError::Db)?; + Ok(()) + } + + // ── Builds ────────────────────────────────────────────────────── + + /// Handle completion of a single build. + /// + /// Updates the build record's status to `Succeeded` or `Failed`. The + /// caller (typically the external build-completion polling logic or the + /// `notify_builds_complete` method) is responsible for checking whether + /// all builds for a job are done and advancing the job to the effects + /// phase. + /// + /// On failure, the build is simply marked as `Failed`. Linked jobs will + /// see the failure when they query whether all their builds succeeded. + async fn handle_build_complete( + &self, + build_id: Uuid, + derivation_path: &str, + success: bool, + ) -> Result<()> { + info!( + "Build {} ({}) {}", + build_id, + derivation_path, + if success { "succeeded" } else { "failed" } + ); + + let status = if success { + BuildStatus::Succeeded + } else { + BuildStatus::Failed + }; + self.db + .update_build_status(build_id, status, None) + .await + .map_err(SchedulerError::Db)?; + + if !success { + // Build failed; linked jobs will observe the failure when they + // check whether all builds are complete. + return Ok(()); + } + + Ok(()) + } + + // ── Effects ───────────────────────────────────────────────────── + + /// Check whether all builds for a job are done and, if so, dispatch its + /// effects (or mark the job as succeeded if there are none). + /// + /// ## Effect serialization across jobs + /// + /// Effects within a single job are dispatched **concurrently** -- all + /// pending effects are enqueued at once. However, effects across + /// different jobs on the **same project + ref** are **serialized**: a + /// job's effects will not start until all preceding jobs' effects on + /// that ref have completed. This is tracked via the `sequence_number` + /// field on the job, which increases monotonically per `(project, ref)` + /// pair. + /// + /// This serialization prevents ordering hazards. For example, if two + /// pushes to `main` trigger jobs A and B (where A was pushed first), + /// job B's deploy effect will not run until job A's deploy effect has + /// finished, even if job B's builds complete first. + /// + /// ## State transitions + /// + /// ```text + /// Building / Evaluating --> Succeeded (no effects) + /// Building / Evaluating --> RunningEffects (has effects, predecessors done) + /// Building / Evaluating --> (unchanged) (has effects, predecessors pending) + /// ``` + async fn check_and_dispatch_effects(&self, job_id: Uuid) -> Result<()> { + let job = self.db.get_job(job_id).await.map_err(SchedulerError::Db)?; + + let effects = self + .db + .get_effects_for_job(job_id) + .await + .map_err(SchedulerError::Db)?; + + if effects.is_empty() { + // No effects to run -- the job is complete. + info!("Job {} complete (no effects)", job_id); + self.db + .update_job_status(job_id, JobStatus::Succeeded) + .await + .map_err(SchedulerError::Db)?; + self.report_commit_status_for_job( + &job, + CommitStatus::Success, + "All tasks succeeded", + ) + .await; + return Ok(()); + } + + // Enforce cross-job effect serialization: only dispatch effects if + // all jobs with a lower sequence_number on the same (project, ref) + // have finished their effects. + let preceding_done = self + .db + .are_preceding_effects_done(job.project_id.clone().into(), &job.ref_name, job.sequence_number as i64) + .await + .map_err(SchedulerError::Db)?; + + if !preceding_done { + info!( + "Job {} waiting for preceding effects to complete", + job_id + ); + return Ok(()); + } + + // Transition: Building / Evaluating -> RunningEffects + self.db + .update_job_status(job_id, JobStatus::RunningEffects) + .await + .map_err(SchedulerError::Db)?; + + // Dispatch all pending effects concurrently by enqueuing a task for + // each one. Effects that have already been dispatched (e.g., from a + // previous partial run) are skipped. + for effect in &effects { + if effect.status == EffectStatus::Pending { + let effect_uuid: Uuid = effect.id.clone().into(); + let effect_payload = serde_json::json!({ + "effectId": effect_uuid.to_string(), + "jobId": job_id.to_string(), + "derivationPath": effect.derivation_path, + "attributePath": effect.attribute_path, + }); + + self.db + .enqueue_task(job_id, TaskType::Effect, None, &effect_payload) + .await + .map_err(SchedulerError::Db)?; + + self.db + .update_effect_status(effect_uuid, EffectStatus::Running) + .await + .map_err(SchedulerError::Db)?; + } + } + + Ok(()) + } + + /// Handle completion of a single effect. + /// + /// On **success**, checks whether all effects for the job are now done. + /// If so, the job transitions to `Succeeded` and a success commit status + /// is reported to the forge. + /// + /// On **failure**, the job transitions immediately to `Failed` and a + /// failure commit status is reported. Other in-flight effects for the + /// same job are not cancelled (they will complete but their results are + /// moot). + /// + /// # State transitions + /// + /// ```text + /// RunningEffects --> Failed (on effect failure) + /// RunningEffects --> Succeeded (when all effects succeed) + /// ``` + async fn handle_effect_complete( + &self, + effect_id: Uuid, + job_id: Uuid, + success: bool, + ) -> Result<()> { + info!( + "Effect {} for job {} {}", + effect_id, + job_id, + if success { "succeeded" } else { "failed" } + ); + + let status = if success { + EffectStatus::Succeeded + } else { + EffectStatus::Failed + }; + self.db + .update_effect_status(effect_id, status) + .await + .map_err(SchedulerError::Db)?; + + if !success { + // Fail-fast: one failed effect fails the entire job. + self.db + .update_job_status(job_id, JobStatus::Failed) + .await + .map_err(SchedulerError::Db)?; + let job = self.db.get_job(job_id).await.map_err(SchedulerError::Db)?; + self.report_commit_status_for_job(&job, CommitStatus::Failure, "Effect failed") + .await; + return Ok(()); + } + + // Check if all effects for this job have now completed successfully. + let all_done = self + .db + .are_all_effects_complete(job_id) + .await + .map_err(SchedulerError::Db)?; + + if all_done { + info!("All effects complete for job {}", job_id); + self.db + .update_job_status(job_id, JobStatus::Succeeded) + .await + .map_err(SchedulerError::Db)?; + let job = self.db.get_job(job_id).await.map_err(SchedulerError::Db)?; + self.report_commit_status_for_job( + &job, + CommitStatus::Success, + "All tasks succeeded", + ) + .await; + } + + Ok(()) + } + + // ── Agent lifecycle ───────────────────────────────────────────── + + /// Handle an agent disconnection by re-queuing its in-flight tasks. + /// + /// When an agent's WebSocket connection drops (crash, network partition, + /// graceful shutdown), all tasks that were assigned to its session are + /// returned to `Pending` state. This allows another connected agent with + /// a compatible platform to pick them up. + /// + /// This is critical for resilience: without re-queuing, an agent crash + /// would leave tasks stuck in `Running` state forever. + /// + /// Note: each WebSocket connection is assigned a unique `agent_session_id`, + /// so if an agent reconnects, it gets a new session and its old tasks are + /// already re-queued. + async fn handle_agent_disconnect( + &self, + agent_session_id: Uuid, + ) -> Result<()> { + info!( + "Agent {} disconnected, re-queuing tasks", + agent_session_id + ); + let requeued = self + .db + .requeue_agent_tasks(agent_session_id) + .await + .map_err(SchedulerError::Db)?; + info!( + "Re-queued {} tasks from agent {}", + requeued.len(), + agent_session_id + ); + Ok(()) + } + + // ── Job management ────────────────────────────────────────────── + + /// Handle a user-initiated re-run of a job. + /// + /// Resets the job to the `Evaluating` state and enqueues a fresh + /// evaluation task. Previous build and effect records are **not** deleted; + /// they remain as an audit trail of the earlier attempt. + /// + /// # State transition + /// + /// ```text + /// (any terminal state) --> Evaluating + /// ``` + async fn handle_rerun_job(&self, job_id: Uuid) -> Result<()> { + let job = self.db.get_job(job_id).await.map_err(SchedulerError::Db)?; + info!("Re-running job {}", job_id); + + // Reset: (current state) -> Evaluating + self.db + .update_job_status(job_id, JobStatus::Evaluating) + .await + .map_err(SchedulerError::Db)?; + + // Look up the project and repo to reconstruct the evaluation payload. + let project = self + .db + .get_project(job.project_id.clone().into()) + .await + .map_err(SchedulerError::Db)?; + let repo = self + .db + .get_repo(project.repo_id.clone().into()) + .await + .map_err(SchedulerError::Db)?; + + let eval_payload = serde_json::json!({ + "jobId": job_id.to_string(), + "projectName": project.name, + "refName": job.ref_name, + "commitSha": job.commit_sha, + "repoCloneUrl": repo.clone_url, + }); + + self.db + .enqueue_task(job_id, TaskType::Evaluate, None, &eval_payload) + .await + .map_err(SchedulerError::Db)?; + + Ok(()) + } + + /// Handle user-initiated job cancellation. + /// + /// Transitions the job to `Cancelled`. In-flight tasks (evaluation, + /// builds, effects) assigned to agents are **not** actively killed; they + /// will complete on the agent side, but their results will be ignored + /// since the job is no longer in an active state. + /// + /// # State transition + /// + /// ```text + /// (any state) --> Cancelled + /// ``` + async fn handle_cancel_job(&self, job_id: Uuid) -> Result<()> { + info!("Cancelling job {}", job_id); + self.db + .update_job_status(job_id, JobStatus::Cancelled) + .await + .map_err(SchedulerError::Db)?; + Ok(()) + } + + /// Handle a user-initiated retry of a failed build. + /// + /// Resets the build status to `Pending`, which causes it to re-enter the + /// task queue and be picked up by an agent. The build's previous log and + /// output data may be overwritten by the new attempt. + async fn handle_retry_build(&self, build_id: Uuid) -> Result<()> { + info!("Retrying build {}", build_id); + self.db + .update_build_status(build_id, BuildStatus::Pending, None) + .await + .map_err(SchedulerError::Db)?; + Ok(()) + } + + /// Handle user-initiated build cancellation. + /// + /// Transitions the build to `Cancelled`. If the build was shared across + /// multiple jobs, all linked jobs will observe the cancellation. + async fn handle_cancel_build(&self, build_id: Uuid) -> Result<()> { + info!("Cancelling build {}", build_id); + self.db + .update_build_status(build_id, BuildStatus::Cancelled, None) + .await + .map_err(SchedulerError::Db)?; + Ok(()) + } + + /// Handle user-initiated effect cancellation. + /// + /// Transitions the effect to `Cancelled`. The owning job is **not** + /// automatically failed or cancelled; the caller should separately cancel + /// the job if desired. + async fn handle_cancel_effect(&self, effect_id: Uuid, job_id: Uuid) -> Result<()> { + info!("Cancelling effect {} for job {}", effect_id, job_id); + self.db + .update_effect_status(effect_id, EffectStatus::Cancelled) + .await + .map_err(SchedulerError::Db)?; + Ok(()) + } + + // ── Commit status helpers ─────────────────────────────────────── + + /// Report a commit status check to a forge (GitHub, Gitea, etc.). + /// + /// This is how Jupiter feeds CI results back to the forge so that pull + /// requests and commit pages show status indicators (pending, success, + /// failure). The status is reported under the `jupiter-ci` context name. + /// + /// Failures to report status are logged as warnings but do **not** + /// propagate as errors -- commit status reporting is best-effort and + /// should never block the pipeline. + async fn report_commit_status( + &self, + forge_id: Uuid, + repo_owner: &str, + repo_name: &str, + commit_sha: &str, + status: CommitStatus, + description: &str, + ) { + if let Some((_, forge)) = self.forges.iter().find(|(id, _)| *id == forge_id) { + let update = CommitStatusUpdate { + context: "jupiter-ci".to_string(), + status, + target_url: None, + description: Some(description.to_string()), + }; + if let Err(e) = forge + .set_commit_status(repo_owner, repo_name, commit_sha, &update) + .await + { + warn!("Failed to report commit status: {}", e); + } + } + } + + /// Convenience wrapper around [`report_commit_status`](Self::report_commit_status) + /// that extracts the forge provider from a [`Job`]'s `forge_type` field. + /// + /// Looks up the forge provider by matching `forge_type` (e.g., `GitHub`, + /// `Gitea`) rather than by `forge_id`. This is useful when the forge ID + /// is not readily available (e.g., in the effects completion handler + /// where only the job record is at hand). + async fn report_commit_status_for_job( + &self, + job: &Job, + status: CommitStatus, + description: &str, + ) { + if let Some((forge_id, _)) = + self.forges.iter().find(|(_, f)| f.forge_type() == job.forge_type) + { + self.report_commit_status( + *forge_id, + &job.repo_owner, + &job.repo_name, + &job.commit_sha, + status, + description, + ) + .await; + } + } + + // ── Public helpers ────────────────────────────────────────────── + + /// Notify the scheduler that all builds for a job have completed, + /// triggering the transition to the effects phase. + /// + /// This is intended to be called by external code (e.g., a periodic + /// build-status polling task) that detects when every build linked to a + /// job has reached a terminal state. It delegates to + /// [`check_and_dispatch_effects`](Self::check_and_dispatch_effects), + /// which handles the `Building -> RunningEffects` or + /// `Building -> Succeeded` transition. + pub async fn notify_builds_complete(&self, job_id: Uuid) -> Result<()> { + self.check_and_dispatch_effects(job_id).await + } +} diff --git a/crates/jupiter-scheduler/src/error.rs b/crates/jupiter-scheduler/src/error.rs new file mode 100644 index 0000000..d795c0a --- /dev/null +++ b/crates/jupiter-scheduler/src/error.rs @@ -0,0 +1,68 @@ +//! Error types for the Jupiter scheduler. +//! +//! Every fallible scheduler operation returns [`Result`], which uses +//! [`SchedulerError`] as its error type. Errors originate from three main +//! sources: +//! +//! - **Database layer** (`jupiter_db`) -- query failures, constraint violations, +//! connection issues. +//! - **Forge layer** (`jupiter_forge`) -- failures when reporting commit status +//! back to GitHub, Gitea, Radicle, etc. +//! - **Scheduler logic** -- invalid state transitions, missing jobs, or missing +//! agents for a required platform. + +use thiserror::Error; + +/// Errors that can occur during scheduler operations. +/// +/// The scheduler is intentionally lenient: most errors are logged and do **not** +/// crash the event loop (see [`crate::engine::SchedulerEngine::run`]). Individual +/// event handlers return `Result<()>` so the loop can log the failure and +/// continue processing the next event. +#[derive(Debug, Error)] +pub enum SchedulerError { + /// A database operation failed. This wraps errors from the `jupiter_db` + /// crate and can indicate connection failures, SQL constraint violations, + /// or missing rows. + #[error("database error: {0}")] + Db(#[from] jupiter_db::error::DbError), + + /// A forge API call failed. This typically occurs when reporting commit + /// status back to the forge (GitHub, Gitea, etc.) and could be caused by + /// network issues, expired tokens, or rate limiting. + #[error("forge error: {0}")] + Forge(#[from] jupiter_forge::error::ForgeError), + + /// No connected agent can serve the requested platform (e.g., + /// `x86_64-linux`, `aarch64-darwin`). The task remains in the queue and + /// will be picked up when a suitable agent connects. + #[error("no agent available for platform: {0}")] + NoAgentAvailable(String), + + /// A job with the given UUID was not found in the database. This can + /// happen if a job is deleted while events referencing it are still + /// in-flight in the scheduler channel. + #[error("job not found: {0}")] + JobNotFound(uuid::Uuid), + + /// An attempted job state transition is not valid. For example, trying to + /// move a `Succeeded` job to `Evaluating` without going through a re-run + /// reset. The `from` and `to` fields contain human-readable state names. + /// + /// Valid transitions are documented in [`crate::engine::SchedulerEngine`]. + #[error("invalid state transition: {from} -> {to}")] + InvalidTransition { + /// The current state of the job at the time of the transition attempt. + from: String, + /// The target state that was rejected. + to: String, + }, + + /// A catch-all for internal scheduler errors that do not fit the other + /// variants. The contained string provides a human-readable description. + #[error("scheduler error: {0}")] + Internal(String), +} + +/// Convenience alias used throughout the scheduler crate. +pub type Result = std::result::Result; diff --git a/crates/jupiter-scheduler/src/lib.rs b/crates/jupiter-scheduler/src/lib.rs new file mode 100644 index 0000000..35c4348 --- /dev/null +++ b/crates/jupiter-scheduler/src/lib.rs @@ -0,0 +1,65 @@ +//! # Jupiter Scheduler +//! +//! The scheduler is the central orchestration engine of **Jupiter**, a self-hosted, +//! wire-compatible replacement for [hercules-ci.com](https://hercules-ci.com). +//! +//! ## Role in the Jupiter architecture +//! +//! Jupiter follows the Hercules CI model where a server coordinates work that is +//! executed by remote *agents*. The scheduler is the "brain" that drives every job +//! through the Hercules CI pipeline: +//! +//! ```text +//! ForgeEvent --> Job creation --> Evaluation --> Build --> Effects --> Done +//! ``` +//! +//! It runs as a long-lived background **tokio task** (see [`engine::SchedulerEngine::run`]) +//! and receives [`engine::SchedulerEvent`]s over a bounded `mpsc` channel from: +//! +//! - **Webhook handlers** -- forge push / PR / patch events. +//! - **WebSocket handler** -- messages from connected Hercules CI agents reporting +//! evaluation results, build completions, effect outcomes, etc. +//! - **REST endpoints** -- user-initiated actions such as re-running or cancelling +//! a job. +//! +//! ## Pipeline state machine +//! +//! Each **Job** progresses through the following states (see also +//! [`engine::SchedulerEngine`] for transition logic): +//! +//! ```text +//! ┌──────────┐ ┌────────────┐ ┌──────────┐ ┌────────────────┐ ┌───────────┐ +//! │ Pending │───>│ Evaluating │───>│ Building │───>│ RunningEffects │───>│ Succeeded │ +//! └──────────┘ └────────────┘ └──────────┘ └────────────────┘ └───────────┘ +//! │ │ │ +//! v v v +//! ┌──────────────┐ ┌──────────┐ ┌──────────┐ +//! │ErrorEvaluating│ │ Failed │ │ Failed │ +//! └──────────────┘ └──────────┘ └──────────┘ +//! ``` +//! +//! Any state can also transition to `Cancelled` via user action. +//! +//! ## Concurrency model +//! +//! - **Effects within a single job** run concurrently -- they are all enqueued at +//! once when the job enters `RunningEffects`. +//! - **Effects across jobs on the same project + ref** are serialized via a +//! `sequence_number` to prevent ordering hazards (e.g., two pushes deploying +//! out of order). +//! - **Builds are deduplicated** by derivation path: if two jobs need the same +//! `.drv`, only one `Build` record is created and linked to both jobs. +//! - **Agent disconnection** causes all in-flight tasks assigned to that agent to +//! be returned to `Pending` state so another agent can pick them up. +//! - **IFD (import-from-derivation)** requires at least 2 concurrent task slots on +//! `x86_64-linux` agents to avoid deadlock (the evaluating agent must be able to +//! build the IFD derivation while still running the evaluation). +//! +//! ## Crate layout +//! +//! - [`engine`] -- The [`SchedulerEngine`](engine::SchedulerEngine) struct and the +//! [`SchedulerEvent`](engine::SchedulerEvent) enum that drives it. +//! - [`error`] -- Error types returned by scheduler operations. + +pub mod engine; +pub mod error; diff --git a/crates/jupiter-server/Cargo.toml b/crates/jupiter-server/Cargo.toml new file mode 100644 index 0000000..ee317bf --- /dev/null +++ b/crates/jupiter-server/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "jupiter-server" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "jupiter-server" +path = "src/main.rs" + +[dependencies] +jupiter-api-types = { workspace = true } +jupiter-db = { workspace = true } +jupiter-forge = { workspace = true } +jupiter-scheduler = { workspace = true } +jupiter-cache = { workspace = true } +axum = { workspace = true } +axum-extra = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +jsonwebtoken = { workspace = true } +bcrypt = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +futures = { workspace = true } diff --git a/crates/jupiter-server/src/auth.rs b/crates/jupiter-server/src/auth.rs new file mode 100644 index 0000000..413c129 --- /dev/null +++ b/crates/jupiter-server/src/auth.rs @@ -0,0 +1,208 @@ +//! # Authentication and authorization +//! +//! Jupiter uses JWT (JSON Web Tokens) for all authentication. Tokens are signed +//! with an HMAC secret (`jwtPrivateKey` from the server config) and carry a +//! [`TokenScope`] that determines what the bearer is allowed to do. +//! +//! ## Token scopes +//! +//! | Scope | Issued to | Typical lifetime | Purpose | +//! |----------|--------------------|------------------|--------------------------------------------------| +//! | `User` | `hci` CLI / Web UI | 24 hours | Full read/write access to the API | +//! | `Agent` | hercules-ci-agent | Long-lived | Cluster join, task polling, result reporting | +//! | `Effect` | Running effects | 1 hour | Scoped access to state files during effect exec | +//! +//! Effect tokens are the most restrictive: they embed the `project_id` (as `sub`) +//! and the `attribute_path` so that an effect can only read/write state files +//! belonging to its own project. This prevents cross-project data leaks when +//! effects run untrusted Nix code. +//! +//! ## Wire format +//! +//! Tokens are passed in the `Authorization: Bearer ` header. The +//! [`BearerToken`] extractor handles parsing this header for axum handlers. + +use axum::{ + extract::FromRequestParts, + http::{request::Parts, StatusCode}, +}; +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; + +/// JWT claims payload embedded in every Jupiter token. +/// +/// - `sub`: the subject -- a username for User tokens, an account ID for Agent +/// tokens, or a project ID for Effect tokens. +/// - `exp` / `iat`: standard JWT expiration and issued-at timestamps (Unix epoch seconds). +/// - `scope`: the [`TokenScope`] determining what this token authorizes. +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + /// Subject identifier. Interpretation depends on the scope. + pub sub: String, + /// Expiration time as a Unix timestamp (seconds since epoch). + pub exp: i64, + /// Issued-at time as a Unix timestamp. + pub iat: i64, + /// The authorization scope of this token. + pub scope: TokenScope, +} + +/// Defines the authorization level of a JWT token. +/// +/// Each variant corresponds to a different actor in the system and determines +/// which API endpoints the token grants access to. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TokenScope { + /// Full API access for human users authenticated via the `hci` CLI or web UI. + /// The token's `sub` field contains the username. + User, + /// Access for `hercules-ci-agent` instances that have joined a cluster. + /// The token's `sub` field contains the account ID. + Agent, + /// Scoped access for Nix effects during execution. Effects can only access + /// state files for the project identified by the token's `sub` field, and + /// only for the specific attribute path encoded here. + Effect { + /// The job ID that spawned this effect, used for audit trailing. + job_id: String, + /// The Nix attribute path (e.g. `["effects", "deploy"]`) that this + /// effect is executing. Limits state file access to this path. + attribute_path: Vec, + }, +} + +/// Create a signed JWT token with the given subject, scope, and validity duration. +/// +/// The token is signed using HMAC-SHA256 with the provided secret. It can be +/// verified later with [`verify_jwt`] using the same secret. +/// +/// # Arguments +/// +/// * `secret` -- the HMAC signing key (from `ServerConfig::jwt_private_key`). +/// * `subject` -- the `sub` claim value (username, account ID, or project ID). +/// * `scope` -- the authorization scope to embed in the token. +/// * `duration_hours` -- how many hours until the token expires. +/// +/// # Errors +/// +/// Returns a `jsonwebtoken` error if encoding fails (should not happen with +/// valid inputs). +pub fn create_jwt( + secret: &str, + subject: &str, + scope: TokenScope, + duration_hours: i64, +) -> Result { + let now = Utc::now(); + let claims = Claims { + sub: subject.to_string(), + exp: (now + Duration::hours(duration_hours)).timestamp(), + iat: now.timestamp(), + scope, + }; + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) +} + +/// Verify and decode a JWT token, returning the embedded claims. +/// +/// Validates the signature against the provided secret and checks that the +/// token has not expired. On success, returns the deserialized [`Claims`]. +/// +/// # Errors +/// +/// Returns an error if the signature is invalid, the token is expired, or +/// the claims cannot be deserialized. +pub fn verify_jwt( + secret: &str, + token: &str, +) -> Result { + let data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::default(), + )?; + Ok(data.claims) +} + +/// Verify an effect-scoped JWT and extract the project ID, job ID, and +/// attribute path. +/// +/// This is used by the `/current-task/state` endpoints to determine which +/// project's state files the caller is authorized to access. The effect +/// token's `sub` field contains the project ID, while the scope payload +/// carries the job ID and attribute path. +/// +/// # Returns +/// +/// A tuple of `(project_id, job_id, attribute_path)` on success. +/// +/// # Errors +/// +/// Returns an error if the token is invalid, expired, or does not have +/// the `Effect` scope. +pub fn parse_effect_token( + secret: &str, + token: &str, +) -> Result<(String, String, Vec), jsonwebtoken::errors::Error> { + let claims = verify_jwt(secret, token)?; + match claims.scope { + TokenScope::Effect { + job_id, + attribute_path, + } => Ok((claims.sub, job_id, attribute_path)), + _ => Err(jsonwebtoken::errors::Error::from( + jsonwebtoken::errors::ErrorKind::InvalidToken, + )), + } +} + +/// Axum extractor that pulls the bearer token string from the `Authorization` +/// header. +/// +/// Expects the header value to be in the form `Bearer `. Returns +/// `401 Unauthorized` if the header is missing or does not have the `Bearer ` +/// prefix. +/// +/// # Example +/// +/// ```ignore +/// async fn protected_handler(BearerToken(token): BearerToken) { +/// // `token` is the raw JWT string +/// } +/// ``` +#[allow(dead_code)] +pub struct BearerToken(pub String); + +impl FromRequestParts for BearerToken +where + S: Send + Sync, +{ + type Rejection = StatusCode; + + /// Extract the bearer token from the request's `Authorization` header. + /// + /// Looks for a header value starting with `"Bearer "` and strips the + /// prefix to yield the raw token string. Returns `401 Unauthorized` + /// if the header is absent or malformed. + async fn from_request_parts( + parts: &mut Parts, + _state: &S, + ) -> Result { + let auth_header = parts + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if let Some(token) = auth_header.strip_prefix("Bearer ") { + Ok(BearerToken(token.to_string())) + } else { + Err(StatusCode::UNAUTHORIZED) + } + } +} diff --git a/crates/jupiter-server/src/config.rs b/crates/jupiter-server/src/config.rs new file mode 100644 index 0000000..292af9c --- /dev/null +++ b/crates/jupiter-server/src/config.rs @@ -0,0 +1,58 @@ +//! # Server configuration loading +//! +//! Jupiter's configuration is stored as TOML and deserialized into the +//! [`ServerConfig`] type defined in the `jupiter-api-types` crate. The config +//! controls: +//! +//! - **`listen`** -- the socket address the HTTP server binds to (e.g. `"0.0.0.0:3000"`). +//! - **`baseUrl`** -- the externally-visible URL, used when generating callback +//! URLs for forge webhooks and effect tokens. +//! - **`jwtPrivateKey`** -- the HMAC secret used to sign and verify JWT tokens +//! for all three scopes (User, Agent, Effect). +//! - **`[database]`** -- either a SQLite `path` or a full PostgreSQL `url`. +//! The server code is generic over [`StorageBackend`] so both backends work +//! with the same handlers. +//! - **`[[forges]]`** -- an array of forge configurations (GitHub, Gitea) with +//! webhook secrets and API tokens. +//! +//! If the config file does not exist on disk, a sensible development default is +//! used so that `cargo run` works out of the box with no external setup. + +use anyhow::Result; +use jupiter_api_types::ServerConfig; + +/// Load the server configuration from the given TOML file path. +/// +/// If the file cannot be read (e.g. it does not exist), the built-in +/// [`default_config`] string is used instead. This makes first-run +/// setup frictionless for development. +/// +/// # Errors +/// +/// Returns an error if the TOML content (whether from disk or the default +/// string) cannot be deserialized into [`ServerConfig`]. +pub fn load_config(path: &str) -> Result { + let content = std::fs::read_to_string(path).unwrap_or_else(|_| default_config()); + let config: ServerConfig = toml::from_str(&content)?; + Ok(config) +} + +/// Returns a minimal TOML configuration suitable for local development. +/// +/// This default listens on all interfaces at port 3000, uses an insecure +/// JWT secret (`"jupiter-dev-secret"`), and stores data in a local SQLite +/// file (`jupiter.db`). No forges are configured, so webhook-driven jobs +/// will not be created until the operator adds a `[[forges]]` section. +fn default_config() -> String { + r#" +listen = "0.0.0.0:3000" +baseUrl = "http://localhost:3000" +jwtPrivateKey = "jupiter-dev-secret" +forges = [] + +[database] +type = "sqlite" +path = "jupiter.db" +"# + .to_string() +} diff --git a/crates/jupiter-server/src/main.rs b/crates/jupiter-server/src/main.rs new file mode 100644 index 0000000..7085dd2 --- /dev/null +++ b/crates/jupiter-server/src/main.rs @@ -0,0 +1,115 @@ +//! # Jupiter Server -- main entry point +//! +//! Jupiter is a self-hosted, wire-compatible replacement for hercules-ci.com. +//! This binary is the central server that coordinates the entire CI pipeline: +//! +//! 1. **Agents** connect over WebSocket (`/api/v1/agent/socket`) and receive +//! evaluation, build, and effect tasks dispatched by the scheduler. +//! 2. **The `hci` CLI and web UI** interact through the REST API for browsing +//! projects, jobs, builds, effects, and managing state files. +//! 3. **Forge webhooks** (GitHub, Gitea) trigger the scheduler to create new +//! jobs when commits are pushed or pull requests are opened. +//! +//! ## Startup sequence +//! +//! 1. Initialize `tracing` with the `RUST_LOG` env filter (defaults to `jupiter=info`). +//! 2. Load the TOML configuration from the path given as the first CLI argument, +//! falling back to `jupiter.toml` in the working directory, or a built-in +//! default if the file does not exist. +//! 3. Open (or create) the SQLite database and run migrations. +//! 4. Construct [`AppState`] which bundles the config, database handle, +//! scheduler channel, agent hub, and forge providers. +//! 5. Spawn the [`SchedulerEngine`] on a background tokio task. The engine +//! owns the receiving half of the `mpsc` channel; all other components +//! communicate with it by sending [`SchedulerEvent`]s. +//! 6. Build the axum [`Router`] with all REST and WebSocket routes. +//! 7. Bind a TCP listener and start serving. +//! +//! ## Architecture overview +//! +//! ```text +//! Forge webhook ──> /webhooks/github ──> SchedulerEvent::ForgeEvent +//! │ +//! v +//! CLI / UI ──> REST API SchedulerEngine +//! │ +//! dispatches tasks via AgentHub +//! │ +//! v +//! hercules-ci-agent <── WebSocket <── AgentSessionInfo.tx +//! ``` + +use std::sync::Arc; +use anyhow::Result; +use tracing::info; +use tracing_subscriber::EnvFilter; + +mod config; +mod state; +mod auth; +mod websocket; +mod routes; + +use jupiter_db::backend::StorageBackend; +use jupiter_db::sqlite::SqliteBackend; + +/// Async entry point powered by the tokio multi-threaded runtime. +/// +/// This function orchestrates the full server lifecycle: config loading, +/// database initialization, scheduler startup, router construction, and +/// TCP listener binding. It returns `Ok(())` only when the server shuts +/// down cleanly; any fatal error during startup propagates as `anyhow::Error`. +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::from_default_env() + .add_directive("jupiter=info".parse()?), + ) + .init(); + + // Load config + let config_path = std::env::args() + .nth(1) + .unwrap_or_else(|| "jupiter.toml".to_string()); + let config = config::load_config(&config_path)?; + let listen_addr = config.listen.clone(); + + // Initialize database + // If a `path` is set in [database], use SQLite with that file. + // Otherwise fall back to the full `url` field, or a default SQLite file. + let db_url = match &config.database.path { + Some(path) => format!("sqlite:{}?mode=rwc", path), + None => config + .database + .url + .clone() + .unwrap_or_else(|| "sqlite:jupiter.db?mode=rwc".to_string()), + }; + let db = SqliteBackend::new(&db_url).await?; + db.run_migrations().await?; + info!("Database initialized"); + + // Build app state -- this also creates the SchedulerEngine internally + let mut app_state = state::AppState::new(config, Arc::new(db)); + + // Take ownership of the scheduler out of AppState and run it on a + // dedicated background task. AppState clones (used by handler closures) + // will have `scheduler: None` since Clone skips it. + let scheduler = app_state.take_scheduler(); + tokio::spawn(async move { + if let Some(s) = scheduler { + s.run().await; + } + }); + + // Build the axum router with all routes and shared state + let app = routes::build_router(app_state); + + // Start the HTTP/WebSocket server + let listener = tokio::net::TcpListener::bind(&listen_addr).await?; + info!("Jupiter server listening on {}", listen_addr); + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/crates/jupiter-server/src/routes/agents.rs b/crates/jupiter-server/src/routes/agents.rs new file mode 100644 index 0000000..a77e182 --- /dev/null +++ b/crates/jupiter-server/src/routes/agents.rs @@ -0,0 +1,258 @@ +//! # Agent and account management endpoints +//! +//! This module provides REST endpoints for managing agent sessions, accounts, +//! and cluster join tokens. These endpoints are used by: +//! +//! - **The web UI and `hci` CLI** to list connected agents and manage accounts. +//! - **The `hercules-ci-agent`** to query the service info during initial setup. +//! - **Administrators** to create/revoke cluster join tokens that authorize +//! new agents to connect. +//! +//! ## Cluster join tokens +//! +//! Agents authenticate by presenting a cluster join token during the WebSocket +//! handshake. These tokens are generated as random UUIDs, bcrypt-hashed before +//! storage in the database, and returned in plaintext only once (at creation +//! time). The administrator distributes the token to the agent's configuration +//! file. On connection, the server verifies the token against the stored hash. +//! +//! ## Account model +//! +//! Accounts are the top-level organizational unit in Jupiter (matching the +//! Hercules CI account concept). Each account can have multiple agents, +//! projects, and cluster join tokens. Accounts are currently simple name-based +//! entities with a `User` type. + +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::{json, Value}; +use std::sync::Arc; +use uuid::Uuid; + +use jupiter_api_types::AccountType; +use jupiter_db::backend::StorageBackend; + +use crate::state::AppState; + +/// Handle `GET /api/v1/agent/service-info` -- return protocol version info. +/// +/// This lightweight endpoint is called by agents during initial setup to +/// discover the server's protocol version. The response mirrors the +/// `ServiceInfo` OOB frame sent during the WebSocket handshake, allowing +/// agents to verify compatibility before establishing a full connection. +/// +/// Returns `{"version": [2, 0]}` indicating protocol version 2.0. +pub async fn service_info() -> Json { + Json(json!({ + "version": [2, 0] + })) +} + +/// Handle `GET /api/v1/agents` -- list all currently active agent sessions. +/// +/// Returns a JSON array of all agent sessions stored in the database. Each +/// session includes the agent's hostname, supported platforms, and connection +/// metadata. Used by the web UI dashboard to show connected agents. +pub async fn list_agents( + State(state): State>>, +) -> impl IntoResponse { + match state.db.list_agent_sessions().await { + Ok(sessions) => Json(json!(sessions)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `GET /api/v1/agents/{id}` -- get a specific agent session by UUID. +/// +/// Returns the full session record for a single agent, or 404 if no session +/// exists with the given ID. The session may have been cleaned up if the +/// agent disconnected. +pub async fn get_agent( + State(state): State>>, + Path(id): Path, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + match state.db.get_agent_session(uuid).await { + Ok(session) => Json(json!(session)).into_response(), + Err(jupiter_db::error::DbError::NotFound { .. }) => StatusCode::NOT_FOUND.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `POST /api/v1/accounts/{account_id}/clusterJoinTokens` -- create a +/// new cluster join token for the given account. +/// +/// Generates a random UUID token, bcrypt-hashes it, and stores the hash in the +/// database. The plaintext token is returned in the response body and must be +/// saved by the administrator -- it cannot be recovered later since only the +/// hash is stored. +/// +/// ## Request body +/// +/// ```json +/// { "name": "my-agent-token" } +/// ``` +/// +/// ## Response (201 Created) +/// +/// ```json +/// { "id": "", "token": "" } +/// ``` +pub async fn create_join_token( + State(state): State>>, + Path(account_id): Path, + Json(body): Json, +) -> impl IntoResponse { + let account_uuid = match Uuid::parse_str(&account_id) { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "invalid UUID"})), + ) + .into_response() + } + }; + let name = body + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("default"); + + // Generate a random token and bcrypt-hash it for secure storage. + // The plaintext is returned only once in the response. + let token = Uuid::new_v4().to_string(); + let token_hash = bcrypt::hash(&token, bcrypt::DEFAULT_COST).unwrap_or_default(); + + match state + .db + .create_cluster_join_token(account_uuid, name, &token_hash) + .await + { + Ok(cjt) => { + let resp = json!({ + "id": cjt.id, + "token": token, + }); + (StatusCode::CREATED, Json(resp)).into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +/// Handle `GET /api/v1/accounts/{account_id}/clusterJoinTokens` -- list all +/// join tokens for an account. +/// +/// Returns metadata about each token (ID, name, creation date) but NOT the +/// plaintext token or hash, since those are sensitive. +pub async fn list_join_tokens( + State(state): State>>, + Path(account_id): Path, +) -> impl IntoResponse { + let account_uuid = match Uuid::parse_str(&account_id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + match state.db.list_cluster_join_tokens(account_uuid).await { + Ok(tokens) => Json(json!(tokens)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `DELETE /api/v1/accounts/{account_id}/clusterJoinTokens/{token_id}` +/// -- revoke a cluster join token. +/// +/// After deletion, any agent using this token will be unable to re-authenticate +/// on its next connection attempt. Existing connections are not forcibly closed. +/// Returns 204 No Content on success. +pub async fn delete_join_token( + State(state): State>>, + Path((_, token_id)): Path<(String, String)>, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&token_id) { + Ok(u) => u, + Err(_) => return StatusCode::BAD_REQUEST.into_response(), + }; + match state.db.delete_cluster_join_token(uuid).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `POST /api/v1/accounts` -- create a new account. +/// +/// Creates an account with the given name and `User` type. Accounts are the +/// top-level organizational unit that owns projects, agents, and join tokens. +/// +/// ## Request body +/// +/// ```json +/// { "name": "my-org" } +/// ``` +/// +/// ## Response (201 Created) +/// +/// The full account record including the generated UUID. +pub async fn create_account( + State(state): State>>, + Json(body): Json, +) -> impl IntoResponse { + let name = match body.get("name").and_then(|v| v.as_str()) { + Some(n) => n, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "name required"})), + ) + .into_response() + } + }; + match state.db.create_account(name, AccountType::User).await { + Ok(account) => (StatusCode::CREATED, Json(json!(account))).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +/// Handle `GET /api/v1/accounts` -- list all accounts. +/// +/// Returns a JSON array of all account records. Used by the web UI for +/// the account selector and by the CLI for `hci account list`. +pub async fn list_accounts( + State(state): State>>, +) -> impl IntoResponse { + match state.db.list_accounts().await { + Ok(accounts) => Json(json!(accounts)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `GET /api/v1/accounts/{id}` -- get a specific account by UUID. +/// +/// Returns the full account record, or 404 if no account exists with that ID. +pub async fn get_account( + State(state): State>>, + Path(id): Path, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + match state.db.get_account(uuid).await { + Ok(account) => Json(json!(account)).into_response(), + Err(jupiter_db::error::DbError::NotFound { .. }) => StatusCode::NOT_FOUND.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/crates/jupiter-server/src/routes/auth.rs b/crates/jupiter-server/src/routes/auth.rs new file mode 100644 index 0000000..3793840 --- /dev/null +++ b/crates/jupiter-server/src/routes/auth.rs @@ -0,0 +1,186 @@ +//! # Authentication endpoints +//! +//! This module provides JWT token creation endpoints for two use cases: +//! +//! 1. **User login** (`POST /auth/token`) -- the `hci` CLI authenticates +//! with username/password and receives a User-scoped JWT valid for 24 hours. +//! The password is verified against a bcrypt hash stored in the database. +//! +//! 2. **Effect token issuance** (`POST /projects/{id}/user-effect-token`) -- +//! creates a short-lived (1 hour) Effect-scoped JWT for use during effect +//! execution. The token's `sub` is the project ID and its scope contains +//! the attribute path, limiting the effect's access to only its own +//! project's state files. +//! +//! ## Token lifecycle +//! +//! ```text +//! hci login ──POST /auth/token──> Verify password ──> User JWT (24h) +//! │ +//! ┌────────────────────────────────────────┘ +//! v +//! hci state / web UI ──> Use User JWT for all API calls +//! +//! Scheduler dispatches effect ──> Creates Effect JWT (1h) +//! │ +//! v +//! Effect runs ──> Uses Effect JWT for /current-task/state +//! ``` + +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::{json, Value}; +use std::sync::Arc; +use uuid::Uuid; + +use jupiter_db::backend::StorageBackend; + +use crate::auth::{create_jwt, TokenScope}; +use crate::state::AppState; + +/// Handle `POST /api/v1/auth/token` -- authenticate with username/password +/// and receive a User-scoped JWT. +/// +/// The handler: +/// 1. Extracts `username` and `password` from the JSON request body. +/// 2. Looks up the bcrypt password hash for the account from the database. +/// 3. Verifies the password against the stored hash. +/// 4. On success, creates a 24-hour JWT with `TokenScope::User`. +/// +/// ## Request body +/// +/// ```json +/// { +/// "username": "admin", +/// "password": "secret" +/// } +/// ``` +/// +/// ## Response (200 OK) +/// +/// ```json +/// { +/// "token": "eyJ...", +/// "expiresAt": "2024-01-01T12:00:00Z" +/// } +/// ``` +/// +/// Returns 401 Unauthorized if the credentials are invalid. +pub async fn create_token( + State(state): State>>, + Json(body): Json, +) -> impl IntoResponse { + let username = body.get("username").and_then(|v| v.as_str()); + let password = body.get("password").and_then(|v| v.as_str()); + + match (username, password) { + (Some(user), Some(pass)) => { + // Look up the bcrypt password hash for this account. + let password_hash = match state.db.get_account_password_hash(user).await { + Ok(Some(hash)) => hash, + Ok(None) => { + return (StatusCode::UNAUTHORIZED, "invalid credentials").into_response(); + } + Err(jupiter_db::error::DbError::NotFound { .. }) => { + return (StatusCode::UNAUTHORIZED, "invalid credentials").into_response(); + } + Err(e) => { + return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(); + } + }; + + // Verify the plaintext password against the stored bcrypt hash. + match bcrypt::verify(pass, &password_hash) { + Ok(true) => {} + Ok(false) => { + return (StatusCode::UNAUTHORIZED, "invalid credentials").into_response(); + } + Err(_) => { + return (StatusCode::UNAUTHORIZED, "invalid credentials").into_response(); + } + } + + // Create a User-scoped JWT valid for 24 hours. + match create_jwt(&state.config.jwt_private_key, user, TokenScope::User, 24) { + Ok(token) => { + let expires_at = chrono::Utc::now() + chrono::Duration::hours(24); + Json(json!({ + "token": token, + "expiresAt": expires_at.to_rfc3339(), + })) + .into_response() + } + Err(e) => { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + } + } + _ => (StatusCode::BAD_REQUEST, "username and password required").into_response(), + } +} + +/// Handle `POST /api/v1/projects/{id}/user-effect-token` -- create an +/// Effect-scoped JWT for a project. +/// +/// This endpoint is called by the scheduler (or manually by administrators) +/// to create a token that an effect will use during execution. The token: +/// +/// - Has `sub` = the project ID (so the effect can access this project's state). +/// - Has `TokenScope::Effect` with the job ID and attribute path. +/// - Expires after 1 hour (effects should not run longer than that). +/// +/// ## Request body +/// +/// ```json +/// { +/// "effectAttributePath": ["effects", "deploy"] +/// } +/// ``` +/// +/// ## Response (200 OK) +/// +/// ```json +/// { +/// "token": "eyJ...", +/// "apiBaseUrl": "https://jupiter.example.com" +/// } +/// ``` +/// +/// The `apiBaseUrl` is included so the effect knows where to send state +/// file requests (it may differ from the agent's server URL in multi-tier +/// deployments). +pub async fn create_effect_token( + State(state): State>>, + Path(project_id): Path, + Json(body): Json, +) -> impl IntoResponse { + let _uuid = match Uuid::parse_str(&project_id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + + let attr_path = body + .get("effectAttributePath") + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + .unwrap_or_default(); + + // Build the Effect scope with the project_id as job_id (for backward + // compatibility with the Hercules CI API) and the attribute path. + let scope = TokenScope::Effect { + job_id: project_id.clone(), + attribute_path: attr_path, + }; + + // Create a short-lived (1 hour) JWT with the project ID as subject. + match create_jwt(&state.config.jwt_private_key, &project_id, scope, 1) { + Ok(token) => Json(json!({ + "token": token, + "apiBaseUrl": state.config.base_url, + })) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/crates/jupiter-server/src/routes/builds.rs b/crates/jupiter-server/src/routes/builds.rs new file mode 100644 index 0000000..0b7b04e --- /dev/null +++ b/crates/jupiter-server/src/routes/builds.rs @@ -0,0 +1,157 @@ +//! # Build (derivation) endpoints +//! +//! Builds represent individual Nix derivation builds within a job. After +//! evaluation discovers the set of attributes and their derivation paths, +//! the scheduler creates build records and dispatches build tasks to agents. +//! +//! These endpoints are keyed by derivation path (e.g. +//! `/nix/store/abc...-my-package.drv`) rather than by UUID, matching the +//! Hercules CI API convention. This makes it easy for the `hci` CLI and +//! web UI to link directly to a specific derivation. +//! +//! ## Data flow +//! +//! ```text +//! Evaluation discovers attribute +//! └─> Scheduler creates Build record (status: Pending) +//! └─> Scheduler dispatches build task to agent +//! └─> Agent builds derivation +//! └─> Agent sends BuildDone +//! └─> Scheduler updates Build record (status: Success/Failure) +//! ``` +//! +//! ## Log retrieval +//! +//! Build logs are stored as structured log entries (not raw text). The +//! `get_derivation_log` endpoint returns paginated log lines that can be +//! rendered in the web UI or printed by the CLI. + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde_json::json; +use std::sync::Arc; +use uuid::Uuid; + +use jupiter_api_types::PaginationParams; +use jupiter_db::backend::StorageBackend; +use jupiter_scheduler::engine::SchedulerEvent; + +use crate::state::AppState; + +/// Handle `GET /api/v1/accounts/{id}/derivations/{drvPath}` -- get build info +/// for a derivation. +/// +/// Looks up the build record by its Nix store derivation path. The account ID +/// path parameter is accepted for API compatibility but is not currently used +/// for filtering (builds are globally unique by derivation path). +/// +/// Returns the build record including status, output paths, and timing, or +/// 404 if no build exists for that derivation path. +pub async fn get_derivation( + State(state): State>>, + Path((_account_id, drv_path)): Path<(String, String)>, +) -> impl IntoResponse { + match state.db.get_build_by_drv_path(&drv_path).await { + Ok(Some(build)) => Json(json!(build)).into_response(), + Ok(None) => StatusCode::NOT_FOUND.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `GET /api/v1/accounts/{id}/derivations/{drvPath}/log/lines` -- get +/// build log lines for a derivation. +/// +/// First resolves the derivation path to a build record, then fetches the +/// paginated log entries associated with that build's UUID. Pagination is +/// controlled by `page` (offset, default 0) and `per_page` (limit, default 100). +/// +/// Returns `{"lines": [...]}` where each entry is a structured log line with +/// timestamp, level, and message fields. +pub async fn get_derivation_log( + State(state): State>>, + Path((_account_id, drv_path)): Path<(String, String)>, + Query(params): Query, +) -> impl IntoResponse { + // First, resolve the derivation path to a build record. + let build = match state.db.get_build_by_drv_path(&drv_path).await { + Ok(Some(b)) => b, + Ok(None) => return StatusCode::NOT_FOUND.into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + // Then fetch the log entries for this build's UUID. + let build_id: Uuid = build.id.into(); + let offset = params.page.unwrap_or(0); + let limit = params.per_page.unwrap_or(100); + + match state.db.get_log_entries(build_id, offset, limit).await { + Ok(entries) => Json(json!({ "lines": entries })).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `POST /api/v1/accounts/{id}/derivations/{drvPath}/retry` -- retry +/// a failed build. +/// +/// Looks up the build by derivation path and sends a [`SchedulerEvent::RetryBuild`] +/// to the scheduler, which will reset the build's status to Pending and +/// re-dispatch it to an available agent. Returns 202 Accepted. +/// +/// This is useful when a build fails due to transient issues (network timeouts, +/// Nix store corruption, etc.) and can be retried without re-evaluation. +pub async fn retry_build( + State(state): State>>, + Path((_account_id, drv_path)): Path<(String, String)>, +) -> impl IntoResponse { + match state.db.get_build_by_drv_path(&drv_path).await { + Ok(Some(build)) => { + let build_id: Uuid = build.id.into(); + let _ = state + .scheduler_tx + .send(SchedulerEvent::RetryBuild { build_id }) + .await; + ( + StatusCode::ACCEPTED, + Json(json!({"status": "retry queued"})), + ) + .into_response() + } + Ok(None) => StatusCode::NOT_FOUND.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `POST /api/v1/accounts/{id}/derivations/{drvPath}/cancel` -- cancel +/// a running or pending build. +/// +/// Looks up the build by derivation path and sends a +/// [`SchedulerEvent::CancelBuild`] to the scheduler. The scheduler will mark +/// the build as cancelled. If the build is already running on an agent, the +/// agent will be notified to abort. +/// +/// Returns 202 Accepted. The actual cancellation is asynchronous. +pub async fn cancel_build( + State(state): State>>, + Path((_account_id, drv_path)): Path<(String, String)>, +) -> impl IntoResponse { + match state.db.get_build_by_drv_path(&drv_path).await { + Ok(Some(build)) => { + let build_id: Uuid = build.id.into(); + let _ = state + .scheduler_tx + .send(SchedulerEvent::CancelBuild { build_id }) + .await; + ( + StatusCode::ACCEPTED, + Json(json!({"status": "cancel queued"})), + ) + .into_response() + } + Ok(None) => StatusCode::NOT_FOUND.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/crates/jupiter-server/src/routes/effects.rs b/crates/jupiter-server/src/routes/effects.rs new file mode 100644 index 0000000..2d83760 --- /dev/null +++ b/crates/jupiter-server/src/routes/effects.rs @@ -0,0 +1,150 @@ +//! # Effect endpoints +//! +//! Effects are side-effecting Nix actions (e.g. deployments, notifications) +//! that run after builds complete. They are defined as Nix attributes under +//! the `effects` output of a flake and are executed by agents with special +//! scoped tokens that limit their access to only the project's state files. +//! +//! Effects are identified by their job ID and attribute path (e.g. `"deploy"`), +//! matching the Hercules CI API convention. This allows the web UI and CLI +//! to link directly to a specific effect within a job. +//! +//! ## Data flow +//! +//! ```text +//! Evaluation discovers Effect attribute +//! └─> Scheduler creates Effect record (status: Pending) +//! └─> All required builds complete +//! └─> Scheduler dispatches effect task to agent +//! └─> Agent runs effect with scoped JWT token +//! └─> Agent sends EffectDone +//! └─> Scheduler updates Effect record +//! ``` +//! +//! ## Scoped access +//! +//! During execution, effects receive a short-lived JWT with `TokenScope::Effect` +//! that grants access to the `/current-task/state` endpoints. This token +//! embeds the project ID and attribute path, ensuring effects can only +//! read/write their own project's state files. + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde_json::json; +use std::sync::Arc; +use uuid::Uuid; + +use jupiter_api_types::PaginationParams; +use jupiter_db::backend::StorageBackend; +use jupiter_scheduler::engine::SchedulerEvent; + +use crate::state::AppState; + +/// Handle `GET /api/v1/jobs/{job_id}/effects/{attr}` -- get effect info. +/// +/// Looks up an effect by its parent job UUID and attribute name. The attribute +/// name corresponds to the Nix attribute path under the `effects` output +/// (e.g. `"deploy"`, `"notify"`). +/// +/// Returns the effect record including status (Pending/Running/Success/Failure), +/// timing data, and the derivation path. Returns 404 if the effect does not +/// exist for this job. +pub async fn get_effect( + State(state): State>>, + Path((job_id, attr)): Path<(String, String)>, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&job_id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + match state.db.get_effect_by_job_and_attr(uuid, &attr).await { + Ok(effect) => Json(json!(effect)).into_response(), + Err(jupiter_db::error::DbError::NotFound { .. }) => StatusCode::NOT_FOUND.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `GET /api/v1/jobs/{job_id}/effects/{attr}/log/lines` -- get effect +/// log lines. +/// +/// Retrieves paginated log entries for an effect, identified by job UUID and +/// attribute name. The effect is first resolved to get its UUID, then log +/// entries are fetched with the given offset/limit pagination. +/// +/// Returns `{"lines": [...]}` with structured log entries. +pub async fn get_effect_log( + State(state): State>>, + Path((job_id, attr)): Path<(String, String)>, + Query(params): Query, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&job_id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + + // First resolve the effect by job + attribute name. + let effect = match state.db.get_effect_by_job_and_attr(uuid, &attr).await { + Ok(e) => e, + Err(jupiter_db::error::DbError::NotFound { .. }) => { + return StatusCode::NOT_FOUND.into_response() + } + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + // Then fetch log entries for the effect's UUID. + let effect_id: Uuid = effect.id.into(); + let offset = params.page.unwrap_or(0); + let limit = params.per_page.unwrap_or(100); + + match state.db.get_log_entries(effect_id, offset, limit).await { + Ok(entries) => Json(json!({ "lines": entries })).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `POST /api/v1/jobs/{job_id}/effects/{attr}/cancel` -- cancel a +/// running or pending effect. +/// +/// Resolves the effect by job UUID and attribute name, then sends a +/// [`SchedulerEvent::CancelEffect`] to the scheduler. The scheduler will +/// mark the effect as cancelled and notify the agent if the effect is +/// currently running. +/// +/// Returns 202 Accepted. The actual cancellation is asynchronous. +pub async fn cancel_effect( + State(state): State>>, + Path((job_id, attr)): Path<(String, String)>, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&job_id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + + let effect = match state.db.get_effect_by_job_and_attr(uuid, &attr).await { + Ok(e) => e, + Err(jupiter_db::error::DbError::NotFound { .. }) => { + return StatusCode::NOT_FOUND.into_response() + } + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let effect_id: Uuid = effect.id.into(); + + let _ = state + .scheduler_tx + .send(SchedulerEvent::CancelEffect { + effect_id, + job_id: uuid, + }) + .await; + + ( + StatusCode::ACCEPTED, + Json(json!({"status": "cancel queued"})), + ) + .into_response() +} diff --git a/crates/jupiter-server/src/routes/health.rs b/crates/jupiter-server/src/routes/health.rs new file mode 100644 index 0000000..12551ec --- /dev/null +++ b/crates/jupiter-server/src/routes/health.rs @@ -0,0 +1,37 @@ +//! # Health check endpoint +//! +//! Provides a simple liveness probe at `GET /health`. This endpoint is +//! intended for use by load balancers, container orchestrators (e.g. +//! Kubernetes), and monitoring systems to verify that the Jupiter server +//! process is running and able to handle requests. +//! +//! The response includes the service name and the version from `Cargo.toml`, +//! which is useful for verifying that a deployment rolled out the expected +//! binary version. + +use axum::Json; +use serde_json::{json, Value}; + +/// Handle `GET /health` -- return a JSON object indicating the server is alive. +/// +/// This endpoint requires no authentication and performs no database queries, +/// so it will succeed even if the database is temporarily unavailable. The +/// response body is: +/// +/// ```json +/// { +/// "status": "ok", +/// "service": "jupiter", +/// "version": "0.1.0" +/// } +/// ``` +/// +/// The `version` field is populated at compile time from the crate's +/// `Cargo.toml` via the `env!("CARGO_PKG_VERSION")` macro. +pub async fn health() -> Json { + Json(json!({ + "status": "ok", + "service": "jupiter", + "version": env!("CARGO_PKG_VERSION"), + })) +} diff --git a/crates/jupiter-server/src/routes/jobs.rs b/crates/jupiter-server/src/routes/jobs.rs new file mode 100644 index 0000000..ae8a693 --- /dev/null +++ b/crates/jupiter-server/src/routes/jobs.rs @@ -0,0 +1,183 @@ +//! # Job management endpoints +//! +//! Jobs are the top-level unit of CI work in Jupiter. A job is created when +//! the scheduler processes a forge webhook event (push, PR) for a project. +//! Each job progresses through a lifecycle: +//! +//! 1. **Pending** -- waiting for an agent to pick up the evaluation task. +//! 2. **Evaluating** -- an agent is evaluating the Nix flake/expression to +//! discover attributes (packages, effects). +//! 3. **Building** -- evaluation is complete; builds are being dispatched to +//! agents for each discovered derivation. +//! 4. **Complete** -- all builds (and optionally effects) have finished. +//! +//! Jobs can be rerun (re-enqueued for evaluation) or cancelled (all pending +//! tasks are aborted). Both actions are asynchronous: the handler sends a +//! [`SchedulerEvent`] and returns immediately with 202 Accepted. +//! +//! ## Evaluation results +//! +//! The `GET /jobs/{id}/evaluation` endpoint returns the list of Nix attributes +//! discovered during the evaluation phase. Each attribute has a path (e.g. +//! `["packages", "x86_64-linux", "default"]`), a derivation path, and a type +//! (Regular or Effect). + +use axum::{ + extract::{Json, Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; +use std::sync::Arc; +use uuid::Uuid; + +use jupiter_api_types::{Paginated, PaginationParams}; +use jupiter_db::backend::StorageBackend; +use jupiter_scheduler::engine::SchedulerEvent; + +use crate::state::AppState; + +/// Handle `GET /api/v1/projects/{project_id}/jobs` -- list jobs for a project. +/// +/// Returns a paginated list of jobs belonging to the given project, ordered +/// by creation time (newest first). Pagination is controlled by the `page` +/// and `per_page` query parameters (defaults: page=1, per_page=20). +/// +/// The response follows the [`Paginated`] wrapper format: +/// +/// ```json +/// { +/// "items": [...], +/// "total": 42, +/// "page": 1, +/// "per_page": 20 +/// } +/// ``` +pub async fn list_jobs( + State(state): State>>, + Path(project_id): Path, + Query(params): Query, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&project_id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + let page = params.page.unwrap_or(1); + let per_page = params.per_page.unwrap_or(20); + match state.db.list_jobs_for_project(uuid, page, per_page).await { + Ok((jobs, total)) => Json(json!(Paginated { + items: jobs, + total, + page, + per_page, + })) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `GET /api/v1/jobs/{id}` -- get a specific job by UUID. +/// +/// Returns the full job record including status, project linkage, commit +/// info, and timing data. Returns 404 if the job does not exist. +pub async fn get_job( + State(state): State>>, + Path(id): Path, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + match state.db.get_job(uuid).await { + Ok(job) => Json(json!(job)).into_response(), + Err(jupiter_db::error::DbError::NotFound { .. }) => StatusCode::NOT_FOUND.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `GET /api/v1/jobs/{id}/evaluation` -- get evaluation results for a job. +/// +/// Returns the list of Nix attributes discovered during the evaluation phase. +/// Each attribute includes its path, derivation path, type (Regular/Effect), +/// and any evaluation errors. This data is used by the web UI to show which +/// packages and effects a job will build/run. +/// +/// The response format is: +/// +/// ```json +/// { +/// "jobId": "", +/// "attributes": [...] +/// } +/// ``` +pub async fn get_evaluation( + State(state): State>>, + Path(id): Path, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + match state.db.get_evaluation_attributes(uuid).await { + Ok(attrs) => Json(json!({ + "jobId": id, + "attributes": attrs, + })) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `POST /api/v1/jobs/{id}/rerun` -- re-enqueue a job for evaluation. +/// +/// Sends a [`SchedulerEvent::RerunJob`] to the scheduler, which will reset +/// the job's status and create a new evaluation task. This is useful when a +/// job failed due to transient issues (network errors, OOM, etc.) and the +/// user wants to retry without pushing a new commit. +/// +/// Returns 202 Accepted immediately; the actual rerun happens asynchronously. +pub async fn rerun_job( + State(state): State>>, + Path(id): Path, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + let _ = state + .scheduler_tx + .send(SchedulerEvent::RerunJob { job_id: uuid }) + .await; + ( + StatusCode::ACCEPTED, + Json(json!({"status": "rerun queued"})), + ) + .into_response() +} + +/// Handle `POST /api/v1/jobs/{id}/cancel` -- cancel a running or pending job. +/// +/// Sends a [`SchedulerEvent::CancelJob`] to the scheduler, which will mark +/// the job and all its pending tasks as cancelled. In-progress builds on +/// agents may continue until they finish, but their results will be discarded. +/// +/// Returns 202 Accepted immediately; the actual cancellation happens +/// asynchronously. +pub async fn cancel_job( + State(state): State>>, + Path(id): Path, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + let _ = state + .scheduler_tx + .send(SchedulerEvent::CancelJob { job_id: uuid }) + .await; + ( + StatusCode::ACCEPTED, + Json(json!({"status": "cancel queued"})), + ) + .into_response() +} diff --git a/crates/jupiter-server/src/routes/mod.rs b/crates/jupiter-server/src/routes/mod.rs new file mode 100644 index 0000000..07794d7 --- /dev/null +++ b/crates/jupiter-server/src/routes/mod.rs @@ -0,0 +1,237 @@ +//! # HTTP route definitions +//! +//! This module assembles the complete axum [`Router`] for the Jupiter server. +//! All REST API endpoints and the WebSocket upgrade route are registered here. +//! +//! ## Route organization +//! +//! Routes are grouped by domain, mirroring the Hercules CI API structure: +//! +//! | Prefix | Module | Purpose | +//! |------------------------------------|----------------|--------------------------------------------| +//! | `/health` | [`health`] | Liveness probe for load balancers | +//! | `/api/v1/agent/socket` | [`websocket`] | WebSocket upgrade for agent connections | +//! | `/api/v1/agents` | [`agents`] | List/get agent sessions | +//! | `/api/v1/agent/{session,heartbeat,goodbye}` | [`tasks`] | REST-based agent session management | +//! | `/api/v1/accounts` | [`agents`] | Account and cluster join token management | +//! | `/api/v1/projects` | [`projects`] | Project CRUD | +//! | `/api/v1/jobs` | [`jobs`] | Job listing, evaluation, rerun, cancel | +//! | `/api/v1/accounts/:id/derivations` | [`builds`] | Build (derivation) info, logs, retry/cancel | +//! | `/api/v1/jobs/:id/effects` | [`effects`] | Effect info, logs, cancel | +//! | `/api/v1/tasks` | [`tasks`] | Agent task polling, status updates, events | +//! | `/api/v1/projects/:id/state` | [`state_files`]| State file upload/download and locking | +//! | `/api/v1/current-task/state` | [`state_files`]| Effect-scoped state access (via JWT) | +//! | `/api/v1/lock-leases` | [`state_files`]| Lock renewal and release | +//! | `/api/v1/webhooks` | [`webhooks`] | GitHub/Gitea webhook receivers | +//! | `/api/v1/auth` | [`auth`] | Token creation (login, effect tokens) | +//! +//! ## Shared state +//! +//! All handlers receive `State>>` which provides access to the +//! database, scheduler channel, agent hub, and configuration. The state is +//! generic over [`StorageBackend`] so the same route tree works with SQLite +//! or PostgreSQL. + +pub mod agents; +pub mod auth; +pub mod builds; +pub mod effects; +pub mod health; +pub mod jobs; +pub mod projects; +pub mod state_files; +pub mod tasks; +pub mod webhooks; + +use axum::{ + routing::{delete, get, post, put}, + Router, +}; +use std::sync::Arc; + +use jupiter_db::backend::StorageBackend; + +use crate::state::AppState; +use crate::websocket; + +/// Build the complete axum router with all API routes and shared state. +/// +/// This function wraps the [`AppState`] in an `Arc`, registers every route +/// handler, and returns the configured [`Router`]. The router is ready to be +/// passed to `axum::serve`. +/// +/// Routes are organized into logical groups: +/// - **Health**: simple liveness check at `/health`. +/// - **Agent WebSocket**: the `/api/v1/agent/socket` endpoint that agents +/// connect to for the binary protocol. +/// - **Agent REST**: session creation, heartbeat, and graceful shutdown +/// endpoints for agents that use the HTTP-based protocol variant. +/// - **Cluster management**: account and join token CRUD for multi-tenant setups. +/// - **Projects/Jobs/Builds/Effects**: the core CI data model, providing +/// read access for the CLI and web UI, plus rerun/cancel actions. +/// - **Tasks**: the agent-facing endpoints for polling work and reporting results. +/// - **State files**: blob upload/download with versioning and distributed locking, +/// powering the `hci state` CLI feature. +/// - **Webhooks**: forge-specific endpoints that verify signatures and trigger +/// the scheduler. +/// - **Auth**: JWT token issuance for user login and effect execution. +pub fn build_router(state: AppState) -> Router { + let shared_state = Arc::new(state); + + Router::new() + // Health check -- used by load balancers and orchestrators to verify + // the server is running and responsive. + .route("/health", get(health::health)) + // Agent WebSocket -- the primary connection path for hercules-ci-agent + // instances. Upgrades to WebSocket and runs the wire protocol. + .route( + "/api/v1/agent/socket", + get(websocket::handler::ws_handler::), + ) + // Agent REST endpoints -- list/get agent sessions for the web UI and CLI. + .route("/api/v1/agents", get(agents::list_agents::)) + .route("/api/v1/agents/{id}", get(agents::get_agent::)) + .route("/api/v1/agent/service-info", get(agents::service_info)) + // Agent lifecycle via REST -- alternative to WebSocket for agents that + // prefer HTTP polling (session creation, heartbeat, goodbye). + .route( + "/api/v1/agent/session", + post(tasks::create_agent_session::), + ) + .route( + "/api/v1/agent/heartbeat", + post(tasks::agent_heartbeat::), + ) + .route("/api/v1/agent/goodbye", post(tasks::agent_goodbye::)) + // Cluster join tokens -- manage tokens that allow new agents to join + // an account's cluster. Tokens are bcrypt-hashed before storage. + .route( + "/api/v1/accounts/{account_id}/clusterJoinTokens", + post(agents::create_join_token::).get(agents::list_join_tokens::), + ) + .route( + "/api/v1/accounts/{account_id}/clusterJoinTokens/{token_id}", + delete(agents::delete_join_token::), + ) + // Account endpoints -- CRUD for accounts (organizations or users). + .route( + "/api/v1/accounts", + post(agents::create_account::).get(agents::list_accounts::), + ) + .route("/api/v1/accounts/{id}", get(agents::get_account::)) + // Derivation (build) endpoints -- look up build info by derivation + // path, view build logs, and trigger retry/cancel via the scheduler. + .route( + "/api/v1/accounts/{id}/derivations/{drvPath}", + get(builds::get_derivation::), + ) + .route( + "/api/v1/accounts/{id}/derivations/{drvPath}/log/lines", + get(builds::get_derivation_log::), + ) + .route( + "/api/v1/accounts/{id}/derivations/{drvPath}/retry", + post(builds::retry_build::), + ) + .route( + "/api/v1/accounts/{id}/derivations/{drvPath}/cancel", + post(builds::cancel_build::), + ) + // Project endpoints -- CRUD for projects. Each project is linked to + // a forge repository and an account. + .route( + "/api/v1/projects", + post(projects::create_project::).get(projects::list_projects::), + ) + .route( + "/api/v1/projects/{id}", + get(projects::get_project::).patch(projects::update_project::), + ) + // Jobs scoped to a project -- paginated listing of CI jobs. + .route( + "/api/v1/projects/{id}/jobs", + get(jobs::list_jobs::), + ) + // Job endpoints -- individual job details, evaluation results, + // and rerun/cancel actions that delegate to the scheduler. + .route("/api/v1/jobs/{id}", get(jobs::get_job::)) + .route( + "/api/v1/jobs/{id}/evaluation", + get(jobs::get_evaluation::), + ) + .route("/api/v1/jobs/{id}/rerun", post(jobs::rerun_job::)) + .route("/api/v1/jobs/{id}/cancel", post(jobs::cancel_job::)) + // Effect endpoints -- view effect status and logs, cancel running effects. + .route( + "/api/v1/jobs/{id}/effects/{attr}", + get(effects::get_effect::), + ) + .route( + "/api/v1/jobs/{id}/effects/{attr}/log/lines", + get(effects::get_effect_log::), + ) + .route( + "/api/v1/jobs/{id}/effects/{attr}/cancel", + post(effects::cancel_effect::), + ) + // Task endpoints -- the agent-facing API for task dispatch and reporting. + // Agents poll POST /tasks to get work, then report status, evaluation + // events, build events, and log entries. + .route("/api/v1/tasks", post(tasks::poll_task::)) + .route("/api/v1/tasks/{id}", post(tasks::update_task::)) + .route( + "/api/v1/tasks/{id}/eval", + post(tasks::task_eval_event::), + ) + .route( + "/api/v1/tasks/{id}/build", + post(tasks::task_build_event::), + ) + .route("/api/v1/tasks/log", post(tasks::task_log::)) + // State file endpoints -- binary blob upload/download with versioning, + // powering the `hci state` CLI command. Includes distributed locking + // to prevent concurrent writes. + .route( + "/api/v1/projects/{id}/state/{name}/data", + put(state_files::put_state::).get(state_files::get_state::), + ) + .route( + "/api/v1/projects/{id}/states", + get(state_files::list_states::), + ) + .route( + "/api/v1/projects/{id}/lock/{name}", + post(state_files::acquire_lock::), + ) + .route( + "/api/v1/lock-leases/{id}", + post(state_files::renew_lock::).delete(state_files::release_lock::), + ) + // Current task state -- effect-scoped state access. Effects use their + // JWT token (which contains the project_id) to read/write state files + // without needing to know the project ID explicitly. + .route( + "/api/v1/current-task/state/{name}/data", + get(state_files::get_current_task_state::) + .put(state_files::put_current_task_state::), + ) + // Webhook endpoints -- receive and verify push/PR events from forges. + // After signature verification and event parsing, the event is forwarded + // to the scheduler which creates a new job. + .route( + "/api/v1/webhooks/github", + post(webhooks::github_webhook::), + ) + .route( + "/api/v1/webhooks/gitea", + post(webhooks::gitea_webhook::), + ) + // Auth endpoints -- JWT token creation for user login (via username/ + // password) and effect token issuance (scoped to a project). + .route("/api/v1/auth/token", post(auth::create_token::)) + .route( + "/api/v1/projects/{id}/user-effect-token", + post(auth::create_effect_token::), + ) + .with_state(shared_state) +} diff --git a/crates/jupiter-server/src/routes/projects.rs b/crates/jupiter-server/src/routes/projects.rs new file mode 100644 index 0000000..2afe076 --- /dev/null +++ b/crates/jupiter-server/src/routes/projects.rs @@ -0,0 +1,145 @@ +//! # Project management endpoints +//! +//! Projects are the central organizational unit for CI work in Jupiter. +//! Each project is linked to a forge repository (via `repo_id`) and belongs +//! to an account (via `account_id`). When a webhook fires for a repository, +//! the scheduler looks up the corresponding project to create a new job. +//! +//! Projects can be enabled or disabled. When disabled, incoming webhooks +//! for that project's repository are ignored and no new jobs are created. +//! +//! ## Data flow +//! +//! ```text +//! Forge webhook ──> Scheduler finds project by repo ──> Creates Job +//! │ +//! ┌──────────────────────────┘ +//! v +//! Evaluation ──> Builds ──> Effects +//! ``` + +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::{json, Value}; +use std::sync::Arc; +use uuid::Uuid; + +use jupiter_db::backend::StorageBackend; + +use crate::state::AppState; + +/// Handle `POST /api/v1/projects` -- create a new project. +/// +/// Links a forge repository to an account, creating the project record that +/// ties together webhooks, jobs, builds, and state files. +/// +/// ## Request body +/// +/// ```json +/// { +/// "accountId": "", +/// "repoId": "", +/// "name": "my-project" +/// } +/// ``` +/// +/// All three fields are required. Returns 201 Created with the full project +/// record on success, or 400 Bad Request if any field is missing. +pub async fn create_project( + State(state): State>>, + Json(body): Json, +) -> impl IntoResponse { + let account_id = body + .get("accountId") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()); + let repo_id = body + .get("repoId") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()); + let name = body.get("name").and_then(|v| v.as_str()); + + match (account_id, repo_id, name) { + (Some(aid), Some(rid), Some(n)) => { + match state.db.create_project(aid, rid, n).await { + Ok(project) => (StatusCode::CREATED, Json(json!(project))).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ) + .into_response(), + } + } + _ => ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "accountId, repoId, and name required"})), + ) + .into_response(), + } +} + +/// Handle `GET /api/v1/projects/{id}` -- get a specific project by UUID. +/// +/// Returns the full project record including account linkage, repository info, +/// and enabled status. Returns 404 if the project does not exist. +pub async fn get_project( + State(state): State>>, + Path(id): Path, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + match state.db.get_project(uuid).await { + Ok(project) => Json(json!(project)).into_response(), + Err(jupiter_db::error::DbError::NotFound { .. }) => StatusCode::NOT_FOUND.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `PATCH /api/v1/projects/{id}` -- update project settings. +/// +/// Currently supports toggling the `enabled` flag. When a project is disabled, +/// incoming webhooks for its repository are silently ignored by the scheduler. +/// +/// ## Request body +/// +/// ```json +/// { "enabled": false } +/// ``` +/// +/// If `enabled` is not provided, defaults to `true`. +pub async fn update_project( + State(state): State>>, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + let enabled = body + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + match state.db.update_project(uuid, enabled).await { + Ok(project) => Json(json!(project)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `GET /api/v1/projects` -- list all projects. +/// +/// Returns a JSON array of all project records. Used by the web UI dashboard +/// and the `hci project list` CLI command to browse available projects. +pub async fn list_projects( + State(state): State>>, +) -> impl IntoResponse { + match state.db.list_projects().await { + Ok(projects) => Json(json!(projects)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/crates/jupiter-server/src/routes/state_files.rs b/crates/jupiter-server/src/routes/state_files.rs new file mode 100644 index 0000000..cd6eaa3 --- /dev/null +++ b/crates/jupiter-server/src/routes/state_files.rs @@ -0,0 +1,311 @@ +//! # State file endpoints +//! +//! State files are binary blobs stored per-project that persist across CI jobs. +//! They power the `hci state` CLI feature, which allows effects to read and +//! write arbitrary data (e.g. Terraform state, deployment manifests, secrets). +//! +//! ## Two access patterns +//! +//! 1. **Direct project access** (`/projects/{id}/state/{name}/data`): +//! Used by the `hci` CLI and web UI with a User-scoped JWT token. The +//! project ID is explicit in the URL path. +//! +//! 2. **Effect-scoped access** (`/current-task/state/{name}/data`): +//! Used by effects during execution with an Effect-scoped JWT token. The +//! project ID is extracted from the token's `sub` field, so the effect +//! does not need to know its project ID -- it simply reads/writes "its" +//! state. This prevents cross-project access. +//! +//! ## Distributed locking +//! +//! To prevent concurrent writes, state files support distributed locking: +//! +//! ```text +//! Client ──POST /projects/:id/lock/:name──> Acquire lock (TTL: 300s) +//! │ │ +//! │ ┌── 201 Created: lock acquired ─────────┘ +//! │ │ (returns lock_id + lease) +//! │ │ +//! │ ├── POST /lock-leases/:id ──> Renew lock (extend TTL) +//! │ │ +//! │ ├── PUT /projects/:id/state/:name/data ──> Upload new state +//! │ │ +//! │ └── DELETE /lock-leases/:id ──> Release lock +//! │ +//! └── 409 Conflict: lock held by another client +//! ``` +//! +//! Locks have a TTL (default 300 seconds) and expire automatically if not +//! renewed, preventing deadlocks when a client crashes. + +use axum::{ + body::Bytes, + extract::{Json, Path, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, +}; +use serde_json::{json, Value}; +use std::sync::Arc; +use uuid::Uuid; + +use jupiter_db::backend::StorageBackend; + +use crate::auth::parse_effect_token; +use crate::state::AppState; + +/// Handle `PUT /api/v1/projects/{project_id}/state/{name}/data` -- upload a +/// state file. +/// +/// Accepts a raw binary body and stores it as a versioned state file blob +/// associated with the given project and name. The name is a user-chosen +/// identifier (e.g. `"terraform.tfstate"`, `"deploy-info"`). +/// +/// Returns 204 No Content on success. The previous version is overwritten. +pub async fn put_state( + State(state): State>>, + Path((project_id, name)): Path<(String, String)>, + body: Bytes, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&project_id) { + Ok(u) => u, + Err(_) => return StatusCode::BAD_REQUEST.into_response(), + }; + match state.db.put_state_file(uuid, &name, &body).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `GET /api/v1/projects/{project_id}/state/{name}/data` -- download +/// a state file. +/// +/// Returns the raw binary blob as `application/octet-stream`, or 404 if no +/// state file exists with the given name for this project. +pub async fn get_state( + State(state): State>>, + Path((project_id, name)): Path<(String, String)>, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&project_id) { + Ok(u) => u, + Err(_) => return StatusCode::BAD_REQUEST.into_response(), + }; + match state.db.get_state_file(uuid, &name).await { + Ok(Some(data)) => ( + StatusCode::OK, + [("content-type", "application/octet-stream")], + data, + ) + .into_response(), + Ok(None) => StatusCode::NOT_FOUND.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `GET /api/v1/projects/{project_id}/states` -- list all state files +/// for a project. +/// +/// Returns a JSON array of state file metadata (names, sizes, last modified +/// timestamps). Does not include the actual blob data. +pub async fn list_states( + State(state): State>>, + Path(project_id): Path, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&project_id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + match state.db.list_state_files(uuid).await { + Ok(files) => Json(json!(files)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `POST /api/v1/projects/{project_id}/lock/{name}` -- acquire a +/// distributed lock on a state file. +/// +/// Locks prevent concurrent writes to a state file. The lock is associated +/// with an owner string (for debugging) and a TTL in seconds. If the lock +/// is already held by another client, returns 409 Conflict. +/// +/// ## Request body +/// +/// ```json +/// { +/// "owner": "hci-cli-user@hostname", +/// "ttlSeconds": 300 +/// } +/// ``` +/// +/// ## Response (201 Created) +/// +/// A lock lease record including the `id` that must be used for renewal +/// and release. +pub async fn acquire_lock( + State(state): State>>, + Path((project_id, name)): Path<(String, String)>, + Json(body): Json, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&project_id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + let owner = body + .get("owner") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let ttl = body + .get("ttlSeconds") + .and_then(|v| v.as_u64()) + .unwrap_or(300); + + match state.db.acquire_lock(uuid, &name, owner, ttl).await { + Ok(lock) => (StatusCode::CREATED, Json(json!(lock))).into_response(), + Err(jupiter_db::error::DbError::Conflict(_)) => StatusCode::CONFLICT.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `POST /api/v1/lock-leases/{lock_id}` -- renew a lock lease. +/// +/// Extends the TTL of an existing lock to prevent it from expiring while +/// a long-running operation is in progress. The lock must still be held +/// by the caller (identified by the lock_id from the acquire response). +/// +/// ## Request body +/// +/// ```json +/// { "ttlSeconds": 300 } +/// ``` +/// +/// Returns the updated lock record, or 404 if the lock has already expired. +pub async fn renew_lock( + State(state): State>>, + Path(lock_id): Path, + Json(body): Json, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&lock_id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + let ttl = body + .get("ttlSeconds") + .and_then(|v| v.as_u64()) + .unwrap_or(300); + + match state.db.renew_lock(uuid, ttl).await { + Ok(lock) => Json(json!(lock)).into_response(), + Err(jupiter_db::error::DbError::NotFound { .. }) => StatusCode::NOT_FOUND.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `DELETE /api/v1/lock-leases/{lock_id}` -- release a lock. +/// +/// Explicitly releases a lock before its TTL expires, allowing other clients +/// to acquire it immediately. Returns 204 No Content on success. +/// +/// If the lock has already expired (or was never acquired), this is a no-op +/// that still returns success. +pub async fn release_lock( + State(state): State>>, + Path(lock_id): Path, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&lock_id) { + Ok(u) => u, + Err(_) => return StatusCode::BAD_REQUEST.into_response(), + }; + match state.db.release_lock(uuid).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `GET /api/v1/current-task/state/{name}/data` -- effect-scoped state +/// file download. +/// +/// This endpoint is used by effects during execution. The project ID is not +/// in the URL; instead, it is extracted from the effect-scoped JWT token in +/// the `Authorization` header. This ensures effects can only read state files +/// belonging to their own project. +/// +/// Returns the raw binary blob as `application/octet-stream`, or 404 if the +/// state file does not exist. Returns 401 if the token is missing, invalid, +/// expired, or does not have the Effect scope. +pub async fn get_current_task_state( + State(state): State>>, + headers: HeaderMap, + Path(name): Path, +) -> impl IntoResponse { + let project_id = match extract_project_from_effect_token(&state, &headers) { + Ok(id) => id, + Err(status) => return status.into_response(), + }; + + match state.db.get_state_file(project_id, &name).await { + Ok(Some(data)) => ( + StatusCode::OK, + [("content-type", "application/octet-stream")], + data, + ) + .into_response(), + Ok(None) => StatusCode::NOT_FOUND.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `PUT /api/v1/current-task/state/{name}/data` -- effect-scoped state +/// file upload. +/// +/// Like [`get_current_task_state`], the project ID comes from the effect JWT +/// token rather than the URL. The raw binary body replaces the current version +/// of the named state file. +/// +/// Returns 204 No Content on success, or 401 if the token is invalid. +pub async fn put_current_task_state( + State(state): State>>, + headers: HeaderMap, + Path(name): Path, + body: Bytes, +) -> impl IntoResponse { + let project_id = match extract_project_from_effect_token(&state, &headers) { + Ok(id) => id, + Err(status) => return status.into_response(), + }; + + match state.db.put_state_file(project_id, &name, &body).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Extract the project UUID from an effect-scoped JWT token in the +/// `Authorization: Bearer ` header. +/// +/// This helper is used by the `/current-task/state` endpoints to determine +/// which project's state files the caller is authorized to access. It: +/// +/// 1. Reads the `Authorization` header and strips the `Bearer ` prefix. +/// 2. Calls [`parse_effect_token`] to verify the JWT signature, check +/// expiration, and confirm the token has `TokenScope::Effect`. +/// 3. Parses the `sub` claim (which contains the project ID) as a UUID. +/// +/// Returns `Err(StatusCode::UNAUTHORIZED)` if any step fails. +fn extract_project_from_effect_token( + state: &AppState, + headers: &HeaderMap, +) -> Result { + let auth_header = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + let token = auth_header + .strip_prefix("Bearer ") + .ok_or(StatusCode::UNAUTHORIZED)?; + + let (project_id_str, _job_id, _attr_path) = + parse_effect_token(&state.config.jwt_private_key, token) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + Uuid::parse_str(&project_id_str).map_err(|_| StatusCode::UNAUTHORIZED) +} diff --git a/crates/jupiter-server/src/routes/tasks.rs b/crates/jupiter-server/src/routes/tasks.rs new file mode 100644 index 0000000..4a93b36 --- /dev/null +++ b/crates/jupiter-server/src/routes/tasks.rs @@ -0,0 +1,432 @@ +//! # Task management endpoints +//! +//! Tasks are the atomic units of work dispatched to agents. There are three +//! task types: evaluation (discover Nix attributes), build (run `nix-build`), +//! and effect (run a side-effecting action). This module provides both the +//! agent-facing polling/reporting API and the REST-based agent lifecycle +//! endpoints. +//! +//! ## Agent task flow +//! +//! ```text +//! Agent ──POST /tasks──> Server dequeues pending task for agent's platform +//! │ │ +//! │ └─> Marks task as Running, assigns to agent +//! │ +//! ├──POST /tasks/:id──> Update task status (running, success, failure) +//! │ +//! ├──POST /tasks/:id/eval──> Report evaluation events (attribute, done) +//! │ └─> Forwarded as SchedulerEvent +//! │ +//! ├──POST /tasks/:id/build──> Report build events (done with success/failure) +//! │ └─> Forwarded as SchedulerEvent +//! │ +//! └──POST /tasks/log──> Send structured log entries for storage +//! ``` +//! +//! ## REST agent lifecycle +//! +//! For agents that use HTTP polling instead of WebSocket, three endpoints +//! manage the session lifecycle: +//! +//! - `POST /agent/session` -- create a new session (equivalent to AgentHello) +//! - `POST /agent/heartbeat` -- periodic liveness signal to prevent timeout +//! - `POST /agent/goodbye` -- graceful shutdown, triggers scheduler notification + +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::{json, Value}; +use std::sync::Arc; +use uuid::Uuid; + +use jupiter_api_types::TaskStatus; +use jupiter_db::backend::StorageBackend; +use jupiter_scheduler::engine::SchedulerEvent; + +use crate::state::AppState; + +/// Handle `POST /api/v1/tasks` -- agent polls for the next available task. +/// +/// The agent sends its session ID, supported platforms, and system features. +/// The server scans each platform in order and attempts to dequeue a pending +/// task that matches. If a task is found, it is marked as Running and assigned +/// to the requesting agent. +/// +/// ## Request body +/// +/// ```json +/// { +/// "agentSessionId": "", +/// "platforms": ["x86_64-linux", "aarch64-linux"], +/// "systemFeatures": ["kvm", "big-parallel"] +/// } +/// ``` +/// +/// ## Response +/// +/// - **200 OK** with task details (`taskId`, `taskType`, `payload`) if work +/// is available. +/// - **204 No Content** if no pending tasks match the agent's capabilities. +/// The agent should back off and poll again after a delay. +pub async fn poll_task( + State(state): State>>, + Json(body): Json, +) -> impl IntoResponse { + let agent_session_id = body + .get("agentSessionId") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()); + let platforms: Vec = body + .get("platforms") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + let system_features: Vec = body + .get("systemFeatures") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + let agent_id = match agent_session_id { + Some(id) => id, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "agentSessionId required"})), + ) + .into_response() + } + }; + + // Try to dequeue a pending task for each of the agent's supported + // platforms, in order. The first match wins. + for platform in &platforms { + match state.db.dequeue_task(platform, &system_features).await { + Ok(Some((task_id, task_type, payload))) => { + // Mark the task as running and assign it to this agent session. + let _ = state + .db + .update_task_status(task_id, TaskStatus::Running, Some(agent_id)) + .await; + return Json(json!({ + "taskId": task_id, + "taskType": task_type.to_string(), + "payload": payload, + })) + .into_response(); + } + Ok(None) => continue, + Err(e) => { + return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + } + } + + // No tasks available for any of the agent's platforms. + StatusCode::NO_CONTENT.into_response() +} + +/// Handle `POST /api/v1/tasks/{id}` -- update task status. +/// +/// Agents use this to report status transitions (e.g. from "running" to +/// "success" or "failure"). The status string is parsed into [`TaskStatus`] +/// and stored in the database. +/// +/// ## Request body +/// +/// ```json +/// { "status": "success" } +/// ``` +/// +/// Valid status values depend on [`TaskStatus`]: `"pending"`, `"running"`, +/// `"success"`, `"failure"`, `"cancelled"`. +pub async fn update_task( + State(state): State>>, + Path(task_id): Path, + Json(body): Json, +) -> impl IntoResponse { + let uuid = match Uuid::parse_str(&task_id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + + let status_str = body + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("running"); + + let status = match status_str.parse::() { + Ok(s) => s, + Err(_) => { + return (StatusCode::BAD_REQUEST, "invalid status").into_response(); + } + }; + + match state.db.update_task_status(uuid, status, None).await { + Ok(()) => Json(json!({"id": task_id, "status": status_str})).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `POST /api/v1/tasks/{id}/eval` -- receive evaluation events from +/// an agent. +/// +/// During evaluation, the agent discovers Nix attributes and sends them here. +/// Each event is translated into a [`SchedulerEvent`] for the scheduler: +/// +/// - `"attribute"` -- a new attribute was discovered, with its path and +/// derivation path. Forwarded as `SchedulerEvent::AttributeDiscovered`. +/// - `"done"` -- evaluation is complete. Forwarded as +/// `SchedulerEvent::EvaluationComplete`, which triggers the scheduler to +/// begin dispatching build tasks. +/// +/// The task's `job_id` is resolved from the database to associate the event +/// with the correct job. +pub async fn task_eval_event( + State(state): State>>, + Path(task_id): Path, + Json(body): Json, +) -> impl IntoResponse { + let task_uuid = match Uuid::parse_str(&task_id) { + Ok(u) => u, + Err(_) => return (StatusCode::BAD_REQUEST, "invalid UUID").into_response(), + }; + + // Resolve the job_id from the task record in the database. + let job_id = match state.db.get_task_job_id(task_uuid).await { + Ok(id) => id, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let event_type = body.get("type").and_then(|v| v.as_str()).unwrap_or(""); + + match event_type { + "attribute" => { + let path = body + .get("path") + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + .unwrap_or_default(); + let drv_path = body + .get("derivationPath") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let _ = state + .scheduler_tx + .send(SchedulerEvent::AttributeDiscovered { + job_id, + path, + derivation_path: Some(drv_path), + typ: jupiter_api_types::AttributeType::Regular, + error: None, + }) + .await; + } + "done" => { + let _ = state + .scheduler_tx + .send(SchedulerEvent::EvaluationComplete { + job_id, + task_id: task_uuid, + }) + .await; + } + _ => {} + } + + StatusCode::OK.into_response() +} + +/// Handle `POST /api/v1/tasks/{id}/build` -- receive build events from an agent. +/// +/// Currently supports the `"done"` event type, which reports a build completion +/// with a success/failure flag. The event is forwarded to the scheduler as +/// `SchedulerEvent::BuildComplete` with the derivation path and build ID. +/// +/// ## Request body +/// +/// ```json +/// { +/// "type": "done", +/// "derivationPath": "/nix/store/...", +/// "success": true, +/// "buildId": "" +/// } +/// ``` +pub async fn task_build_event( + State(state): State>>, + Path(_task_id): Path, + Json(body): Json, +) -> impl IntoResponse { + let event_type = body.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let drv_path = body + .get("derivationPath") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + match event_type { + "done" => { + let success = body + .get("success") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let build_id = body + .get("buildId") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .unwrap_or(Uuid::nil()); + let _ = state + .scheduler_tx + .send(SchedulerEvent::BuildComplete { + build_id, + derivation_path: drv_path, + success, + }) + .await; + } + _ => {} + } + + StatusCode::OK.into_response() +} + +/// Handle `POST /api/v1/tasks/log` -- receive structured log entries from an agent. +/// +/// Agents batch log output and send it here for persistent storage. Each entry +/// contains a timestamp, log level, and message. The entries are associated with +/// a task ID so they can be retrieved later via the build/effect log endpoints. +/// +/// ## Request body +/// +/// ```json +/// { +/// "taskId": "", +/// "entries": [ +/// { "i": 0, "o": "stdout", "t": 1234567890, "s": "building..." } +/// ] +/// } +/// ``` +pub async fn task_log( + State(state): State>>, + Json(body): Json, +) -> impl IntoResponse { + let task_id = body + .get("taskId") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()); + let entries = body + .get("entries") + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + .unwrap_or_default(); + + if let Some(tid) = task_id { + let _ = state.db.store_log_entries(tid, &entries).await; + } + + StatusCode::OK.into_response() +} + +/// Handle `POST /api/v1/agent/session` -- create an agent session via REST. +/// +/// This is the HTTP equivalent of the WebSocket AgentHello handshake. Agents +/// that prefer HTTP polling (instead of a persistent WebSocket connection) +/// use this endpoint to register their session, then poll `/tasks` for work. +/// +/// The request body must be a valid [`AgentHello`] JSON object containing +/// hostname, platforms, and agent metadata. +/// +/// Returns 201 Created with the session record. +pub async fn create_agent_session( + State(state): State>>, + Json(body): Json, +) -> impl IntoResponse { + let hello: jupiter_api_types::AgentHello = match serde_json::from_value(body) { + Ok(h) => h, + Err(e) => { + return (StatusCode::BAD_REQUEST, e.to_string()).into_response(); + } + }; + + // Use nil account_id for now; in production, the cluster_join_token + // in the request should be verified to determine the account. + let account_id = Uuid::nil(); + match state.db.create_agent_session(&hello, account_id).await { + Ok(session) => (StatusCode::CREATED, Json(json!(session))).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +/// Handle `POST /api/v1/agent/heartbeat` -- agent liveness signal. +/// +/// Agents send periodic heartbeats to indicate they are still alive and +/// processing tasks. The server updates the session's `last_heartbeat` +/// timestamp. If an agent stops sending heartbeats, the server may +/// eventually garbage-collect its session and reassign tasks. +/// +/// ## Request body +/// +/// ```json +/// { "sessionId": "" } +/// ``` +pub async fn agent_heartbeat( + State(state): State>>, + Json(body): Json, +) -> impl IntoResponse { + let session_id = body + .get("sessionId") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()); + + match session_id { + Some(id) => match state.db.update_agent_heartbeat(id).await { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }, + None => (StatusCode::BAD_REQUEST, "sessionId required").into_response(), + } +} + +/// Handle `POST /api/v1/agent/goodbye` -- graceful agent shutdown. +/// +/// When an agent shuts down cleanly, it sends a goodbye message so the server +/// can immediately clean up the session and notify the scheduler. This is +/// preferable to waiting for heartbeat timeout, as the scheduler can +/// reassign in-progress tasks right away. +/// +/// The session is deleted from the database and a +/// [`SchedulerEvent::AgentDisconnected`] is sent to the scheduler. +/// +/// ## Request body +/// +/// ```json +/// { "sessionId": "" } +/// ``` +pub async fn agent_goodbye( + State(state): State>>, + Json(body): Json, +) -> impl IntoResponse { + let session_id = body + .get("sessionId") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()); + + match session_id { + Some(id) => { + // Delete the session from the database. + let _ = state.db.delete_agent_session(id).await; + // Notify the scheduler so it can reassign any tasks that were + // assigned to this agent. + let _ = state + .scheduler_tx + .send(SchedulerEvent::AgentDisconnected { + agent_session_id: id, + }) + .await; + StatusCode::OK.into_response() + } + None => (StatusCode::BAD_REQUEST, "sessionId required").into_response(), + } +} diff --git a/crates/jupiter-server/src/routes/webhooks.rs b/crates/jupiter-server/src/routes/webhooks.rs new file mode 100644 index 0000000..c42f0f7 --- /dev/null +++ b/crates/jupiter-server/src/routes/webhooks.rs @@ -0,0 +1,175 @@ +//! # Forge webhook endpoints +//! +//! These endpoints receive webhook events from source code forges (GitHub, +//! Gitea) and forward them to the scheduler to trigger CI jobs. The flow is: +//! +//! ```text +//! GitHub/Gitea ──POST /webhooks/github or /webhooks/gitea──> Jupiter +//! │ +//! ├── 1. Extract signature header and event type header +//! ├── 2. Find the matching ForgeProvider by type +//! ├── 3. Verify the HMAC signature against the webhook secret +//! ├── 4. Parse the event payload (push, PR, etc.) +//! └── 5. Send SchedulerEvent::ForgeEvent to the scheduler +//! │ +//! v +//! Scheduler looks up project by repo +//! Creates a new Job for the commit +//! ``` +//! +//! ## Signature verification +//! +//! Each forge uses a different signature scheme: +//! - **GitHub**: HMAC-SHA256 in the `X-Hub-Signature-256` header. +//! - **Gitea**: HMAC-SHA256 in the `X-Gitea-Signature` header. +//! +//! The webhook secret is configured per-forge in `jupiter.toml` and passed to +//! the [`ForgeProvider::verify_webhook`] method. If verification fails, the +//! endpoint returns 401 Unauthorized. +//! +//! ## Supported events +//! +//! The [`ForgeProvider::parse_webhook`] method determines which events are +//! actionable (typically push events and pull request events). Unrecognized +//! event types are silently ignored with a 200 OK response. + +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, +}; +use std::sync::Arc; +use tracing::{info, warn}; + +use jupiter_api_types::ForgeType; +use jupiter_db::backend::StorageBackend; +use jupiter_scheduler::engine::SchedulerEvent; + +use crate::state::AppState; + +/// Handle `POST /api/v1/webhooks/github` -- receive GitHub webhook events. +/// +/// Delegates to [`handle_webhook`] with GitHub-specific header names: +/// - Signature: `X-Hub-Signature-256` (HMAC-SHA256) +/// - Event type: `X-GitHub-Event` (e.g. `"push"`, `"pull_request"`) +pub async fn github_webhook( + State(state): State>>, + headers: HeaderMap, + body: Bytes, +) -> impl IntoResponse { + handle_webhook( + state, + &headers, + &body, + ForgeType::GitHub, + "X-Hub-Signature-256", + "X-GitHub-Event", + ) + .await +} + +/// Handle `POST /api/v1/webhooks/gitea` -- receive Gitea webhook events. +/// +/// Delegates to [`handle_webhook`] with Gitea-specific header names: +/// - Signature: `X-Gitea-Signature` (HMAC-SHA256) +/// - Event type: `X-Gitea-Event` (e.g. `"push"`, `"pull_request"`) +pub async fn gitea_webhook( + State(state): State>>, + headers: HeaderMap, + body: Bytes, +) -> impl IntoResponse { + handle_webhook( + state, + &headers, + &body, + ForgeType::Gitea, + "X-Gitea-Signature", + "X-Gitea-Event", + ) + .await +} + +/// Shared webhook handler for all forge types. +/// +/// This function implements the common webhook processing pipeline: +/// +/// 1. Extract the signature and event type from forge-specific headers. +/// 2. Find the configured [`ForgeProvider`] that matches the given forge type. +/// Returns 404 if no forge of that type is configured. +/// 3. Verify the webhook signature using the forge's secret. Returns 401 if +/// the signature is invalid. +/// 4. Parse the event payload. Returns the parsed event to the scheduler +/// via [`SchedulerEvent::ForgeEvent`], or ignores unsupported event types. +/// +/// # Arguments +/// +/// * `state` -- shared application state containing forge providers and the +/// scheduler channel. +/// * `headers` -- HTTP headers from the webhook request. +/// * `body` -- raw request body bytes (needed for signature verification). +/// * `forge_type` -- which forge sent this webhook (GitHub, Gitea). +/// * `sig_header` -- the header name containing the HMAC signature. +/// * `event_header` -- the header name containing the event type string. +async fn handle_webhook( + state: Arc>, + headers: &HeaderMap, + body: &[u8], + forge_type: ForgeType, + sig_header: &str, + event_header: &str, +) -> impl IntoResponse { + // Extract the signature (optional for some forges) and event type (required). + let signature = headers.get(sig_header).and_then(|v| v.to_str().ok()); + let event_type = match headers.get(event_header).and_then(|v| v.to_str().ok()) { + Some(e) => e, + None => { + return (StatusCode::BAD_REQUEST, "missing event type header").into_response(); + } + }; + + // Find the configured forge provider that matches this forge type. + let forge = state + .forges + .iter() + .find(|(_, f)| f.forge_type() == forge_type); + + let (forge_id, forge) = match forge { + Some((id, f)) => (*id, f.as_ref()), + None => { + return (StatusCode::NOT_FOUND, "forge not configured").into_response(); + } + }; + + // Verify the HMAC signature against the forge's webhook secret. + match forge.verify_webhook(signature, body) { + Ok(true) => {} + Ok(false) => { + return (StatusCode::UNAUTHORIZED, "invalid signature").into_response(); + } + Err(e) => { + return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(); + } + } + + // Parse the event payload and forward to the scheduler if actionable. + match forge.parse_webhook(event_type, body) { + Ok(Some(event)) => { + info!("Received forge event: {:?}", event); + let _ = state + .scheduler_tx + .send(SchedulerEvent::ForgeEvent { forge_id, event }) + .await; + StatusCode::OK.into_response() + } + Ok(None) => { + // Event type is valid but not actionable (e.g. star, fork). + info!("Ignoring unsupported event type: {}", event_type); + StatusCode::OK.into_response() + } + Err(e) => { + warn!("Failed to parse webhook: {}", e); + (StatusCode::BAD_REQUEST, e.to_string()).into_response() + } + } +} diff --git a/crates/jupiter-server/src/state.rs b/crates/jupiter-server/src/state.rs new file mode 100644 index 0000000..0863d55 --- /dev/null +++ b/crates/jupiter-server/src/state.rs @@ -0,0 +1,243 @@ +//! # Shared application state +//! +//! This module defines the core state structures that are shared across all +//! axum handlers via `State>>`. The state is generic over the +//! [`StorageBackend`] trait, allowing the same server code to work against +//! SQLite (for single-node deployments) or PostgreSQL (for production clusters). +//! +//! ## Data flow overview +//! +//! ```text +//! AppState +//! ├── config -- ServerConfig loaded from TOML +//! ├── db -- Arc shared database handle +//! ├── scheduler_tx -- mpsc::Sender to the scheduler loop +//! ├── agent_hub -- Arc> tracking live WebSocket agents +//! └── forges -- Arc> for webhook verification +//! ``` +//! +//! When a webhook arrives or an agent reports results, the handler sends a +//! [`SchedulerEvent`] through `scheduler_tx`. The scheduler processes these +//! events, creates/updates database records, and dispatches new tasks to agents +//! by writing into the appropriate `AgentSessionInfo.tx` channel found in the +//! [`AgentHub`]. +//! +//! ## Task context tracking +//! +//! When the scheduler dispatches a task to an agent, it stores a [`TaskContext`] +//! in the agent's [`AgentSessionInfo::task_contexts`] map. This allows the +//! WebSocket handler to resolve incoming agent messages (which reference a +//! `task_id`) back to the originating `job_id`, `build_id`, or `effect_id` +//! for database updates and scheduler notifications. + +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::{mpsc, RwLock}; +use uuid::Uuid; + +use jupiter_api_types::{AgentSession, ServerConfig, TaskType}; +use jupiter_db::backend::StorageBackend; +use jupiter_forge::ForgeProvider; +use jupiter_scheduler::engine::{SchedulerEngine, SchedulerEvent}; + +/// Central application state shared across all HTTP and WebSocket handlers. +/// +/// This struct is wrapped in `Arc` and passed as axum `State`. It holds +/// everything a handler needs: configuration, database access, the scheduler +/// channel for emitting events, the agent hub for tracking live connections, +/// and the list of configured forge providers. +/// +/// The `scheduler` field is an `Option` because it is taken out via +/// [`take_scheduler`](Self::take_scheduler) during startup and moved into a +/// dedicated tokio task. All clones of `AppState` (produced by the manual +/// `Clone` impl) have `scheduler: None` since the engine is not cloneable +/// and only one instance should ever run. +pub struct AppState { + /// Server configuration loaded from TOML (listen address, JWT secret, etc.). + pub config: ServerConfig, + /// Shared database handle. All handlers read/write through this reference. + pub db: Arc, + /// Sending half of the channel to the [`SchedulerEngine`] event loop. + /// Handlers use this to notify the scheduler of webhook events, agent + /// disconnects, build completions, and other state transitions. + pub scheduler_tx: mpsc::Sender, + /// The scheduler engine itself, present only before [`take_scheduler`] is + /// called during startup. After that this is `None` in all copies. + scheduler: Option>, + /// Registry of all currently connected agents and their WebSocket channels. + /// Protected by an async `RwLock` so multiple readers can inspect agent + /// state concurrently while writes (connect/disconnect) are serialized. + pub agent_hub: Arc>, + /// Configured forge providers (GitHub, Gitea) used by webhook handlers to + /// verify signatures and parse events. Each entry is a `(forge_id, provider)` + /// pair where the `forge_id` is a stable UUID assigned at configuration time. + pub forges: Arc)>>, +} + +/// Registry of all currently connected agents and their WebSocket sessions. +/// +/// The `AgentHub` is the central coordination point between the scheduler +/// (which needs to dispatch tasks to agents) and the WebSocket handler +/// (which manages the actual connections). It is wrapped in +/// `Arc>` inside [`AppState`] so it can be accessed from any +/// handler or background task. +pub struct AgentHub { + /// Map from agent session UUID to the session metadata and channel. + pub sessions: HashMap, +} + +/// Tracks the mapping from a dispatched `task_id` back to the job, build, +/// or effect that created it. +/// +/// When the scheduler creates a task and sends it to an agent, it populates +/// this context. When the agent later reports back (e.g. `BuildDone`, +/// `EffectDone`), the WebSocket handler looks up the context to determine +/// which database records to update and which [`SchedulerEvent`] to emit. +#[derive(Debug, Clone)] +pub struct TaskContext { + /// The unique ID of the task that was dispatched. + #[allow(dead_code)] + pub task_id: Uuid, + /// Whether this task is an evaluation, build, or effect. + #[allow(dead_code)] + pub task_type: TaskType, + /// The job that this task belongs to. Always set. + pub job_id: Uuid, + /// The build record, if this is a build task. + pub build_id: Option, + /// The effect record, if this is an effect task. + pub effect_id: Option, +} + +/// Per-agent session state stored in the [`AgentHub`]. +/// +/// Each connected agent has one of these entries. It contains the database +/// session record, the channel for sending WebSocket messages to the agent, +/// the pending acknowledgement queue for reliable delivery, and the task +/// context map for correlating agent responses to jobs/builds/effects. +pub struct AgentSessionInfo { + /// The database record for this agent session, containing hostname, + /// platform list, and other metadata from the AgentHello handshake. + #[allow(dead_code)] + pub session: AgentSession, + /// Channel for sending serialized JSON frames to the agent over WebSocket. + /// The WebSocket send loop reads from the corresponding receiver. + pub tx: mpsc::Sender, + /// Messages that have been sent to the agent but not yet acknowledged. + /// Each entry is `(sequence_number, payload)`. When an `Ack { n }` frame + /// arrives, all entries with `seq <= n` are removed. This enables retry + /// semantics: if the connection drops, unacknowledged messages can be + /// re-sent on reconnection. + pub pending_acks: Vec<(u64, serde_json::Value)>, + /// The next sequence number to use when sending a `Msg` frame to this agent. + #[allow(dead_code)] + pub next_seq: u64, + /// Maps `task_id` to [`TaskContext`] for all tasks currently dispatched to + /// this agent. Populated when the scheduler sends a task; looked up when + /// the agent reports results (build done, eval done, etc.). + pub task_contexts: HashMap, +} + +impl AgentHub { + /// Create an empty agent hub with no connected sessions. + pub fn new() -> Self { + Self { + sessions: HashMap::new(), + } + } + + /// Register a newly connected agent in the hub. + /// + /// Stores the session metadata and the sending half of the WebSocket + /// message channel. Returns the session UUID for later reference. + /// The agent is now eligible to receive tasks from the scheduler. + pub fn add_session(&mut self, session: AgentSession, tx: mpsc::Sender) -> Uuid { + let id: Uuid = session.id.clone().into(); + self.sessions.insert( + id, + AgentSessionInfo { + session, + tx, + pending_acks: Vec::new(), + next_seq: 1, + task_contexts: HashMap::new(), + }, + ); + id + } + + /// Remove a disconnected agent from the hub. + /// + /// This drops the `tx` channel, which will cause the WebSocket send + /// loop to terminate. Any pending task contexts are also discarded; + /// the scheduler should be notified separately via + /// [`SchedulerEvent::AgentDisconnected`] so it can reassign tasks. + pub fn remove_session(&mut self, id: Uuid) { + self.sessions.remove(&id); + } + + /// Find an agent that advertises support for the given Nix platform + /// string (e.g. `"x86_64-linux"`). + /// + /// This performs a linear scan of all connected sessions and returns + /// the first match. Used by the scheduler when it needs to dispatch + /// a task to a compatible agent. + #[allow(dead_code)] + pub fn find_agent_for_platform(&self, platform: &str) -> Option { + self.sessions + .iter() + .find(|(_, info)| info.session.platforms.contains(&platform.to_string())) + .map(|(id, _)| *id) + } +} + +impl AppState { + /// Create a new `AppState` with a freshly constructed [`SchedulerEngine`]. + /// + /// The scheduler is created internally and its `event_sender()` channel + /// is stored in `scheduler_tx`. The caller must call [`take_scheduler`] + /// to extract the engine and spawn it on a background task before the + /// server starts accepting connections. + pub fn new(config: ServerConfig, db: Arc) -> Self { + let forges: Arc)>> = Arc::new(Vec::new()); + let scheduler = SchedulerEngine::new(db.clone(), forges.clone()); + let scheduler_tx = scheduler.event_sender(); + + Self { + config, + db, + scheduler_tx, + scheduler: Some(scheduler), + agent_hub: Arc::new(RwLock::new(AgentHub::new())), + forges, + } + } + + /// Extract the [`SchedulerEngine`] from this state, leaving `None` behind. + /// + /// This must be called exactly once during startup. The returned engine + /// should be spawned on a background tokio task via `tokio::spawn`. + /// Subsequent clones of `AppState` (used by handlers) will have + /// `scheduler: None`, which is correct since only one engine should run. + pub fn take_scheduler(&mut self) -> Option> { + self.scheduler.take() + } +} + +/// Manual `Clone` implementation because [`SchedulerEngine`] does not +/// implement `Clone`. Cloned copies always have `scheduler: None`; only +/// the original holds the engine (until [`take_scheduler`] extracts it). +/// All other fields are cheaply cloneable (`Arc`, channel senders, etc.). +impl Clone for AppState { + fn clone(&self) -> Self { + Self { + config: self.config.clone(), + db: self.db.clone(), + scheduler_tx: self.scheduler_tx.clone(), + scheduler: None, + agent_hub: self.agent_hub.clone(), + forges: self.forges.clone(), + } + } +} diff --git a/crates/jupiter-server/src/websocket/handler.rs b/crates/jupiter-server/src/websocket/handler.rs new file mode 100644 index 0000000..032fc63 --- /dev/null +++ b/crates/jupiter-server/src/websocket/handler.rs @@ -0,0 +1,524 @@ +//! # WebSocket handler -- Hercules CI agent wire protocol +//! +//! This module contains the core WebSocket logic for communicating with +//! `hercules-ci-agent` instances. It implements the full connection lifecycle: +//! handshake, bidirectional message processing, and cleanup on disconnect. +//! +//! ## Data flow +//! +//! ```text +//! hercules-ci-agent +//! │ +//! │ WebSocket (JSON frames) +//! v +//! ws_handler ──> handle_socket +//! │ +//! ├── send_task (tokio::spawn) +//! │ Reads from agent_rx channel, writes to WebSocket sink. +//! │ The scheduler writes into agent_tx (stored in AgentHub) +//! │ to dispatch tasks. +//! │ +//! └── recv_task (tokio::spawn) +//! Reads from WebSocket stream, dispatches to: +//! ├── Msg: process_agent_message() ──> SchedulerEvent +//! ├── Ack: removes entries from pending_acks +//! ├── Oob: ignored after handshake +//! └── Exception: logged as warning +//! ``` +//! +//! ## Reliable delivery +//! +//! Each `Msg` frame carries a monotonically increasing sequence number. The +//! receiver sends back an `Ack { n }` to confirm receipt of all messages up +//! to `n`. The sender retains unacknowledged messages in `pending_acks` so +//! they can be retransmitted if the connection is re-established. + +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + State, + }, + response::IntoResponse, +}; +use futures::{SinkExt, StreamExt}; +use serde_json::json; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{mpsc, RwLock}; +use tracing::{error, info, warn}; +use uuid::Uuid; + +use jupiter_api_types::*; +use jupiter_db::backend::StorageBackend; +use jupiter_scheduler::engine::SchedulerEvent; + +use crate::state::{AgentHub, AppState, TaskContext}; + +/// Axum handler for the WebSocket upgrade at `/api/v1/agent/socket`. +/// +/// This is the entry point for agent connections. It accepts the HTTP upgrade +/// request and delegates to [`handle_socket`] which runs the full agent +/// protocol lifecycle on the upgraded WebSocket connection. +pub async fn ws_handler( + ws: WebSocketUpgrade, + State(state): State>>, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, state)) +} + +/// Run the full agent WebSocket protocol on an upgraded connection. +/// +/// This function: +/// 1. Sends the `ServiceInfo` out-of-band frame (version `[2, 0]`). +/// 2. Waits for the agent's `AgentHello` OOB frame containing hostname, +/// platforms, and capabilities. +/// 3. Creates a database session for the agent and sends `Ack { n: 0 }` +/// to complete the three-way handshake. +/// 4. Splits the WebSocket into send/receive halves and spawns two tasks: +/// - **send_task**: forwards messages from the `agent_rx` channel to the +/// WebSocket. Other components (scheduler) write to `agent_tx` in the +/// [`AgentHub`] to send messages to this agent. +/// - **recv_task**: reads frames from the WebSocket and processes them +/// via [`process_agent_message`]. +/// 5. When either task finishes (connection dropped, error, etc.), aborts +/// the other and cleans up: removes the session from the agent hub, +/// deletes it from the database, and notifies the scheduler. +async fn handle_socket(socket: WebSocket, state: Arc>) { + let (mut ws_tx, mut ws_rx) = socket.split(); + + // Step 1: Send ServiceInfo as an out-of-band frame to initiate the + // handshake. The version [2, 0] indicates protocol v2. + let service_info = Frame::Oob { + p: json!({ "version": [2, 0] }), + }; + if let Ok(msg) = serde_json::to_string(&service_info) { + if ws_tx.send(Message::Text(msg.into())).await.is_err() { + return; + } + } + + // Step 2: Wait for the agent's AgentHello OOB frame. This contains the + // agent's hostname, supported Nix platforms, and other metadata needed + // to create a session record. + let agent_hello: AgentHello = loop { + match ws_rx.next().await { + Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { + Ok(Frame::Oob { p }) => match serde_json::from_value::(p) { + Ok(hello) => break hello, + Err(e) => { + warn!("Failed to parse AgentHello: {}", e); + let err = Frame::Exception { + message: format!("Invalid AgentHello: {}", e), + }; + let _ = ws_tx + .send(Message::Text(serde_json::to_string(&err).unwrap().into())) + .await; + return; + } + }, + Ok(_) => continue, + Err(e) => { + warn!("Failed to parse frame: {}", e); + return; + } + }, + Some(Ok(Message::Close(_))) | None => return, + _ => continue, + } + }; + + info!( + "Agent connected: {} (platforms: {:?})", + agent_hello.hostname, agent_hello.platforms + ); + + // Step 3: Create a database session for the agent. The account_id is nil + // for now; in production, the cluster_join_token should be verified to + // determine which account the agent belongs to. + let account_id = Uuid::nil(); + let session = match state + .db + .create_agent_session(&agent_hello, account_id) + .await + { + Ok(s) => s, + Err(e) => { + error!("Failed to create agent session: {}", e); + let err = Frame::Exception { + message: format!("Failed to create session: {}", e), + }; + let _ = ws_tx + .send(Message::Text(serde_json::to_string(&err).unwrap().into())) + .await; + return; + } + }; + + let session_id: Uuid = session.id.clone().into(); + + // Send Ack { n: 0 } to complete the three-way handshake. This tells the + // agent that the server is ready to exchange Msg frames. + let ack = Frame::Ack { n: 0 }; + if let Ok(msg) = serde_json::to_string(&ack) { + if ws_tx.send(Message::Text(msg.into())).await.is_err() { + return; + } + } + + // Step 4: Create a bounded channel for sending messages to this agent. + // The scheduler and other components will write to `agent_tx`, which is + // stored in the AgentHub. The send_task reads from `agent_rx` and + // forwards messages to the WebSocket. + let (agent_tx, mut agent_rx) = mpsc::channel::(100); + + // Register the agent in the hub so the scheduler can find it and + // dispatch tasks to it. + { + let mut hub = state.agent_hub.write().await; + hub.add_session(session, agent_tx); + } + + info!("Agent session {} established", session_id); + + // Spawn the send loop: reads serialized JSON frames from the agent_rx + // channel and writes them to the WebSocket. This task runs until the + // channel is closed (agent disconnects) or a write error occurs. + let mut send_task = tokio::spawn(async move { + while let Some(msg) = agent_rx.recv().await { + if ws_tx.send(Message::Text(msg.into())).await.is_err() { + break; + } + } + }); + + // Spawn the receive loop: reads frames from the WebSocket and processes + // them. Msg frames are dispatched to process_agent_message() which + // translates agent events into SchedulerEvents. Ack frames update the + // pending_acks buffer to track reliable delivery. + let scheduler_tx = state.scheduler_tx.clone(); + let db = state.db.clone(); + let agent_hub = state.agent_hub.clone(); + + let mut recv_task = tokio::spawn(async move { + while let Some(msg_result) = ws_rx.next().await { + match msg_result { + Ok(Message::Text(text)) => match serde_json::from_str::(&text) { + Ok(Frame::Msg { n, p }) => { + // Process the agent's message payload (eval results, + // build completions, log entries, etc.) + if let Err(e) = + process_agent_message(&p, session_id, &scheduler_tx, &db, &agent_hub) + .await + { + warn!("Error processing agent message: {}", e); + } + // Acknowledge receipt of this message so the agent + // can remove it from its retry buffer. + let ack = Frame::Ack { n }; + let hub = agent_hub.read().await; + if let Some(info) = hub.sessions.get(&session_id) { + let _ = info + .tx + .send(serde_json::to_string(&ack).unwrap()) + .await; + } + } + Ok(Frame::Ack { n }) => { + // The agent has acknowledged receipt of our messages + // up to sequence number `n`. Remove those from our + // pending_acks buffer since they no longer need + // retransmission. + let mut hub = agent_hub.write().await; + if let Some(info) = hub.sessions.get_mut(&session_id) { + info.pending_acks.retain(|(seq, _)| *seq > n); + } + } + Ok(Frame::Oob { .. }) => { + // OOB frames are only expected during the handshake + // phase. Ignore any that arrive after. + } + Ok(Frame::Exception { message }) => { + warn!("Agent exception: {}", message); + } + Err(e) => { + warn!("Failed to parse frame: {}", e); + } + }, + Ok(Message::Close(_)) => break, + Ok(Message::Ping(_)) => { + // Pong is handled automatically by axum + } + Err(e) => { + warn!("WebSocket error: {}", e); + break; + } + _ => {} + } + } + }); + + // Step 5: Wait for either the send or receive task to finish, then + // abort the other. This handles both clean disconnects and errors. + tokio::select! { + _ = &mut send_task => { + recv_task.abort(); + } + _ = &mut recv_task => { + send_task.abort(); + } + } + + // Cleanup: remove the agent from the hub, delete the database session, + // and notify the scheduler so it can reassign any in-progress tasks. + info!("Agent session {} disconnected", session_id); + { + let mut hub = state.agent_hub.write().await; + hub.remove_session(session_id); + } + let _ = state.db.delete_agent_session(session_id).await; + + // Notify the scheduler that this agent is no longer available. The + // scheduler will mark any tasks assigned to this agent as failed or + // pending re-dispatch. + let _ = state + .scheduler_tx + .send(SchedulerEvent::AgentDisconnected { + agent_session_id: session_id, + }) + .await; +} + +/// Look up the [`TaskContext`] for a given `task_id` from the agent's context map. +/// +/// Returns the `(job_id, build_id, effect_id)` triple associated with the task. +/// If no context is found (which may happen if the task was dispatched before +/// context tracking was added, or if there is a race condition), returns +/// `(Uuid::nil(), None, None)` with a warning log. +fn resolve_task_context( + task_contexts: &HashMap, + task_id: Uuid, +) -> (Uuid, Option, Option) { + match task_contexts.get(&task_id) { + Some(ctx) => (ctx.job_id, ctx.build_id, ctx.effect_id), + None => { + warn!( + "No task context found for task_id {}, using nil UUIDs", + task_id + ); + (Uuid::nil(), None, None) + } + } +} + +/// Process an incoming message from a connected agent. +/// +/// Agent messages are deserialized into [`AgentMessage`] variants and then +/// translated into appropriate [`SchedulerEvent`]s or database operations. +/// The mapping is: +/// +/// | Agent message | Action | +/// |--------------------|------------------------------------------------------| +/// | `Started` | Logged (informational) | +/// | `Cancelled` | Logged (informational) | +/// | `Attribute` | `SchedulerEvent::AttributeDiscovered` with type info | +/// | `AttributeEffect` | `SchedulerEvent::AttributeDiscovered` (Effect type) | +/// | `AttributeError` | `SchedulerEvent::AttributeDiscovered` with error | +/// | `DerivationInfo` | `SchedulerEvent::DerivationInfoReceived` | +/// | `BuildRequired` | No-op (reserved for future use) | +/// | `EvaluationDone` | `SchedulerEvent::EvaluationComplete` | +/// | `OutputInfo` | No-op (reserved for future use) | +/// | `Pushed` | No-op (reserved for future use) | +/// | `BuildDone` | `SchedulerEvent::BuildComplete` | +/// | `EffectDone` | `SchedulerEvent::EffectComplete` | +/// | `LogItems` | Stored directly to database via `store_log_entries` | +/// +/// Each message that carries a `task_id` is resolved through the agent's +/// [`TaskContext`] map to find the associated `job_id`, `build_id`, or +/// `effect_id` for the scheduler event. +async fn process_agent_message( + payload: &serde_json::Value, + session_id: Uuid, + scheduler_tx: &mpsc::Sender, + db: &Arc, + agent_hub: &Arc>, +) -> anyhow::Result<()> { + let msg: AgentMessage = serde_json::from_value(payload.clone())?; + + // Snapshot the task contexts from the agent hub. This read lock is held + // briefly; we clone the map so we can work with it without holding the + // lock during async scheduler sends. + let task_contexts = { + let hub = agent_hub.read().await; + hub.sessions + .get(&session_id) + .map(|info| info.task_contexts.clone()) + .unwrap_or_default() + }; + + match msg { + AgentMessage::Started { .. } => { + info!("Task started"); + } + AgentMessage::Cancelled { .. } => { + info!("Task cancelled"); + } + AgentMessage::Attribute { + task_id, + path, + derivation_path, + typ, + .. + } => { + // An evaluation task discovered a Nix attribute (e.g. a package + // or a CI job). Forward to the scheduler so it can create build + // records for each discovered derivation. + let task_uuid: Uuid = task_id.into(); + let (job_id, _, _) = resolve_task_context(&task_contexts, task_uuid); + let _ = scheduler_tx + .send(SchedulerEvent::AttributeDiscovered { + job_id, + path, + derivation_path: Some(derivation_path), + typ, + error: None, + }) + .await; + } + AgentMessage::AttributeEffect { + task_id, + path, + derivation_path, + .. + } => { + // An evaluation discovered an effect attribute (side-effecting + // action like deployment). Tagged as AttributeType::Effect so + // the scheduler creates an effect record instead of a build. + let task_uuid: Uuid = task_id.into(); + let (job_id, _, _) = resolve_task_context(&task_contexts, task_uuid); + let _ = scheduler_tx + .send(SchedulerEvent::AttributeDiscovered { + job_id, + path, + derivation_path: Some(derivation_path), + typ: AttributeType::Effect, + error: None, + }) + .await; + } + AgentMessage::AttributeError { + task_id, + path, + error, + .. + } => { + // An evaluation encountered an error while processing an attribute. + // The error is forwarded to the scheduler which will mark the + // attribute as failed in the database. + let task_uuid: Uuid = task_id.into(); + let (job_id, _, _) = resolve_task_context(&task_contexts, task_uuid); + let _ = scheduler_tx + .send(SchedulerEvent::AttributeDiscovered { + job_id, + path, + derivation_path: None, + typ: AttributeType::Regular, + error: Some(error), + }) + .await; + } + AgentMessage::DerivationInfo { + task_id, + derivation_path, + platform, + required_system_features, + input_derivations, + outputs, + .. + } => { + // Detailed info about a derivation (inputs, outputs, platform + // requirements). The scheduler uses this to determine build + // ordering and platform compatibility for task dispatch. + let task_uuid: Uuid = task_id.into(); + let (job_id, _, _) = resolve_task_context(&task_contexts, task_uuid); + let _ = scheduler_tx + .send(SchedulerEvent::DerivationInfoReceived { + job_id, + derivation_path, + platform, + required_system_features, + input_derivations, + outputs, + }) + .await; + } + AgentMessage::BuildRequired { .. } => { + // Reserved for future use: the agent signals that a build + // dependency is required before it can proceed. + } + AgentMessage::EvaluationDone { task_id } => { + // The agent has finished evaluating all attributes for this task. + // The scheduler will transition the job from "evaluating" to + // "building" and begin dispatching build tasks. + let task_uuid: Uuid = task_id.clone().into(); + let (job_id, _, _) = resolve_task_context(&task_contexts, task_uuid); + let _ = scheduler_tx + .send(SchedulerEvent::EvaluationComplete { + job_id, + task_id: task_id.into(), + }) + .await; + } + AgentMessage::OutputInfo { .. } => { + // Reserved for future use: output path info from builds. + } + AgentMessage::Pushed { .. } => { + // Reserved for future use: confirmation that build outputs + // have been pushed to the binary cache. + } + AgentMessage::BuildDone { + task_id, + derivation_path, + success, + .. + } => { + // A build has completed (either successfully or with failure). + // The scheduler updates the build record status and, if all + // builds for a job succeed, transitions the job to completion. + let task_uuid: Uuid = task_id.into(); + let (_, build_id, _) = resolve_task_context(&task_contexts, task_uuid); + let _ = scheduler_tx + .send(SchedulerEvent::BuildComplete { + build_id: build_id.unwrap_or(Uuid::nil()), + derivation_path, + success, + }) + .await; + } + AgentMessage::EffectDone { + task_id, success, .. + } => { + // An effect (side-effecting action like deployment) has completed. + // The scheduler updates the effect record and job status. + let task_uuid: Uuid = task_id.into(); + let (job_id, _, effect_id) = resolve_task_context(&task_contexts, task_uuid); + let _ = scheduler_tx + .send(SchedulerEvent::EffectComplete { + effect_id: effect_id.unwrap_or(Uuid::nil()), + job_id, + success, + }) + .await; + } + AgentMessage::LogItems { + task_id, + log_entries, + } => { + // Structured log output from the agent (build logs, eval logs). + // Stored directly in the database for later retrieval via the + // log endpoints. + let _ = db.store_log_entries(task_id.into(), &log_entries).await; + } + } + + Ok(()) +} diff --git a/crates/jupiter-server/src/websocket/mod.rs b/crates/jupiter-server/src/websocket/mod.rs new file mode 100644 index 0000000..a1e57e8 --- /dev/null +++ b/crates/jupiter-server/src/websocket/mod.rs @@ -0,0 +1,33 @@ +//! # WebSocket module +//! +//! This module implements the Hercules CI agent wire protocol over WebSocket. +//! The `hercules-ci-agent` connects to `/api/v1/agent/socket` and communicates +//! using a framed JSON protocol with sequenced delivery and acknowledgement +//! semantics. +//! +//! ## Wire protocol overview +//! +//! The protocol uses four frame types (defined in `jupiter-api-types`): +//! +//! - **`Oob`** -- out-of-band messages used only during the handshake phase. +//! The server sends `ServiceInfo` (version negotiation), the agent replies +//! with `AgentHello` (hostname, platforms, capabilities). +//! - **`Msg { n, p }`** -- sequenced data messages carrying a payload `p` and +//! sequence number `n`. Each side independently numbers its outgoing messages. +//! - **`Ack { n }`** -- acknowledges receipt of all messages up to sequence `n`. +//! The sender can discard those messages from its retry buffer. +//! - **`Exception`** -- signals a fatal protocol error, typically followed by +//! connection close. +//! +//! ## Connection lifecycle +//! +//! 1. Server sends `Oob(ServiceInfo)` with the protocol version. +//! 2. Agent sends `Oob(AgentHello)` with hostname, platforms, and agent version. +//! 3. Server creates a database session and sends `Ack { n: 0 }` to confirm. +//! 4. Both sides enter the message loop, exchanging `Msg`/`Ack` frames. +//! 5. On disconnect (or error), the session is removed from the agent hub and +//! the scheduler is notified via `SchedulerEvent::AgentDisconnected`. +//! +//! See [`handler`] for the implementation. + +pub mod handler;