Compare commits
8 commits
70ca754fa5
...
1336f998bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
1336f998bf |
|||
|
1e28c31077 |
|||
|
85a43c8ca0 |
|||
|
5bf755960d |
|||
|
569da83b78 |
|||
|
a1c0142fb0 |
|||
|
a132591228 |
|||
|
a127f3f62c |
68 changed files with 2276 additions and 1195 deletions
114
Cargo.lock
generated
114
Cargo.lock
generated
|
|
@ -90,9 +90,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.101"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "ar_archive_writer"
|
||||
|
|
@ -229,9 +229,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
|||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.15.4"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256"
|
||||
checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
|
|
@ -373,9 +373,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.1"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
|
|
@ -421,9 +421,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.43"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
|
|
@ -445,9 +445,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.59"
|
||||
version = "4.5.60"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499"
|
||||
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
|
|
@ -455,9 +455,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.59"
|
||||
version = "4.5.60"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24"
|
||||
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
|
|
@ -823,7 +823,7 @@ dependencies = [
|
|||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml 1.0.2+spec-1.1.0",
|
||||
"toml 1.0.3+spec-1.1.0",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"urlencoding",
|
||||
|
|
@ -849,7 +849,7 @@ dependencies = [
|
|||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml 1.0.2+spec-1.1.0",
|
||||
"toml 1.0.3+spec-1.1.0",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
|
|
@ -1556,9 +1556,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.85"
|
||||
version = "0.3.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
|
||||
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
|
|
@ -1652,7 +1652,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
|
|||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
"redox_syscall 0.7.1",
|
||||
"redox_syscall 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1681,9 +1681,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "libz-sys"
|
||||
version = "1.1.23"
|
||||
version = "1.1.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7"
|
||||
checksum = "4735e9cbde5aac84a5ce588f6b23a90b9b0b528f6c5a8db8a4aff300463a0839"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
|
|
@ -1693,9 +1693,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
|
|
@ -2038,9 +2038,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
|
|
@ -2269,9 +2269,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b"
|
||||
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
|
@ -2301,9 +2301,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.9"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
|
|
@ -2446,9 +2446,9 @@ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
|||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
|
|
@ -2459,9 +2459,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.36"
|
||||
version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
|
|
@ -2572,9 +2572,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
|||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.6.0"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
|
|
@ -2585,9 +2585,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.16.0"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
|
|
@ -3029,9 +3029,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.116"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -3071,9 +3071,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.25.0"
|
||||
version = "3.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.1",
|
||||
|
|
@ -3242,9 +3242,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.0.2+spec-1.1.0"
|
||||
version = "1.0.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1dfefef6a142e93f346b64c160934eb13b5594b84ab378133ac6815cb2bd57f"
|
||||
checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
|
|
@ -3606,9 +3606,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
|||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.108"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
|
||||
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
|
|
@ -3619,9 +3619,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.58"
|
||||
version = "0.4.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
|
||||
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
|
|
@ -3633,9 +3633,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.108"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
|
||||
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
|
|
@ -3643,9 +3643,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.108"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
|
||||
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
|
|
@ -3656,9 +3656,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.108"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
|
||||
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
|
@ -3699,9 +3699,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.85"
|
||||
version = "0.3.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
|
||||
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
|
|
@ -4277,18 +4277,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.39"
|
||||
version = "0.8.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
|
||||
checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.39"
|
||||
version = "0.8.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
|
||||
checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
|
|||
70
Cargo.toml
70
Cargo.toml
|
|
@ -67,6 +67,76 @@ urlencoding = "2.1.3"
|
|||
uuid = { version = "1.18.1", features = [ "v4", "serde" ] }
|
||||
xz2 = "0.1.7"
|
||||
|
||||
# See:
|
||||
# <https://doc.rust-lang.org/rustc/lints/listing/allowed-by-default.html>
|
||||
[workspace.lints.clippy]
|
||||
cargo = { level = "warn", priority = -1 }
|
||||
complexity = { level = "warn", priority = -1 }
|
||||
nursery = { level = "warn", priority = -1 }
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
perf = { level = "warn", priority = -1 }
|
||||
style = { level = "warn", priority = -1 }
|
||||
|
||||
# The lint groups above enable some less-than-desirable rules, we should manually
|
||||
# enable those to keep our sanity.
|
||||
absolute_paths = "allow"
|
||||
arbitrary_source_item_ordering = "allow"
|
||||
clone_on_ref_ptr = "warn"
|
||||
dbg_macro = "warn"
|
||||
empty_drop = "warn"
|
||||
empty_structs_with_brackets = "warn"
|
||||
exit = "warn"
|
||||
filetype_is_file = "warn"
|
||||
get_unwrap = "warn"
|
||||
implicit_return = "allow"
|
||||
infinite_loop = "warn"
|
||||
map_with_unused_argument_over_ranges = "warn"
|
||||
missing_docs_in_private_items = "allow"
|
||||
multiple_crate_versions = "allow" # :(
|
||||
non_ascii_literal = "allow"
|
||||
non_std_lazy_statics = "warn"
|
||||
pathbuf_init_then_push = "warn"
|
||||
pattern_type_mismatch = "allow"
|
||||
question_mark_used = "allow"
|
||||
rc_buffer = "warn"
|
||||
rc_mutex = "warn"
|
||||
rest_pat_in_fully_bound_structs = "warn"
|
||||
similar_names = "allow"
|
||||
single_call_fn = "allow"
|
||||
std_instead_of_core = "allow"
|
||||
too_long_first_doc_paragraph = "allow"
|
||||
too_many_lines = "allow"
|
||||
cast_possible_truncation = "allow"
|
||||
cast_possible_wrap = "allow"
|
||||
cast_precision_loss = "allow"
|
||||
cast_sign_loss = "allow"
|
||||
undocumented_unsafe_blocks = "warn"
|
||||
unnecessary_safety_comment = "warn"
|
||||
unused_result_ok = "warn"
|
||||
unused_trait_names = "allow"
|
||||
|
||||
# False positive:
|
||||
# clippy's build script check doesn't recognize workspace-inherited metadata
|
||||
# which means in our current workspace layout, we get pranked by Clippy.
|
||||
cargo_common_metadata = "allow"
|
||||
|
||||
# In the honor of a recent Cloudflare regression
|
||||
panic = "deny"
|
||||
unwrap_used = "deny"
|
||||
|
||||
# Less dangerous, but we'd like to know
|
||||
# Those must be opt-in, and are fine ONLY in tests and examples.
|
||||
expect_used = "warn"
|
||||
print_stderr = "warn"
|
||||
print_stdout = "warn"
|
||||
todo = "warn"
|
||||
unimplemented = "warn"
|
||||
unreachable = "warn"
|
||||
|
||||
[profile.dev]
|
||||
debug = true
|
||||
opt-level = 0
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
opt-level = "z"
|
||||
|
|
|
|||
|
|
@ -30,11 +30,13 @@ impl std::fmt::Debug for AlertManager {
|
|||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AlertManager")
|
||||
.field("config", &self.config)
|
||||
.finish()
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl AlertManager {
|
||||
/// Create an alert manager from config.
|
||||
#[must_use]
|
||||
pub fn new(config: AlertConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
|
|
@ -42,10 +44,14 @@ impl AlertManager {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
/// Check if alerts are enabled in the config.
|
||||
#[must_use]
|
||||
pub const fn is_enabled(&self) -> bool {
|
||||
self.config.enabled
|
||||
}
|
||||
|
||||
/// Calculate failure rate and dispatch alerts if threshold exceeded.
|
||||
/// Returns the computed failure rate if alerts are enabled.
|
||||
pub async fn check_and_alert(
|
||||
&self,
|
||||
pool: &PgPool,
|
||||
|
|
@ -56,16 +62,15 @@ impl AlertManager {
|
|||
return None;
|
||||
}
|
||||
|
||||
let failure_rate = match build_metrics::calculate_failure_rate(
|
||||
let Ok(failure_rate) = build_metrics::calculate_failure_rate(
|
||||
pool,
|
||||
project_id,
|
||||
jobset_id,
|
||||
self.config.time_window_minutes,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(rate) => rate,
|
||||
Err(_) => return None,
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if failure_rate > self.config.error_threshold {
|
||||
|
|
@ -74,6 +79,7 @@ impl AlertManager {
|
|||
|
||||
if time_since_last >= self.config.time_window_minutes {
|
||||
state.last_alert_at = Utc::now();
|
||||
drop(state);
|
||||
info!(
|
||||
"Alert: failure rate {:.1}% exceeds threshold {:.1}%",
|
||||
failure_rate, self.config.error_threshold
|
||||
|
|
|
|||
|
|
@ -21,11 +21,10 @@ use crate::{
|
|||
/// Supports ${VAR}, $VAR, and ~ for home directory.
|
||||
fn expand_path(path: &str) -> String {
|
||||
let expanded = if path.starts_with('~') {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
path.replacen('~', &home.to_string_lossy(), 1)
|
||||
} else {
|
||||
path.to_string()
|
||||
}
|
||||
std::env::var_os("HOME").map_or_else(
|
||||
|| path.to_string(),
|
||||
|home| path.replacen('~', &home.to_string_lossy(), 1),
|
||||
)
|
||||
} else {
|
||||
path.to_string()
|
||||
};
|
||||
|
|
@ -51,24 +50,25 @@ fn expand_path(path: &str) -> String {
|
|||
|
||||
/// Resolve secret for a webhook from inline value or file.
|
||||
fn resolve_webhook_secret(webhook: &DeclarativeWebhook) -> Option<String> {
|
||||
if let Some(ref secret) = webhook.secret {
|
||||
Some(secret.clone())
|
||||
} else if let Some(ref file) = webhook.secret_file {
|
||||
let expanded = expand_path(file);
|
||||
match std::fs::read_to_string(&expanded) {
|
||||
Ok(s) => Some(s.trim().to_string()),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
forge_type = %webhook.forge_type,
|
||||
file = %expanded,
|
||||
"Failed to read webhook secret file: {e}"
|
||||
);
|
||||
None
|
||||
},
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
webhook.secret.as_ref().map_or_else(
|
||||
|| {
|
||||
webhook.secret_file.as_ref().and_then(|file| {
|
||||
let expanded = expand_path(file);
|
||||
match std::fs::read_to_string(&expanded) {
|
||||
Ok(s) => Some(s.trim().to_string()),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
forge_type = %webhook.forge_type,
|
||||
file = %expanded,
|
||||
"Failed to read webhook secret file: {e}"
|
||||
);
|
||||
None
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
|secret| Some(secret.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bootstrap declarative configuration into the database.
|
||||
|
|
@ -76,6 +76,10 @@ fn resolve_webhook_secret(webhook: &DeclarativeWebhook) -> Option<String> {
|
|||
/// This function is idempotent: running it multiple times with the same config
|
||||
/// produces the same database state. It upserts (insert or update) all
|
||||
/// configured projects, jobsets, API keys, and users.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operations fail.
|
||||
pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
|
||||
if config.projects.is_empty()
|
||||
&& config.api_keys.is_empty()
|
||||
|
|
@ -120,10 +124,10 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
|
|||
let state = decl_jobset.state.as_ref().map(|s| {
|
||||
match s.as_str() {
|
||||
"disabled" => JobsetState::Disabled,
|
||||
"enabled" => JobsetState::Enabled,
|
||||
"one_shot" => JobsetState::OneShot,
|
||||
"one_at_a_time" => JobsetState::OneAtATime,
|
||||
_ => JobsetState::Enabled, // Default to enabled for unknown values
|
||||
_ => JobsetState::Enabled, /* Default to enabled for "enabled" or
|
||||
* unknown values */
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -239,24 +243,25 @@ pub async fn run(pool: &PgPool, config: &DeclarativeConfig) -> Result<()> {
|
|||
// Upsert users
|
||||
for decl_user in &config.users {
|
||||
// Resolve password from inline or file
|
||||
let password = if let Some(ref p) = decl_user.password {
|
||||
Some(p.clone())
|
||||
} else if let Some(ref file) = decl_user.password_file {
|
||||
let expanded = expand_path(file);
|
||||
match std::fs::read_to_string(&expanded) {
|
||||
Ok(p) => Some(p.trim().to_string()),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
username = %decl_user.username,
|
||||
file = %expanded,
|
||||
"Failed to read password file: {e}"
|
||||
);
|
||||
None
|
||||
},
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let password = decl_user.password.as_ref().map_or_else(
|
||||
|| {
|
||||
decl_user.password_file.as_ref().and_then(|file| {
|
||||
let expanded = expand_path(file);
|
||||
match std::fs::read_to_string(&expanded) {
|
||||
Ok(p) => Some(p.trim().to_string()),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
username = %decl_user.username,
|
||||
file = %expanded,
|
||||
"Failed to read password file: {e}"
|
||||
);
|
||||
None
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
|p| Some(p.clone()),
|
||||
);
|
||||
|
||||
// Check if user exists
|
||||
let existing =
|
||||
|
|
|
|||
|
|
@ -202,16 +202,18 @@ pub struct SigningConfig {
|
|||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
#[derive(Default)]
|
||||
pub struct CacheUploadConfig {
|
||||
pub enabled: bool,
|
||||
pub store_uri: Option<String>,
|
||||
/// S3-specific configuration (used when store_uri starts with s3://)
|
||||
/// S3-specific configuration (used when `store_uri` starts with s3://)
|
||||
pub s3: Option<S3CacheConfig>,
|
||||
}
|
||||
|
||||
/// S3-specific cache configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
#[derive(Default)]
|
||||
pub struct S3CacheConfig {
|
||||
/// AWS region (e.g., "us-east-1")
|
||||
pub region: Option<String>,
|
||||
|
|
@ -223,36 +225,12 @@ pub struct S3CacheConfig {
|
|||
pub secret_access_key: Option<String>,
|
||||
/// Session token for temporary credentials (optional)
|
||||
pub session_token: Option<String>,
|
||||
/// Endpoint URL for S3-compatible services (e.g., MinIO)
|
||||
/// Endpoint URL for S3-compatible services (e.g., `MinIO`)
|
||||
pub endpoint_url: Option<String>,
|
||||
/// Whether to use path-style addressing (for MinIO compatibility)
|
||||
/// Whether to use path-style addressing (for `MinIO` compatibility)
|
||||
pub use_path_style: bool,
|
||||
}
|
||||
|
||||
impl Default for S3CacheConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
region: None,
|
||||
prefix: None,
|
||||
access_key_id: None,
|
||||
secret_access_key: None,
|
||||
session_token: None,
|
||||
endpoint_url: None,
|
||||
use_path_style: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CacheUploadConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
store_uri: None,
|
||||
s3: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Declarative project/jobset/api-key/user definitions.
|
||||
/// These are upserted on server startup, enabling fully declarative operation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
|
|
@ -493,6 +471,11 @@ impl Default for DatabaseConfig {
|
|||
}
|
||||
|
||||
impl DatabaseConfig {
|
||||
/// Validate database configuration.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if configuration is invalid.
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
if self.url.is_empty() {
|
||||
return Err(anyhow::anyhow!("Database URL cannot be empty"));
|
||||
|
|
@ -606,6 +589,11 @@ impl Default for CacheConfig {
|
|||
}
|
||||
|
||||
impl Config {
|
||||
/// Load configuration from file and environment variables.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if configuration loading or validation fails.
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let mut settings = config_crate::Config::builder();
|
||||
|
||||
|
|
@ -639,6 +627,11 @@ impl Config {
|
|||
Ok(config)
|
||||
}
|
||||
|
||||
/// Validate all configuration sections.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if any configuration section is invalid.
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
// Validate database URL
|
||||
if self.database.url.is_empty() {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ pub struct Database {
|
|||
}
|
||||
|
||||
impl Database {
|
||||
/// Create a new database connection pool from config.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if connection fails or health check fails.
|
||||
pub async fn new(config: DatabaseConfig) -> anyhow::Result<Self> {
|
||||
info!("Initializing database connection pool");
|
||||
|
||||
|
|
@ -32,11 +37,17 @@ impl Database {
|
|||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
/// Get a reference to the underlying connection pool.
|
||||
#[must_use]
|
||||
pub const fn pool(&self) -> &PgPool {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
/// Run a simple query to verify the database is reachable.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if query fails or returns unexpected result.
|
||||
pub async fn health_check(pool: &PgPool) -> anyhow::Result<()> {
|
||||
debug!("Performing database health check");
|
||||
|
||||
|
|
@ -52,11 +63,17 @@ impl Database {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the connection pool gracefully.
|
||||
pub async fn close(&self) {
|
||||
info!("Closing database connection pool");
|
||||
self.pool.close().await;
|
||||
}
|
||||
|
||||
/// Query database metadata (version, user, address).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if query fails.
|
||||
pub async fn get_connection_info(&self) -> anyhow::Result<ConnectionInfo> {
|
||||
let row = sqlx::query(
|
||||
r"
|
||||
|
|
@ -80,7 +97,9 @@ impl Database {
|
|||
})
|
||||
}
|
||||
|
||||
pub async fn get_pool_stats(&self) -> PoolStats {
|
||||
/// Get current connection pool statistics (size, idle, active).
|
||||
#[must_use]
|
||||
pub fn get_pool_stats(&self) -> PoolStats {
|
||||
let pool = &self.pool;
|
||||
|
||||
PoolStats {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ pub enum CiError {
|
|||
}
|
||||
|
||||
impl CiError {
|
||||
/// Check if this error indicates a disk-full condition.
|
||||
#[must_use]
|
||||
pub fn is_disk_full(&self) -> bool {
|
||||
let msg = self.to_string().to_lowercase();
|
||||
|
|
@ -65,6 +66,10 @@ impl CiError {
|
|||
pub type Result<T> = std::result::Result<T, CiError>;
|
||||
|
||||
/// Check disk space on the given path
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if statfs call fails or path is invalid.
|
||||
pub fn check_disk_space(path: &std::path::Path) -> Result<DiskSpaceInfo> {
|
||||
fn to_gb(bytes: u64) -> f64 {
|
||||
bytes as f64 / 1024.0 / 1024.0 / 1024.0
|
||||
|
|
@ -83,9 +88,9 @@ pub fn check_disk_space(path: &std::path::Path) -> Result<DiskSpaceInfo> {
|
|||
return Err(CiError::Io(std::io::Error::last_os_error()));
|
||||
}
|
||||
|
||||
let bavail = statfs.f_bavail * (statfs.f_bsize as u64);
|
||||
let bfree = statfs.f_bfree * (statfs.f_bsize as u64);
|
||||
let btotal = statfs.f_blocks * (statfs.f_bsize as u64);
|
||||
let bavail = statfs.f_bavail * statfs.f_bsize.cast_unsigned();
|
||||
let bfree = statfs.f_bfree * statfs.f_bsize.cast_unsigned();
|
||||
let btotal = statfs.f_blocks * statfs.f_bsize.cast_unsigned();
|
||||
|
||||
Ok(DiskSpaceInfo {
|
||||
total_gb: to_gb(btotal),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ use uuid::Uuid;
|
|||
/// Remove GC root symlinks with mtime older than `max_age`. Returns count
|
||||
/// removed. Symlinks whose filename matches a UUID in `pinned_build_ids` are
|
||||
/// skipped regardless of age.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if directory read fails.
|
||||
pub fn cleanup_old_roots(
|
||||
roots_dir: &Path,
|
||||
max_age: Duration,
|
||||
|
|
@ -29,23 +33,20 @@ pub fn cleanup_old_roots(
|
|||
let entry = entry?;
|
||||
|
||||
// Check if this root is pinned (filename is a build UUID with keep=true)
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
if let Ok(build_id) = name.parse::<Uuid>() {
|
||||
if pinned_build_ids.contains(&build_id) {
|
||||
debug!(build_id = %build_id, "Skipping pinned GC root");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(name) = entry.file_name().to_str()
|
||||
&& let Ok(build_id) = name.parse::<Uuid>()
|
||||
&& pinned_build_ids.contains(&build_id)
|
||||
{
|
||||
debug!(build_id = %build_id, "Skipping pinned GC root");
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
let Ok(metadata) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let modified = match metadata.modified() {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
let Ok(modified) = metadata.modified() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Ok(age) = now.duration_since(modified)
|
||||
|
|
@ -71,6 +72,11 @@ pub struct GcRoots {
|
|||
}
|
||||
|
||||
impl GcRoots {
|
||||
/// Create a GC roots manager. Creates the directory if enabled.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if directory creation or permission setting fails.
|
||||
pub fn new(roots_dir: PathBuf, enabled: bool) -> std::io::Result<Self> {
|
||||
if enabled {
|
||||
std::fs::create_dir_all(&roots_dir)?;
|
||||
|
|
@ -87,6 +93,10 @@ impl GcRoots {
|
|||
}
|
||||
|
||||
/// Register a GC root for a build output. Returns the symlink path.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if path is invalid or symlink creation fails.
|
||||
pub fn register(
|
||||
&self,
|
||||
build_id: &uuid::Uuid,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ pub struct LogStorage {
|
|||
}
|
||||
|
||||
impl LogStorage {
|
||||
/// Create a log storage instance. Creates the directory if needed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if directory creation fails.
|
||||
pub fn new(log_dir: PathBuf) -> std::io::Result<Self> {
|
||||
std::fs::create_dir_all(&log_dir)?;
|
||||
Ok(Self { log_dir })
|
||||
|
|
@ -27,6 +32,10 @@ impl LogStorage {
|
|||
}
|
||||
|
||||
/// Write build log content to file
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if file write fails.
|
||||
pub fn write_log(
|
||||
&self,
|
||||
build_id: &Uuid,
|
||||
|
|
@ -50,6 +59,10 @@ impl LogStorage {
|
|||
}
|
||||
|
||||
/// Read a build log from disk. Returns None if the file doesn't exist.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if file read fails.
|
||||
pub fn read_log(&self, build_id: &Uuid) -> std::io::Result<Option<String>> {
|
||||
let path = self.log_path(build_id);
|
||||
if !path.exists() {
|
||||
|
|
@ -60,6 +73,10 @@ impl LogStorage {
|
|||
}
|
||||
|
||||
/// Delete a build log
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if file deletion fails.
|
||||
pub fn delete_log(&self, build_id: &Uuid) -> std::io::Result<()> {
|
||||
let path = self.log_path(build_id);
|
||||
if path.exists() {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ use sqlx::{PgPool, Postgres, migrate::MigrateDatabase};
|
|||
use tracing::{error, info, warn};
|
||||
|
||||
/// Runs database migrations and ensures the database exists
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operations or migrations fail.
|
||||
pub async fn run_migrations(database_url: &str) -> anyhow::Result<()> {
|
||||
info!("Starting database migrations");
|
||||
|
||||
|
|
@ -39,6 +43,10 @@ async fn create_connection_pool(database_url: &str) -> anyhow::Result<PgPool> {
|
|||
}
|
||||
|
||||
/// Validates that all required tables exist and have the expected structure
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if schema validation fails or required tables are missing.
|
||||
pub async fn validate_schema(pool: &PgPool) -> anyhow::Result<()> {
|
||||
info!("Validating database schema");
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ pub enum Commands {
|
|||
},
|
||||
}
|
||||
|
||||
/// Execute the CLI command.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if command execution fails.
|
||||
pub async fn run() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
|
|
|
|||
|
|
@ -147,20 +147,23 @@ pub enum BuildStatus {
|
|||
|
||||
impl BuildStatus {
|
||||
/// Returns true if the build has completed (not pending or running).
|
||||
pub fn is_finished(&self) -> bool {
|
||||
#[must_use]
|
||||
pub const fn is_finished(&self) -> bool {
|
||||
!matches!(self, Self::Pending | Self::Running)
|
||||
}
|
||||
|
||||
/// Returns true if the build succeeded.
|
||||
/// Note: Does NOT include CachedFailure - a cached failure is still a
|
||||
/// Note: Does NOT include `CachedFailure` - a cached failure is still a
|
||||
/// failure.
|
||||
pub fn is_success(&self) -> bool {
|
||||
#[must_use]
|
||||
pub const fn is_success(&self) -> bool {
|
||||
matches!(self, Self::Succeeded)
|
||||
}
|
||||
|
||||
/// Returns true if the build completed without needing a retry.
|
||||
/// This includes both successful builds and cached failures.
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
#[must_use]
|
||||
pub const fn is_terminal(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Succeeded
|
||||
|
|
@ -180,7 +183,8 @@ impl BuildStatus {
|
|||
|
||||
/// Returns the database integer representation of this status.
|
||||
/// Note: This uses an internal numbering scheme (0-13), not Hydra exit codes.
|
||||
pub fn as_i32(&self) -> i32 {
|
||||
#[must_use]
|
||||
pub const fn as_i32(&self) -> i32 {
|
||||
match self {
|
||||
Self::Pending => 0,
|
||||
Self::Running => 1,
|
||||
|
|
@ -199,9 +203,10 @@ impl BuildStatus {
|
|||
}
|
||||
}
|
||||
|
||||
/// Converts a database integer to BuildStatus.
|
||||
/// This is the inverse of as_i32() for reading from the database.
|
||||
pub fn from_i32(code: i32) -> Option<Self> {
|
||||
/// Converts a database integer to `BuildStatus`.
|
||||
/// This is the inverse of `as_i32()` for reading from the database.
|
||||
#[must_use]
|
||||
pub const fn from_i32(code: i32) -> Option<Self> {
|
||||
match code {
|
||||
0 => Some(Self::Pending),
|
||||
1 => Some(Self::Running),
|
||||
|
|
@ -221,17 +226,17 @@ impl BuildStatus {
|
|||
}
|
||||
}
|
||||
|
||||
/// Converts a Hydra-compatible exit code to a BuildStatus.
|
||||
/// Converts a Hydra-compatible exit code to a `BuildStatus`.
|
||||
/// Note: These codes follow Hydra's conventions and differ from
|
||||
/// as_i32/from_i32.
|
||||
pub fn from_exit_code(exit_code: i32) -> Self {
|
||||
/// `as_i32/from_i32`.
|
||||
#[must_use]
|
||||
pub const fn from_exit_code(exit_code: i32) -> Self {
|
||||
match exit_code {
|
||||
0 => Self::Succeeded,
|
||||
1 => Self::Failed,
|
||||
2 => Self::DependencyFailed,
|
||||
3 => Self::Aborted,
|
||||
3 | 5 => Self::Aborted, // 5 is obsolete in Hydra, treat as aborted
|
||||
4 => Self::Cancelled,
|
||||
5 => Self::Aborted, // Obsolete in Hydra, treat as aborted
|
||||
6 => Self::FailedWithOutput,
|
||||
7 => Self::Timeout,
|
||||
8 => Self::CachedFailure,
|
||||
|
|
@ -262,7 +267,7 @@ impl std::fmt::Display for BuildStatus {
|
|||
Self::NarSizeLimitExceeded => "nar size limit exceeded",
|
||||
Self::NonDeterministic => "non-deterministic",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
write!(f, "{s}")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -320,7 +325,7 @@ pub mod metric_units {
|
|||
pub const BYTES: &str = "bytes";
|
||||
}
|
||||
|
||||
/// Active jobset view — enabled jobsets joined with project info.
|
||||
/// Active jobsets joined with project info.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct ActiveJobset {
|
||||
pub id: Uuid,
|
||||
|
|
@ -398,7 +403,7 @@ pub struct JobsetInput {
|
|||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Release channel — tracks the latest "good" evaluation for a jobset.
|
||||
/// Tracks the latest "good" evaluation for a jobset.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct Channel {
|
||||
pub id: Uuid,
|
||||
|
|
@ -430,6 +435,21 @@ pub struct RemoteBuilder {
|
|||
pub last_failure: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Parameters for creating or updating a remote builder.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteBuilderParams<'a> {
|
||||
pub name: &'a str,
|
||||
pub ssh_uri: &'a str,
|
||||
pub systems: &'a [String],
|
||||
pub max_jobs: i32,
|
||||
pub speed_factor: i32,
|
||||
pub supported_features: &'a [String],
|
||||
pub mandatory_features: &'a [String],
|
||||
pub enabled: bool,
|
||||
pub public_host_key: Option<&'a str>,
|
||||
pub ssh_key_file: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// User account for authentication and personalization
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct User {
|
||||
|
|
|
|||
|
|
@ -65,12 +65,12 @@ fn to_flake_ref(url: &str) -> String {
|
|||
.unwrap_or(url_trimmed);
|
||||
let without_dotgit = without_scheme.trim_end_matches(".git");
|
||||
|
||||
// github.com/owner/repo → github:owner/repo
|
||||
// github.com/owner/repo -> github:owner/repo
|
||||
if let Some(path) = without_dotgit.strip_prefix("github.com/") {
|
||||
return format!("github:{path}");
|
||||
}
|
||||
|
||||
// gitlab.com/owner/repo → gitlab:owner/repo
|
||||
// gitlab.com/owner/repo -> gitlab:owner/repo
|
||||
if let Some(path) = without_dotgit.strip_prefix("gitlab.com/") {
|
||||
return format!("gitlab:{path}");
|
||||
}
|
||||
|
|
@ -84,6 +84,10 @@ fn to_flake_ref(url: &str) -> String {
|
|||
}
|
||||
|
||||
/// Probe a flake repository to discover its outputs and suggest jobsets.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if nix flake show command fails or times out.
|
||||
pub async fn probe_flake(
|
||||
repo_url: &str,
|
||||
revision: Option<&str>,
|
||||
|
|
@ -157,13 +161,10 @@ pub async fn probe_flake(
|
|||
CiError::NixEval(format!("Failed to parse flake show output: {e}"))
|
||||
})?;
|
||||
|
||||
let top = match raw.as_object() {
|
||||
Some(obj) => obj,
|
||||
None => {
|
||||
return Err(CiError::NixEval(
|
||||
"Unexpected flake show output format".to_string(),
|
||||
));
|
||||
},
|
||||
let Some(top) = raw.as_object() else {
|
||||
return Err(CiError::NixEval(
|
||||
"Unexpected flake show output format".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let mut outputs = Vec::new();
|
||||
|
|
@ -220,7 +221,7 @@ pub async fn probe_flake(
|
|||
}
|
||||
|
||||
// Sort jobsets by priority (highest first)
|
||||
suggested_jobsets.sort_by(|a, b| b.priority.cmp(&a.priority));
|
||||
suggested_jobsets.sort_by_key(|j| std::cmp::Reverse(j.priority));
|
||||
|
||||
// Extract metadata from the flake
|
||||
let metadata = FlakeMetadata {
|
||||
|
|
@ -441,7 +442,7 @@ mod tests {
|
|||
},
|
||||
];
|
||||
|
||||
jobsets.sort_by(|a, b| b.priority.cmp(&a.priority));
|
||||
jobsets.sort_by_key(|j| std::cmp::Reverse(j.priority));
|
||||
assert_eq!(jobsets[0].name, "hydraJobs");
|
||||
assert_eq!(jobsets[1].name, "checks");
|
||||
assert_eq!(jobsets[2].name, "packages");
|
||||
|
|
|
|||
|
|
@ -173,7 +173,151 @@ async fn enqueue_notifications(
|
|||
}
|
||||
}
|
||||
|
||||
/// Send notifications immediately (legacy fire-and-forget behavior)
|
||||
/// Enqueue commit status notifications for GitHub/GitLab/Gitea/Forgejo.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Logs database errors if task creation fails.
|
||||
async fn enqueue_commit_status_notification(
|
||||
pool: &PgPool,
|
||||
build: &Build,
|
||||
project: &Project,
|
||||
commit_hash: &str,
|
||||
config: &NotificationsConfig,
|
||||
) {
|
||||
let max_attempts = config.max_retry_attempts;
|
||||
|
||||
// GitHub commit status
|
||||
if let Some(ref token) = config.github_token
|
||||
&& project.repository_url.contains("github.com")
|
||||
{
|
||||
let payload = serde_json::json!({
|
||||
"type": "github_status",
|
||||
"token": token,
|
||||
"repository_url": project.repository_url,
|
||||
"commit_hash": commit_hash,
|
||||
"build_id": build.id,
|
||||
"build_status": build.status,
|
||||
"build_job": build.job_name,
|
||||
});
|
||||
|
||||
if let Err(e) = repo::notification_tasks::create(
|
||||
pool,
|
||||
"github_status",
|
||||
payload,
|
||||
max_attempts,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!(build_id = %build.id, "Failed to enqueue GitHub status notification: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea/Forgejo commit status
|
||||
if let (Some(url), Some(token)) = (&config.gitea_url, &config.gitea_token) {
|
||||
let payload = serde_json::json!({
|
||||
"type": "gitea_status",
|
||||
"base_url": url,
|
||||
"token": token,
|
||||
"repository_url": project.repository_url,
|
||||
"commit_hash": commit_hash,
|
||||
"build_id": build.id,
|
||||
"build_status": build.status,
|
||||
"build_job": build.job_name,
|
||||
});
|
||||
|
||||
if let Err(e) = repo::notification_tasks::create(
|
||||
pool,
|
||||
"gitea_status",
|
||||
payload,
|
||||
max_attempts,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!(build_id = %build.id, "Failed to enqueue Gitea status notification: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// GitLab commit status
|
||||
if let (Some(url), Some(token)) = (&config.gitlab_url, &config.gitlab_token) {
|
||||
let payload = serde_json::json!({
|
||||
"type": "gitlab_status",
|
||||
"base_url": url,
|
||||
"token": token,
|
||||
"repository_url": project.repository_url,
|
||||
"commit_hash": commit_hash,
|
||||
"build_id": build.id,
|
||||
"build_status": build.status,
|
||||
"build_job": build.job_name,
|
||||
});
|
||||
|
||||
if let Err(e) = repo::notification_tasks::create(
|
||||
pool,
|
||||
"gitlab_status",
|
||||
payload,
|
||||
max_attempts,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!(build_id = %build.id, "Failed to enqueue GitLab status notification: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch commit status notification when a build is created (pending state).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Logs database errors if task creation fails.
|
||||
pub async fn dispatch_build_created(
|
||||
pool: &PgPool,
|
||||
build: &Build,
|
||||
project: &Project,
|
||||
commit_hash: &str,
|
||||
config: &NotificationsConfig,
|
||||
) {
|
||||
if !config.enable_retry_queue {
|
||||
return;
|
||||
}
|
||||
|
||||
enqueue_commit_status_notification(pool, build, project, commit_hash, config)
|
||||
.await;
|
||||
info!(
|
||||
build_id = %build.id,
|
||||
job = %build.job_name,
|
||||
status = %build.status,
|
||||
"Enqueued commit status notification for build creation"
|
||||
);
|
||||
}
|
||||
|
||||
/// Dispatch commit status notification when a build starts (running state).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Logs database errors if task creation fails.
|
||||
pub async fn dispatch_build_started(
|
||||
pool: &PgPool,
|
||||
build: &Build,
|
||||
project: &Project,
|
||||
commit_hash: &str,
|
||||
config: &NotificationsConfig,
|
||||
) {
|
||||
if !config.enable_retry_queue {
|
||||
return;
|
||||
}
|
||||
|
||||
enqueue_commit_status_notification(pool, build, project, commit_hash, config)
|
||||
.await;
|
||||
info!(
|
||||
build_id = %build.id,
|
||||
job = %build.job_name,
|
||||
status = %build.status,
|
||||
"Enqueued commit status notification for build start"
|
||||
);
|
||||
}
|
||||
|
||||
/// Send notifications immediately.
|
||||
/// This is the "legacy" fire-and-forget behavior.
|
||||
async fn send_notifications_immediate(
|
||||
build: &Build,
|
||||
project: &Project,
|
||||
|
|
@ -267,9 +411,7 @@ async fn set_github_status(
|
|||
build: &Build,
|
||||
) {
|
||||
// Parse owner/repo from URL
|
||||
let (owner, repo) = if let Some(v) = parse_github_repo(repo_url) {
|
||||
v
|
||||
} else {
|
||||
let Some((owner, repo)) = parse_github_repo(repo_url) else {
|
||||
warn!("Cannot parse GitHub owner/repo from {repo_url}");
|
||||
return;
|
||||
};
|
||||
|
|
@ -330,9 +472,7 @@ async fn set_gitea_status(
|
|||
build: &Build,
|
||||
) {
|
||||
// Parse owner/repo from URL (try to extract from the gitea URL)
|
||||
let (owner, repo) = if let Some(v) = parse_gitea_repo(repo_url, base_url) {
|
||||
v
|
||||
} else {
|
||||
let Some((owner, repo)) = parse_gitea_repo(repo_url, base_url) else {
|
||||
warn!("Cannot parse Gitea owner/repo from {repo_url}");
|
||||
return;
|
||||
};
|
||||
|
|
@ -390,9 +530,7 @@ async fn set_gitlab_status(
|
|||
build: &Build,
|
||||
) {
|
||||
// Parse project path from URL
|
||||
let project_path = if let Some(p) = parse_gitlab_project(repo_url, base_url) {
|
||||
p
|
||||
} else {
|
||||
let Some(project_path) = parse_gitlab_project(repo_url, base_url) else {
|
||||
warn!("Cannot parse GitLab project from {repo_url}");
|
||||
return;
|
||||
};
|
||||
|
|
@ -606,6 +744,10 @@ async fn send_email_notification(
|
|||
}
|
||||
|
||||
/// Process a notification task from the retry queue
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if notification delivery fails.
|
||||
pub async fn process_notification_task(
|
||||
task: &crate::models::NotificationTask,
|
||||
) -> Result<(), String> {
|
||||
|
|
@ -618,7 +760,7 @@ pub async fn process_notification_task(
|
|||
.as_str()
|
||||
.ok_or("Missing url in webhook payload")?;
|
||||
let status_str = match payload["build_status"].as_str() {
|
||||
Some("succeeded") | Some("cached_failure") => "success",
|
||||
Some("succeeded" | "cached_failure") => "success",
|
||||
Some("failed") => "failure",
|
||||
Some("cancelled") => "cancelled",
|
||||
Some("aborted") => "aborted",
|
||||
|
|
@ -667,9 +809,7 @@ pub async fn process_notification_task(
|
|||
.ok_or_else(|| format!("Cannot parse GitHub repo from {repo_url}"))?;
|
||||
|
||||
let (state, description) = match payload["build_status"].as_str() {
|
||||
Some("succeeded") | Some("cached_failure") => {
|
||||
("success", "Build succeeded")
|
||||
},
|
||||
Some("succeeded" | "cached_failure") => ("success", "Build succeeded"),
|
||||
Some("failed") => ("failure", "Build failed"),
|
||||
Some("running") => ("pending", "Build in progress"),
|
||||
Some("cancelled") => ("error", "Build cancelled"),
|
||||
|
|
@ -721,9 +861,7 @@ pub async fn process_notification_task(
|
|||
.ok_or_else(|| format!("Cannot parse Gitea repo from {repo_url}"))?;
|
||||
|
||||
let (state, description) = match payload["build_status"].as_str() {
|
||||
Some("succeeded") | Some("cached_failure") => {
|
||||
("success", "Build succeeded")
|
||||
},
|
||||
Some("succeeded" | "cached_failure") => ("success", "Build succeeded"),
|
||||
Some("failed") => ("failure", "Build failed"),
|
||||
Some("running") => ("pending", "Build in progress"),
|
||||
Some("cancelled") => ("error", "Build cancelled"),
|
||||
|
|
@ -774,9 +912,7 @@ pub async fn process_notification_task(
|
|||
})?;
|
||||
|
||||
let (state, description) = match payload["build_status"].as_str() {
|
||||
Some("succeeded") | Some("cached_failure") => {
|
||||
("success", "Build succeeded")
|
||||
},
|
||||
Some("succeeded" | "cached_failure") => ("success", "Build succeeded"),
|
||||
Some("failed") => ("failed", "Build failed"),
|
||||
Some("running") => ("running", "Build in progress"),
|
||||
Some("cancelled") => ("canceled", "Build cancelled"),
|
||||
|
|
@ -814,6 +950,14 @@ pub async fn process_notification_task(
|
|||
Ok(())
|
||||
},
|
||||
"email" => {
|
||||
use lettre::{
|
||||
AsyncSmtpTransport,
|
||||
AsyncTransport,
|
||||
Message,
|
||||
Tokio1Executor,
|
||||
transport::smtp::authentication::Credentials,
|
||||
};
|
||||
|
||||
// Email sending is complex, so we'll reuse the existing function
|
||||
// by deserializing the config from payload
|
||||
let email_config: EmailConfig =
|
||||
|
|
@ -841,7 +985,6 @@ pub async fn process_notification_task(
|
|||
.ok_or("Missing build_status")?;
|
||||
let status = match status_str {
|
||||
"succeeded" => BuildStatus::Succeeded,
|
||||
"failed" => BuildStatus::Failed,
|
||||
_ => BuildStatus::Failed,
|
||||
};
|
||||
|
||||
|
|
@ -849,23 +992,13 @@ pub async fn process_notification_task(
|
|||
.as_str()
|
||||
.ok_or("Missing project_name")?;
|
||||
|
||||
// Simplified email send (direct implementation to avoid complex struct
|
||||
// creation)
|
||||
use lettre::{
|
||||
AsyncSmtpTransport,
|
||||
AsyncTransport,
|
||||
Message,
|
||||
Tokio1Executor,
|
||||
transport::smtp::authentication::Credentials,
|
||||
};
|
||||
|
||||
let status_display = match status {
|
||||
BuildStatus::Succeeded => "SUCCESS",
|
||||
_ => "FAILURE",
|
||||
};
|
||||
|
||||
let subject =
|
||||
format!("[FC] {} - {} ({})", status_display, job_name, project_name);
|
||||
format!("[FC] {status_display} - {job_name} ({project_name})");
|
||||
let body = format!(
|
||||
"Build notification from FC CI\n\nProject: {}\nJob: {}\nStatus: \
|
||||
{}\nDerivation: {}\nOutput: {}\nBuild ID: {}\n",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ use crate::{
|
|||
models::ApiKey,
|
||||
};
|
||||
|
||||
/// Create a new API key.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails or key already exists.
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
name: &str,
|
||||
|
|
@ -31,6 +36,11 @@ pub async fn create(
|
|||
})
|
||||
}
|
||||
|
||||
/// Insert or update an API key by hash.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operation fails.
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
name: &str,
|
||||
|
|
@ -50,6 +60,11 @@ pub async fn upsert(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Find an API key by its hash.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_by_hash(
|
||||
pool: &PgPool,
|
||||
key_hash: &str,
|
||||
|
|
@ -61,6 +76,11 @@ pub async fn get_by_hash(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// List all API keys.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list(pool: &PgPool) -> Result<Vec<ApiKey>> {
|
||||
sqlx::query_as::<_, ApiKey>("SELECT * FROM api_keys ORDER BY created_at DESC")
|
||||
.fetch_all(pool)
|
||||
|
|
@ -68,6 +88,11 @@ pub async fn list(pool: &PgPool) -> Result<Vec<ApiKey>> {
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Delete an API key by ID.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails or key not found.
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM api_keys WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -79,6 +104,11 @@ pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the `last_used_at` timestamp for an API key.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails.
|
||||
pub async fn touch_last_used(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
sqlx::query("UPDATE api_keys SET last_used_at = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ use crate::{
|
|||
models::BuildDependency,
|
||||
};
|
||||
|
||||
/// Create a build dependency relationship.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails or dependency already exists.
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
build_id: Uuid,
|
||||
|
|
@ -31,6 +36,11 @@ pub async fn create(
|
|||
})
|
||||
}
|
||||
|
||||
/// List all dependencies for a build.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_for_build(
|
||||
pool: &PgPool,
|
||||
build_id: Uuid,
|
||||
|
|
@ -46,6 +56,10 @@ pub async fn list_for_build(
|
|||
|
||||
/// Batch check if all dependency builds are completed for multiple builds at
|
||||
/// once. Returns a map from `build_id` to whether all deps are completed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn check_deps_for_builds(
|
||||
pool: &PgPool,
|
||||
build_ids: &[Uuid],
|
||||
|
|
@ -77,6 +91,10 @@ pub async fn check_deps_for_builds(
|
|||
}
|
||||
|
||||
/// Check if all dependency builds for a given build are completed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn all_deps_completed(pool: &PgPool, build_id: Uuid) -> Result<bool> {
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM build_dependencies bd JOIN builds b ON \
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ use crate::{
|
|||
models::BuildMetric,
|
||||
};
|
||||
|
||||
type PercentileRow = (DateTime<Utc>, Option<f64>, Option<f64>, Option<f64>);
|
||||
|
||||
/// Time-series data point for metrics visualization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TimeseriesPoint {
|
||||
|
|
@ -32,6 +34,11 @@ pub struct DurationPercentiles {
|
|||
pub p99: Option<f64>,
|
||||
}
|
||||
|
||||
/// Insert or update a build metric.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operation fails.
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
build_id: Uuid,
|
||||
|
|
@ -54,6 +61,11 @@ pub async fn upsert(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Calculate build failure rate over a time window.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn calculate_failure_rate(
|
||||
pool: &PgPool,
|
||||
project_id: Option<Uuid>,
|
||||
|
|
@ -87,6 +99,10 @@ pub async fn calculate_failure_rate(
|
|||
|
||||
/// Get build success/failure counts over time.
|
||||
/// Buckets builds by time interval for charting.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_build_stats_timeseries(
|
||||
pool: &PgPool,
|
||||
project_id: Option<Uuid>,
|
||||
|
|
@ -136,6 +152,10 @@ pub async fn get_build_stats_timeseries(
|
|||
}
|
||||
|
||||
/// Get build duration percentiles over time.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_duration_percentiles_timeseries(
|
||||
pool: &PgPool,
|
||||
project_id: Option<Uuid>,
|
||||
|
|
@ -143,18 +163,17 @@ pub async fn get_duration_percentiles_timeseries(
|
|||
hours: i32,
|
||||
bucket_minutes: i32,
|
||||
) -> Result<Vec<DurationPercentiles>> {
|
||||
let rows: Vec<(DateTime<Utc>, Option<f64>, Option<f64>, Option<f64>)> =
|
||||
sqlx::query_as(
|
||||
"SELECT
|
||||
let rows: Vec<PercentileRow> = sqlx::query_as(
|
||||
"SELECT
|
||||
date_trunc('minute', b.completed_at) +
|
||||
(EXTRACT(MINUTE FROM b.completed_at)::int / $4) * INTERVAL '1 minute' \
|
||||
* $4 AS bucket_time,
|
||||
* $4 AS bucket_time,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM \
|
||||
(b.completed_at - b.started_at))) AS p50,
|
||||
(b.completed_at - b.started_at))) AS p50,
|
||||
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM \
|
||||
(b.completed_at - b.started_at))) AS p95,
|
||||
(b.completed_at - b.started_at))) AS p95,
|
||||
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM \
|
||||
(b.completed_at - b.started_at))) AS p99
|
||||
(b.completed_at - b.started_at))) AS p99
|
||||
FROM builds b
|
||||
JOIN evaluations e ON b.evaluation_id = e.id
|
||||
JOIN jobsets j ON e.jobset_id = j.id
|
||||
|
|
@ -165,14 +184,14 @@ pub async fn get_duration_percentiles_timeseries(
|
|||
AND ($3::uuid IS NULL OR j.id = $3)
|
||||
GROUP BY bucket_time
|
||||
ORDER BY bucket_time ASC",
|
||||
)
|
||||
.bind(hours)
|
||||
.bind(project_id)
|
||||
.bind(jobset_id)
|
||||
.bind(bucket_minutes)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
)
|
||||
.bind(hours)
|
||||
.bind(project_id)
|
||||
.bind(jobset_id)
|
||||
.bind(bucket_minutes)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)?;
|
||||
|
||||
Ok(
|
||||
rows
|
||||
|
|
@ -190,6 +209,10 @@ pub async fn get_duration_percentiles_timeseries(
|
|||
}
|
||||
|
||||
/// Get queue depth over time.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_queue_depth_timeseries(
|
||||
pool: &PgPool,
|
||||
hours: i32,
|
||||
|
|
@ -228,6 +251,10 @@ pub async fn get_queue_depth_timeseries(
|
|||
}
|
||||
|
||||
/// Get per-system build distribution.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_system_distribution(
|
||||
pool: &PgPool,
|
||||
project_id: Option<Uuid>,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ use crate::{
|
|||
models::{BuildProduct, CreateBuildProduct},
|
||||
};
|
||||
|
||||
/// Create a build product record.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails.
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateBuildProduct,
|
||||
|
|
@ -27,6 +32,11 @@ pub async fn create(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Get a build product by ID.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails or product not found.
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<BuildProduct> {
|
||||
sqlx::query_as::<_, BuildProduct>(
|
||||
"SELECT * FROM build_products WHERE id = $1",
|
||||
|
|
@ -37,6 +47,11 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<BuildProduct> {
|
|||
.ok_or_else(|| CiError::NotFound(format!("Build product {id} not found")))
|
||||
}
|
||||
|
||||
/// List all build products for a build.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_for_build(
|
||||
pool: &PgPool,
|
||||
build_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ use crate::{
|
|||
models::{BuildStep, CreateBuildStep},
|
||||
};
|
||||
|
||||
/// Create a build step record.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails or step already exists.
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateBuildStep,
|
||||
|
|
@ -32,6 +37,11 @@ pub async fn create(
|
|||
})
|
||||
}
|
||||
|
||||
/// Mark a build step as completed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails or step not found.
|
||||
pub async fn complete(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
|
|
@ -52,6 +62,11 @@ pub async fn complete(
|
|||
.ok_or_else(|| CiError::NotFound(format!("Build step {id} not found")))
|
||||
}
|
||||
|
||||
/// List all build steps for a build.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_for_build(
|
||||
pool: &PgPool,
|
||||
build_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ use crate::{
|
|||
models::{Build, BuildStats, BuildStatus, CreateBuild},
|
||||
};
|
||||
|
||||
/// Create a new build record in pending state.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails or job already exists.
|
||||
pub async fn create(pool: &PgPool, input: CreateBuild) -> Result<Build> {
|
||||
let is_aggregate = input.is_aggregate.unwrap_or(false);
|
||||
sqlx::query_as::<_, Build>(
|
||||
|
|
@ -35,6 +40,11 @@ pub async fn create(pool: &PgPool, input: CreateBuild) -> Result<Build> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Find a succeeded build by derivation path (for build result caching).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_completed_by_drv_path(
|
||||
pool: &PgPool,
|
||||
drv_path: &str,
|
||||
|
|
@ -48,6 +58,11 @@ pub async fn get_completed_by_drv_path(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Get a build by ID.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails or build not found.
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Build> {
|
||||
sqlx::query_as::<_, Build>("SELECT * FROM builds WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -56,6 +71,11 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<Build> {
|
|||
.ok_or_else(|| CiError::NotFound(format!("Build {id} not found")))
|
||||
}
|
||||
|
||||
/// List all builds for a given evaluation.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_for_evaluation(
|
||||
pool: &PgPool,
|
||||
evaluation_id: Uuid,
|
||||
|
|
@ -69,6 +89,12 @@ pub async fn list_for_evaluation(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// List pending builds, prioritizing non-aggregate jobs.
|
||||
/// Returns up to `limit * worker_count` builds.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_pending(
|
||||
pool: &PgPool,
|
||||
limit: i64,
|
||||
|
|
@ -99,6 +125,10 @@ pub async fn list_pending(
|
|||
|
||||
/// Atomically claim a pending build by setting it to running.
|
||||
/// Returns `None` if the build was already claimed by another worker.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails.
|
||||
pub async fn start(pool: &PgPool, id: Uuid) -> Result<Option<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET status = 'running', started_at = NOW() WHERE id = $1 \
|
||||
|
|
@ -110,6 +140,11 @@ pub async fn start(pool: &PgPool, id: Uuid) -> Result<Option<Build>> {
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Mark a build as completed with final status and outputs.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails or build not found.
|
||||
pub async fn complete(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
|
|
@ -132,6 +167,11 @@ pub async fn complete(
|
|||
.ok_or_else(|| CiError::NotFound(format!("Build {id} not found")))
|
||||
}
|
||||
|
||||
/// List recent builds ordered by creation time.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_recent(pool: &PgPool, limit: i64) -> Result<Vec<Build>> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"SELECT * FROM builds ORDER BY created_at DESC LIMIT $1",
|
||||
|
|
@ -142,6 +182,11 @@ pub async fn list_recent(pool: &PgPool, limit: i64) -> Result<Vec<Build>> {
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// List all builds for a project.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -157,6 +202,11 @@ pub async fn list_for_project(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Get aggregate build statistics.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_stats(pool: &PgPool) -> Result<BuildStats> {
|
||||
match sqlx::query_as::<_, BuildStats>("SELECT * FROM build_stats")
|
||||
.fetch_optional(pool)
|
||||
|
|
@ -178,6 +228,10 @@ pub async fn get_stats(pool: &PgPool) -> Result<BuildStats> {
|
|||
|
||||
/// Reset builds that were left in 'running' state (orphaned by a crashed
|
||||
/// runner). Limited to 50 builds per call to prevent thundering herd.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails.
|
||||
pub async fn reset_orphaned(
|
||||
pool: &PgPool,
|
||||
older_than_secs: i64,
|
||||
|
|
@ -197,6 +251,10 @@ pub async fn reset_orphaned(
|
|||
|
||||
/// List builds with optional `evaluation_id`, status, system, and `job_name`
|
||||
/// filters, with pagination.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_filtered(
|
||||
pool: &PgPool,
|
||||
evaluation_id: Option<Uuid>,
|
||||
|
|
@ -223,6 +281,11 @@ pub async fn list_filtered(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Count builds matching filter criteria.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn count_filtered(
|
||||
pool: &PgPool,
|
||||
evaluation_id: Option<Uuid>,
|
||||
|
|
@ -247,6 +310,10 @@ pub async fn count_filtered(
|
|||
|
||||
/// Return the subset of the given build IDs whose status is 'cancelled'.
|
||||
/// Used by the cancel-checker loop to detect builds cancelled while running.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_cancelled_among(
|
||||
pool: &PgPool,
|
||||
build_ids: &[Uuid],
|
||||
|
|
@ -265,6 +332,11 @@ pub async fn get_cancelled_among(
|
|||
Ok(rows.into_iter().map(|(id,)| id).collect())
|
||||
}
|
||||
|
||||
/// Cancel a build.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails or build not in cancellable state.
|
||||
pub async fn cancel(pool: &PgPool, id: Uuid) -> Result<Build> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET status = 'cancelled', completed_at = NOW() WHERE id = \
|
||||
|
|
@ -281,6 +353,10 @@ pub async fn cancel(pool: &PgPool, id: Uuid) -> Result<Build> {
|
|||
}
|
||||
|
||||
/// Cancel a build and all its transitive dependents.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn cancel_cascade(pool: &PgPool, id: Uuid) -> Result<Vec<Build>> {
|
||||
let mut cancelled = Vec::new();
|
||||
|
||||
|
|
@ -312,7 +388,11 @@ pub async fn cancel_cascade(pool: &PgPool, id: Uuid) -> Result<Vec<Build>> {
|
|||
}
|
||||
|
||||
/// Restart a build by resetting it to pending state.
|
||||
/// Only works for failed, succeeded, cancelled, or cached_failure builds.
|
||||
/// Only works for failed, succeeded, cancelled, or `cached_failure` builds.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails or build not in restartable state.
|
||||
pub async fn restart(pool: &PgPool, id: Uuid) -> Result<Build> {
|
||||
let build = sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET status = 'pending', started_at = NULL, completed_at = \
|
||||
|
|
@ -339,6 +419,10 @@ pub async fn restart(pool: &PgPool, id: Uuid) -> Result<Build> {
|
|||
}
|
||||
|
||||
/// Mark a build's outputs as signed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails.
|
||||
pub async fn mark_signed(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
sqlx::query("UPDATE builds SET signed = true WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -350,6 +434,10 @@ pub async fn mark_signed(pool: &PgPool, id: Uuid) -> Result<()> {
|
|||
|
||||
/// Batch-fetch completed builds by derivation paths.
|
||||
/// Returns a map from `drv_path` to Build for deduplication.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_completed_by_drv_paths(
|
||||
pool: &PgPool,
|
||||
drv_paths: &[String],
|
||||
|
|
@ -375,6 +463,10 @@ pub async fn get_completed_by_drv_paths(
|
|||
}
|
||||
|
||||
/// Return the set of build IDs that have `keep = true` (GC-pinned).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_pinned_ids(
|
||||
pool: &PgPool,
|
||||
) -> Result<std::collections::HashSet<Uuid>> {
|
||||
|
|
@ -387,6 +479,10 @@ pub async fn list_pinned_ids(
|
|||
}
|
||||
|
||||
/// Set the `keep` (GC pin) flag on a build.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails or build not found.
|
||||
pub async fn set_keep(pool: &PgPool, id: Uuid, keep: bool) -> Result<Build> {
|
||||
sqlx::query_as::<_, Build>(
|
||||
"UPDATE builds SET keep = $1 WHERE id = $2 RETURNING *",
|
||||
|
|
@ -399,6 +495,10 @@ pub async fn set_keep(pool: &PgPool, id: Uuid, keep: bool) -> Result<Build> {
|
|||
}
|
||||
|
||||
/// Set the `builder_id` for a build.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails.
|
||||
pub async fn set_builder(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ use crate::{
|
|||
models::{Channel, CreateChannel},
|
||||
};
|
||||
|
||||
/// Create a release channel.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails or channel already exists.
|
||||
pub async fn create(pool: &PgPool, input: CreateChannel) -> Result<Channel> {
|
||||
sqlx::query_as::<_, Channel>(
|
||||
"INSERT INTO channels (project_id, name, jobset_id) VALUES ($1, $2, $3) \
|
||||
|
|
@ -30,6 +35,11 @@ pub async fn create(pool: &PgPool, input: CreateChannel) -> Result<Channel> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Get a channel by ID.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails or channel not found.
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Channel> {
|
||||
sqlx::query_as::<_, Channel>("SELECT * FROM channels WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -38,6 +48,11 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<Channel> {
|
|||
.ok_or_else(|| CiError::NotFound(format!("Channel {id} not found")))
|
||||
}
|
||||
|
||||
/// List all channels for a project.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -51,6 +66,11 @@ pub async fn list_for_project(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// List all channels.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_all(pool: &PgPool) -> Result<Vec<Channel>> {
|
||||
sqlx::query_as::<_, Channel>("SELECT * FROM channels ORDER BY name")
|
||||
.fetch_all(pool)
|
||||
|
|
@ -59,6 +79,10 @@ pub async fn list_all(pool: &PgPool) -> Result<Vec<Channel>> {
|
|||
}
|
||||
|
||||
/// Promote an evaluation to a channel (set it as the current evaluation).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails or channel not found.
|
||||
pub async fn promote(
|
||||
pool: &PgPool,
|
||||
channel_id: Uuid,
|
||||
|
|
@ -75,6 +99,11 @@ pub async fn promote(
|
|||
.ok_or_else(|| CiError::NotFound(format!("Channel {channel_id} not found")))
|
||||
}
|
||||
|
||||
/// Delete a channel.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails or channel not found.
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM channels WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -88,6 +117,10 @@ pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
|||
}
|
||||
|
||||
/// Upsert a channel (insert or update on conflict).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operation fails.
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -109,6 +142,10 @@ pub async fn upsert(
|
|||
|
||||
/// Sync channels from declarative config.
|
||||
/// Deletes channels not in the declarative list and upserts those that are.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operations fail.
|
||||
pub async fn sync_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -146,6 +183,10 @@ pub async fn sync_for_project(
|
|||
|
||||
/// Find the channel for a jobset and auto-promote if all builds in the
|
||||
/// evaluation succeeded.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operations fail.
|
||||
pub async fn auto_promote_if_complete(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
|
|
@ -166,7 +207,7 @@ pub async fn auto_promote_if_complete(
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
// All builds completed — promote to any channels tracking this jobset
|
||||
// All builds completed, promote to any channels tracking this jobset
|
||||
let channels =
|
||||
sqlx::query_as::<_, Channel>("SELECT * FROM channels WHERE jobset_id = $1")
|
||||
.bind(jobset_id)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ use crate::{
|
|||
models::{CreateEvaluation, Evaluation, EvaluationStatus},
|
||||
};
|
||||
|
||||
/// Create a new evaluation in pending state.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails or evaluation already exists.
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateEvaluation,
|
||||
|
|
@ -36,6 +41,11 @@ pub async fn create(
|
|||
})
|
||||
}
|
||||
|
||||
/// Get an evaluation by ID.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails or evaluation not found.
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Evaluation> {
|
||||
sqlx::query_as::<_, Evaluation>("SELECT * FROM evaluations WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -44,6 +54,11 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<Evaluation> {
|
|||
.ok_or_else(|| CiError::NotFound(format!("Evaluation {id} not found")))
|
||||
}
|
||||
|
||||
/// List all evaluations for a jobset.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_for_jobset(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
|
|
@ -60,6 +75,10 @@ pub async fn list_for_jobset(
|
|||
|
||||
/// List evaluations with optional `jobset_id` and status filters, with
|
||||
/// pagination.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_filtered(
|
||||
pool: &PgPool,
|
||||
jobset_id: Option<Uuid>,
|
||||
|
|
@ -81,6 +100,11 @@ pub async fn list_filtered(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Count evaluations matching filter criteria.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn count_filtered(
|
||||
pool: &PgPool,
|
||||
jobset_id: Option<Uuid>,
|
||||
|
|
@ -98,6 +122,11 @@ pub async fn count_filtered(
|
|||
Ok(row.0)
|
||||
}
|
||||
|
||||
/// Update evaluation status and optional error message.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails or evaluation not found.
|
||||
pub async fn update_status(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
|
|
@ -116,6 +145,11 @@ pub async fn update_status(
|
|||
.ok_or_else(|| CiError::NotFound(format!("Evaluation {id} not found")))
|
||||
}
|
||||
|
||||
/// Get the latest evaluation for a jobset.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_latest(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
|
|
@ -131,6 +165,10 @@ pub async fn get_latest(
|
|||
}
|
||||
|
||||
/// Set the inputs hash for an evaluation (used for eval caching).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails.
|
||||
pub async fn set_inputs_hash(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
|
|
@ -147,6 +185,10 @@ pub async fn set_inputs_hash(
|
|||
|
||||
/// Check if an evaluation with the same `inputs_hash` already exists for this
|
||||
/// jobset.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_by_inputs_hash(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
|
|
@ -163,6 +205,11 @@ pub async fn get_by_inputs_hash(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Count total evaluations.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn count(pool: &PgPool) -> Result<i64> {
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM evaluations")
|
||||
.fetch_one(pool)
|
||||
|
|
@ -171,7 +218,11 @@ pub async fn count(pool: &PgPool) -> Result<i64> {
|
|||
Ok(row.0)
|
||||
}
|
||||
|
||||
/// Get an evaluation by jobset_id and commit_hash.
|
||||
/// Get an evaluation by `jobset_id` and `commit_hash`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_by_jobset_and_commit(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ use crate::{
|
|||
models::BuildStatus,
|
||||
};
|
||||
|
||||
/// Check if a derivation path is in the failed paths cache.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn is_cached_failure(pool: &PgPool, drv_path: &str) -> Result<bool> {
|
||||
let row: Option<(bool,)> =
|
||||
sqlx::query_as("SELECT true FROM failed_paths_cache WHERE drv_path = $1")
|
||||
|
|
@ -17,6 +22,11 @@ pub async fn is_cached_failure(pool: &PgPool, drv_path: &str) -> Result<bool> {
|
|||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
/// Insert a failed derivation path into the cache.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails.
|
||||
pub async fn insert(
|
||||
pool: &PgPool,
|
||||
drv_path: &str,
|
||||
|
|
@ -40,6 +50,11 @@ pub async fn insert(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a derivation path from the failed paths cache.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails.
|
||||
pub async fn invalidate(pool: &PgPool, drv_path: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM failed_paths_cache WHERE drv_path = $1")
|
||||
.bind(drv_path)
|
||||
|
|
@ -50,6 +65,11 @@ pub async fn invalidate(pool: &PgPool, drv_path: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove expired entries from the failed paths cache.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails.
|
||||
pub async fn cleanup_expired(pool: &PgPool, ttl_seconds: u64) -> Result<u64> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM failed_paths_cache WHERE failed_at < NOW() - \
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ use crate::{
|
|||
models::JobsetInput,
|
||||
};
|
||||
|
||||
/// Create a new jobset input.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails or input already exists.
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
|
|
@ -38,6 +43,11 @@ pub async fn create(
|
|||
})
|
||||
}
|
||||
|
||||
/// List all inputs for a jobset.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_for_jobset(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
|
|
@ -51,6 +61,11 @@ pub async fn list_for_jobset(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Delete a jobset input.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails or input not found.
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM jobset_inputs WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -63,6 +78,10 @@ pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
|||
}
|
||||
|
||||
/// Upsert a jobset input (insert or update on conflict).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operation fails.
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
|
|
@ -89,6 +108,10 @@ pub async fn upsert(
|
|||
|
||||
/// Sync jobset inputs from declarative config.
|
||||
/// Deletes inputs not in the config and upserts those that are.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operations fail.
|
||||
pub async fn sync_for_jobset(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ use crate::{
|
|||
models::{ActiveJobset, CreateJobset, Jobset, JobsetState, UpdateJobset},
|
||||
};
|
||||
|
||||
/// Create a new jobset with defaults applied.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails or jobset already exists.
|
||||
pub async fn create(pool: &PgPool, input: CreateJobset) -> Result<Jobset> {
|
||||
let state = input.state.unwrap_or(JobsetState::Enabled);
|
||||
// Sync enabled with state if state was explicitly set, otherwise use
|
||||
|
|
@ -50,6 +55,11 @@ pub async fn create(pool: &PgPool, input: CreateJobset) -> Result<Jobset> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Get a jobset by ID.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails or jobset not found.
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Jobset> {
|
||||
sqlx::query_as::<_, Jobset>("SELECT * FROM jobsets WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -58,6 +68,11 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<Jobset> {
|
|||
.ok_or_else(|| CiError::NotFound(format!("Jobset {id} not found")))
|
||||
}
|
||||
|
||||
/// List all jobsets for a project.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -76,6 +91,11 @@ pub async fn list_for_project(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Count jobsets for a project.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn count_for_project(pool: &PgPool, project_id: Uuid) -> Result<i64> {
|
||||
let row: (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM jobsets WHERE project_id = $1")
|
||||
|
|
@ -86,6 +106,11 @@ pub async fn count_for_project(pool: &PgPool, project_id: Uuid) -> Result<i64> {
|
|||
Ok(row.0)
|
||||
}
|
||||
|
||||
/// Update a jobset with partial fields.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails or jobset not found.
|
||||
pub async fn update(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
|
|
@ -139,6 +164,11 @@ pub async fn update(
|
|||
})
|
||||
}
|
||||
|
||||
/// Delete a jobset.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails or jobset not found.
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM jobsets WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -152,6 +182,11 @@ pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert or update a jobset by name.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operation fails.
|
||||
pub async fn upsert(pool: &PgPool, input: CreateJobset) -> Result<Jobset> {
|
||||
let state = input.state.unwrap_or(JobsetState::Enabled);
|
||||
// Sync enabled with state if state was explicitly set, otherwise use
|
||||
|
|
@ -191,6 +226,11 @@ pub async fn upsert(pool: &PgPool, input: CreateJobset) -> Result<Jobset> {
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// List all active jobsets with project info.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_active(pool: &PgPool) -> Result<Vec<ActiveJobset>> {
|
||||
sqlx::query_as::<_, ActiveJobset>("SELECT * FROM active_jobsets")
|
||||
.fetch_all(pool)
|
||||
|
|
@ -199,6 +239,10 @@ pub async fn list_active(pool: &PgPool) -> Result<Vec<ActiveJobset>> {
|
|||
}
|
||||
|
||||
/// Mark a one-shot jobset as complete (set state to disabled).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails.
|
||||
pub async fn mark_one_shot_complete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE jobsets SET state = 'disabled', enabled = false WHERE id = $1 AND \
|
||||
|
|
@ -212,6 +256,10 @@ pub async fn mark_one_shot_complete(pool: &PgPool, id: Uuid) -> Result<()> {
|
|||
}
|
||||
|
||||
/// Update the `last_checked_at` timestamp for a jobset.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails.
|
||||
pub async fn update_last_checked(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
sqlx::query("UPDATE jobsets SET last_checked_at = NOW() WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -222,6 +270,10 @@ pub async fn update_last_checked(pool: &PgPool, id: Uuid) -> Result<()> {
|
|||
}
|
||||
|
||||
/// Check if a jobset has any running builds.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn has_running_builds(
|
||||
pool: &PgPool,
|
||||
jobset_id: Uuid,
|
||||
|
|
@ -240,6 +292,10 @@ pub async fn has_running_builds(
|
|||
/// List jobsets that are due for evaluation based on their `check_interval`.
|
||||
/// Returns jobsets where `last_checked_at` is NULL or older than
|
||||
/// `check_interval` seconds.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_due_for_eval(
|
||||
pool: &PgPool,
|
||||
limit: i64,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ use crate::{
|
|||
models::{CreateNotificationConfig, NotificationConfig},
|
||||
};
|
||||
|
||||
/// Create a new notification config.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails or config already exists.
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateNotificationConfig,
|
||||
|
|
@ -33,6 +38,11 @@ pub async fn create(
|
|||
})
|
||||
}
|
||||
|
||||
/// List all enabled notification configs for a project.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -47,6 +57,11 @@ pub async fn list_for_project(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Delete a notification config.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails or config not found.
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM notification_configs WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -61,6 +76,10 @@ pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
|||
}
|
||||
|
||||
/// Upsert a notification config (insert or update on conflict).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operation fails.
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -85,6 +104,10 @@ pub async fn upsert(
|
|||
|
||||
/// Sync notification configs from declarative config.
|
||||
/// Deletes configs not in the declarative list and upserts those that are.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operations fail.
|
||||
pub async fn sync_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ use uuid::Uuid;
|
|||
use crate::{error::Result, models::NotificationTask};
|
||||
|
||||
/// Create a new notification task for later delivery
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails.
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
notification_type: &str,
|
||||
|
|
@ -13,11 +17,11 @@ pub async fn create(
|
|||
max_attempts: i32,
|
||||
) -> Result<NotificationTask> {
|
||||
let task = sqlx::query_as::<_, NotificationTask>(
|
||||
r#"
|
||||
r"
|
||||
INSERT INTO notification_tasks (notification_type, payload, max_attempts)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *
|
||||
"#,
|
||||
",
|
||||
)
|
||||
.bind(notification_type)
|
||||
.bind(payload)
|
||||
|
|
@ -29,19 +33,23 @@ pub async fn create(
|
|||
}
|
||||
|
||||
/// Fetch pending tasks that are ready for retry
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_pending(
|
||||
pool: &PgPool,
|
||||
limit: i32,
|
||||
) -> Result<Vec<NotificationTask>> {
|
||||
let tasks = sqlx::query_as::<_, NotificationTask>(
|
||||
r#"
|
||||
r"
|
||||
SELECT *
|
||||
FROM notification_tasks
|
||||
WHERE status = 'pending'
|
||||
AND next_retry_at <= NOW()
|
||||
ORDER BY next_retry_at ASC
|
||||
LIMIT $1
|
||||
"#,
|
||||
",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
|
|
@ -51,14 +59,18 @@ pub async fn list_pending(
|
|||
}
|
||||
|
||||
/// Mark a task as running (claimed by worker)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails.
|
||||
pub async fn mark_running(pool: &PgPool, task_id: Uuid) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
r"
|
||||
UPDATE notification_tasks
|
||||
SET status = 'running',
|
||||
attempts = attempts + 1
|
||||
WHERE id = $1
|
||||
"#,
|
||||
",
|
||||
)
|
||||
.bind(task_id)
|
||||
.execute(pool)
|
||||
|
|
@ -68,14 +80,18 @@ pub async fn mark_running(pool: &PgPool, task_id: Uuid) -> Result<()> {
|
|||
}
|
||||
|
||||
/// Mark a task as completed successfully
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails.
|
||||
pub async fn mark_completed(pool: &PgPool, task_id: Uuid) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
r"
|
||||
UPDATE notification_tasks
|
||||
SET status = 'completed',
|
||||
completed_at = NOW()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
",
|
||||
)
|
||||
.bind(task_id)
|
||||
.execute(pool)
|
||||
|
|
@ -86,13 +102,17 @@ pub async fn mark_completed(pool: &PgPool, task_id: Uuid) -> Result<()> {
|
|||
|
||||
/// Mark a task as failed and schedule retry with exponential backoff
|
||||
/// Backoff formula: 1s, 2s, 4s, 8s, 16s...
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails.
|
||||
pub async fn mark_failed_and_retry(
|
||||
pool: &PgPool,
|
||||
task_id: Uuid,
|
||||
error: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
r"
|
||||
UPDATE notification_tasks
|
||||
SET status = CASE
|
||||
WHEN attempts >= max_attempts THEN 'failed'::varchar
|
||||
|
|
@ -101,14 +121,14 @@ pub async fn mark_failed_and_retry(
|
|||
last_error = $2,
|
||||
next_retry_at = CASE
|
||||
WHEN attempts >= max_attempts THEN NOW()
|
||||
ELSE NOW() + (POWER(2, attempts) || ' seconds')::interval
|
||||
ELSE NOW() + (POWER(2, attempts - 1) || ' seconds')::interval
|
||||
END,
|
||||
completed_at = CASE
|
||||
WHEN attempts >= max_attempts THEN NOW()
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE id = $1
|
||||
"#,
|
||||
",
|
||||
)
|
||||
.bind(task_id)
|
||||
.bind(error)
|
||||
|
|
@ -119,11 +139,15 @@ pub async fn mark_failed_and_retry(
|
|||
}
|
||||
|
||||
/// Get task by ID
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get(pool: &PgPool, task_id: Uuid) -> Result<NotificationTask> {
|
||||
let task = sqlx::query_as::<_, NotificationTask>(
|
||||
r#"
|
||||
r"
|
||||
SELECT * FROM notification_tasks WHERE id = $1
|
||||
"#,
|
||||
",
|
||||
)
|
||||
.bind(task_id)
|
||||
.fetch_one(pool)
|
||||
|
|
@ -133,17 +157,21 @@ pub async fn get(pool: &PgPool, task_id: Uuid) -> Result<NotificationTask> {
|
|||
}
|
||||
|
||||
/// Clean up old completed/failed tasks (older than retention days)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails.
|
||||
pub async fn cleanup_old_tasks(
|
||||
pool: &PgPool,
|
||||
retention_days: i64,
|
||||
) -> Result<u64> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
r"
|
||||
DELETE FROM notification_tasks
|
||||
WHERE status IN ('completed', 'failed')
|
||||
AND (completed_at < NOW() - ($1 || ' days')::interval
|
||||
OR created_at < NOW() - ($1 || ' days')::interval)
|
||||
"#,
|
||||
",
|
||||
)
|
||||
.bind(retention_days)
|
||||
.execute(pool)
|
||||
|
|
@ -153,11 +181,15 @@ pub async fn cleanup_old_tasks(
|
|||
}
|
||||
|
||||
/// Count pending tasks (for monitoring)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn count_pending(pool: &PgPool) -> Result<i64> {
|
||||
let count: (i64,) = sqlx::query_as(
|
||||
r#"
|
||||
r"
|
||||
SELECT COUNT(*) FROM notification_tasks WHERE status = 'pending'
|
||||
"#,
|
||||
",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
|
@ -166,11 +198,15 @@ pub async fn count_pending(pool: &PgPool) -> Result<i64> {
|
|||
}
|
||||
|
||||
/// Count failed tasks (for monitoring)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn count_failed(pool: &PgPool) -> Result<i64> {
|
||||
let count: (i64,) = sqlx::query_as(
|
||||
r#"
|
||||
r"
|
||||
SELECT COUNT(*) FROM notification_tasks WHERE status = 'failed'
|
||||
"#,
|
||||
",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ use crate::{
|
|||
};
|
||||
|
||||
/// Add a member to a project with role validation
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if validation fails or database insert fails.
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -43,6 +47,10 @@ pub async fn create(
|
|||
}
|
||||
|
||||
/// Get a project member by ID
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails or member not found.
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<ProjectMember> {
|
||||
sqlx::query_as::<_, ProjectMember>(
|
||||
"SELECT * FROM project_members WHERE id = $1",
|
||||
|
|
@ -61,6 +69,10 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<ProjectMember> {
|
|||
}
|
||||
|
||||
/// Get a project member by project and user
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_by_project_and_user(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -77,6 +89,10 @@ pub async fn get_by_project_and_user(
|
|||
}
|
||||
|
||||
/// List all members of a project
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -91,6 +107,10 @@ pub async fn list_for_project(
|
|||
}
|
||||
|
||||
/// List all projects a user is a member of
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_for_user(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
|
|
@ -105,6 +125,10 @@ pub async fn list_for_user(
|
|||
}
|
||||
|
||||
/// Update a project member's role with validation
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if validation fails or database update fails.
|
||||
pub async fn update(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
|
|
@ -135,6 +159,10 @@ pub async fn update(
|
|||
}
|
||||
|
||||
/// Remove a member from a project
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails or member not found.
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM project_members WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -147,6 +175,10 @@ pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
|||
}
|
||||
|
||||
/// Remove a specific user from a project
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails or user not found.
|
||||
pub async fn delete_by_project_and_user(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -168,6 +200,10 @@ pub async fn delete_by_project_and_user(
|
|||
}
|
||||
|
||||
/// Check if a user has a specific role or higher in a project
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn check_permission(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -186,6 +222,10 @@ pub async fn check_permission(
|
|||
}
|
||||
|
||||
/// Upsert a project member (insert or update on conflict).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if validation fails or database operation fails.
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -211,6 +251,10 @@ pub async fn upsert(
|
|||
|
||||
/// Sync project members from declarative config.
|
||||
/// Deletes members not in the declarative list and upserts those that are.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operations fail.
|
||||
pub async fn sync_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ use crate::{
|
|||
models::{CreateProject, Project, UpdateProject},
|
||||
};
|
||||
|
||||
/// Create a new project.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails or project name already exists.
|
||||
pub async fn create(pool: &PgPool, input: CreateProject) -> Result<Project> {
|
||||
sqlx::query_as::<_, Project>(
|
||||
"INSERT INTO projects (name, description, repository_url) VALUES ($1, $2, \
|
||||
|
|
@ -26,6 +31,11 @@ pub async fn create(pool: &PgPool, input: CreateProject) -> Result<Project> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Get a project by ID.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails or project not found.
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Project> {
|
||||
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -34,6 +44,11 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<Project> {
|
|||
.ok_or_else(|| CiError::NotFound(format!("Project {id} not found")))
|
||||
}
|
||||
|
||||
/// Get a project by name.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails or project not found.
|
||||
pub async fn get_by_name(pool: &PgPool, name: &str) -> Result<Project> {
|
||||
sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE name = $1")
|
||||
.bind(name)
|
||||
|
|
@ -42,6 +57,11 @@ pub async fn get_by_name(pool: &PgPool, name: &str) -> Result<Project> {
|
|||
.ok_or_else(|| CiError::NotFound(format!("Project '{name}' not found")))
|
||||
}
|
||||
|
||||
/// List projects with pagination.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list(
|
||||
pool: &PgPool,
|
||||
limit: i64,
|
||||
|
|
@ -57,6 +77,11 @@ pub async fn list(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Count total number of projects.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn count(pool: &PgPool) -> Result<i64> {
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM projects")
|
||||
.fetch_one(pool)
|
||||
|
|
@ -65,12 +90,17 @@ pub async fn count(pool: &PgPool) -> Result<i64> {
|
|||
Ok(row.0)
|
||||
}
|
||||
|
||||
/// Update a project with partial fields.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails or project not found.
|
||||
pub async fn update(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
input: UpdateProject,
|
||||
) -> Result<Project> {
|
||||
// Build dynamic update — only set provided fields
|
||||
// Dynamic update - only set provided fields
|
||||
let existing = get(pool, id).await?;
|
||||
|
||||
let name = input.name.unwrap_or(existing.name);
|
||||
|
|
@ -97,6 +127,11 @@ pub async fn update(
|
|||
})
|
||||
}
|
||||
|
||||
/// Insert or update a project by name.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operation fails.
|
||||
pub async fn upsert(pool: &PgPool, input: CreateProject) -> Result<Project> {
|
||||
sqlx::query_as::<_, Project>(
|
||||
"INSERT INTO projects (name, description, repository_url) VALUES ($1, $2, \
|
||||
|
|
@ -111,6 +146,11 @@ pub async fn upsert(pool: &PgPool, input: CreateProject) -> Result<Project> {
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Delete a project by ID.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails or project not found.
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM projects WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ use crate::{
|
|||
models::{CreateRemoteBuilder, RemoteBuilder},
|
||||
};
|
||||
|
||||
/// Create a new remote builder.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails or builder already exists.
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateRemoteBuilder,
|
||||
|
|
@ -40,6 +45,11 @@ pub async fn create(
|
|||
})
|
||||
}
|
||||
|
||||
/// Get a remote builder by ID.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails or builder not found.
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<RemoteBuilder> {
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"SELECT * FROM remote_builders WHERE id = $1",
|
||||
|
|
@ -50,6 +60,11 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<RemoteBuilder> {
|
|||
.ok_or_else(|| CiError::NotFound(format!("Remote builder {id} not found")))
|
||||
}
|
||||
|
||||
/// List all remote builders.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list(pool: &PgPool) -> Result<Vec<RemoteBuilder>> {
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"SELECT * FROM remote_builders ORDER BY speed_factor DESC, name",
|
||||
|
|
@ -59,6 +74,11 @@ pub async fn list(pool: &PgPool) -> Result<Vec<RemoteBuilder>> {
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// List all enabled remote builders.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_enabled(pool: &PgPool) -> Result<Vec<RemoteBuilder>> {
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"SELECT * FROM remote_builders WHERE enabled = true ORDER BY speed_factor \
|
||||
|
|
@ -71,6 +91,10 @@ pub async fn list_enabled(pool: &PgPool) -> Result<Vec<RemoteBuilder>> {
|
|||
|
||||
/// Find a suitable builder for the given system.
|
||||
/// Excludes builders that are temporarily disabled due to consecutive failures.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn find_for_system(
|
||||
pool: &PgPool,
|
||||
system: &str,
|
||||
|
|
@ -87,9 +111,14 @@ pub async fn find_for_system(
|
|||
}
|
||||
|
||||
/// Record a build failure for a remote builder.
|
||||
/// Increments consecutive_failures (capped at 4), sets last_failure,
|
||||
/// and computes disabled_until with exponential backoff.
|
||||
///
|
||||
/// Increments `consecutive_failures` (capped at 4), sets `last_failure`,
|
||||
/// and computes `disabled_until` with exponential backoff.
|
||||
/// Backoff formula (from Hydra): delta = 60 * 3^(min(failures, 4) - 1) seconds.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails or builder not found.
|
||||
pub async fn record_failure(pool: &PgPool, id: Uuid) -> Result<RemoteBuilder> {
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"UPDATE remote_builders SET consecutive_failures = \
|
||||
|
|
@ -105,7 +134,11 @@ pub async fn record_failure(pool: &PgPool, id: Uuid) -> Result<RemoteBuilder> {
|
|||
}
|
||||
|
||||
/// Record a build success for a remote builder.
|
||||
/// Resets consecutive_failures and clears disabled_until.
|
||||
/// Resets `consecutive_failures` and clears `disabled_until`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails or builder not found.
|
||||
pub async fn record_success(pool: &PgPool, id: Uuid) -> Result<RemoteBuilder> {
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"UPDATE remote_builders SET consecutive_failures = 0, disabled_until = \
|
||||
|
|
@ -117,12 +150,17 @@ pub async fn record_success(pool: &PgPool, id: Uuid) -> Result<RemoteBuilder> {
|
|||
.ok_or_else(|| CiError::NotFound(format!("Remote builder {id} not found")))
|
||||
}
|
||||
|
||||
/// Update a remote builder with partial fields.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails or builder not found.
|
||||
pub async fn update(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
input: crate::models::UpdateRemoteBuilder,
|
||||
) -> Result<RemoteBuilder> {
|
||||
// Build dynamic update — use COALESCE pattern
|
||||
// Dynamic update using COALESCE pattern
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"UPDATE remote_builders SET name = COALESCE($1, name), ssh_uri = \
|
||||
COALESCE($2, ssh_uri), systems = COALESCE($3, systems), max_jobs = \
|
||||
|
|
@ -148,6 +186,11 @@ pub async fn update(
|
|||
.ok_or_else(|| CiError::NotFound(format!("Remote builder {id} not found")))
|
||||
}
|
||||
|
||||
/// Delete a remote builder.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails or builder not found.
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM remote_builders WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -160,6 +203,11 @@ pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Count total remote builders.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn count(pool: &PgPool) -> Result<i64> {
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM remote_builders")
|
||||
.fetch_one(pool)
|
||||
|
|
@ -169,18 +217,13 @@ pub async fn count(pool: &PgPool) -> Result<i64> {
|
|||
}
|
||||
|
||||
/// Upsert a remote builder (insert or update on conflict by name).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operation fails.
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
name: &str,
|
||||
ssh_uri: &str,
|
||||
systems: &[String],
|
||||
max_jobs: i32,
|
||||
speed_factor: i32,
|
||||
supported_features: &[String],
|
||||
mandatory_features: &[String],
|
||||
enabled: bool,
|
||||
public_host_key: Option<&str>,
|
||||
ssh_key_file: Option<&str>,
|
||||
params: &crate::models::RemoteBuilderParams<'_>,
|
||||
) -> Result<RemoteBuilder> {
|
||||
sqlx::query_as::<_, RemoteBuilder>(
|
||||
"INSERT INTO remote_builders (name, ssh_uri, systems, max_jobs, \
|
||||
|
|
@ -194,16 +237,16 @@ pub async fn upsert(
|
|||
remote_builders.public_host_key), ssh_key_file = \
|
||||
COALESCE(EXCLUDED.ssh_key_file, remote_builders.ssh_key_file) RETURNING *",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(ssh_uri)
|
||||
.bind(systems)
|
||||
.bind(max_jobs)
|
||||
.bind(speed_factor)
|
||||
.bind(supported_features)
|
||||
.bind(mandatory_features)
|
||||
.bind(enabled)
|
||||
.bind(public_host_key)
|
||||
.bind(ssh_key_file)
|
||||
.bind(params.name)
|
||||
.bind(params.ssh_uri)
|
||||
.bind(params.systems)
|
||||
.bind(params.max_jobs)
|
||||
.bind(params.speed_factor)
|
||||
.bind(params.supported_features)
|
||||
.bind(params.mandatory_features)
|
||||
.bind(params.enabled)
|
||||
.bind(params.public_host_key)
|
||||
.bind(params.ssh_key_file)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(CiError::Database)
|
||||
|
|
@ -211,6 +254,10 @@ pub async fn upsert(
|
|||
|
||||
/// Sync remote builders from declarative config.
|
||||
/// Deletes builders not in the declarative list and upserts those that are.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operations fail.
|
||||
pub async fn sync_all(
|
||||
pool: &PgPool,
|
||||
builders: &[DeclarativeRemoteBuilder],
|
||||
|
|
@ -227,20 +274,19 @@ pub async fn sync_all(
|
|||
|
||||
// Upsert each builder
|
||||
for builder in builders {
|
||||
upsert(
|
||||
pool,
|
||||
&builder.name,
|
||||
&builder.ssh_uri,
|
||||
&builder.systems,
|
||||
builder.max_jobs,
|
||||
builder.speed_factor,
|
||||
&builder.supported_features,
|
||||
&builder.mandatory_features,
|
||||
builder.enabled,
|
||||
builder.public_host_key.as_deref(),
|
||||
builder.ssh_key_file.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let params = crate::models::RemoteBuilderParams {
|
||||
name: &builder.name,
|
||||
ssh_uri: &builder.ssh_uri,
|
||||
systems: &builder.systems,
|
||||
max_jobs: builder.max_jobs,
|
||||
speed_factor: builder.speed_factor,
|
||||
supported_features: &builder.supported_features,
|
||||
mandatory_features: &builder.mandatory_features,
|
||||
enabled: builder.enabled,
|
||||
public_host_key: builder.public_host_key.as_deref(),
|
||||
ssh_key_file: builder.ssh_key_file.as_deref(),
|
||||
};
|
||||
upsert(pool, ¶ms).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -146,6 +146,10 @@ pub struct SearchResults {
|
|||
}
|
||||
|
||||
/// Execute a comprehensive search across all entities
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn search(
|
||||
pool: &PgPool,
|
||||
params: &SearchParams,
|
||||
|
|
@ -511,6 +515,10 @@ async fn search_builds(
|
|||
}
|
||||
|
||||
/// Quick search - simple text search across entities
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn quick_search(
|
||||
pool: &PgPool,
|
||||
query: &str,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ use crate::{
|
|||
};
|
||||
|
||||
/// Create a new starred job
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails or job already starred.
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
|
|
@ -35,6 +39,10 @@ pub async fn create(
|
|||
}
|
||||
|
||||
/// Get a starred job by ID
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails or starred job not found.
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<StarredJob> {
|
||||
sqlx::query_as::<_, StarredJob>("SELECT * FROM starred_jobs WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -51,6 +59,10 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<StarredJob> {
|
|||
}
|
||||
|
||||
/// List starred jobs for a user with pagination
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_for_user(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
|
|
@ -70,6 +82,10 @@ pub async fn list_for_user(
|
|||
}
|
||||
|
||||
/// Count starred jobs for a user
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn count_for_user(pool: &PgPool, user_id: Uuid) -> Result<i64> {
|
||||
let (count,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM starred_jobs WHERE user_id = $1")
|
||||
|
|
@ -80,6 +96,10 @@ pub async fn count_for_user(pool: &PgPool, user_id: Uuid) -> Result<i64> {
|
|||
}
|
||||
|
||||
/// Check if a user has starred a specific job
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn is_starred(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
|
|
@ -101,6 +121,10 @@ pub async fn is_starred(
|
|||
}
|
||||
|
||||
/// Delete a starred job
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails or starred job not found.
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM starred_jobs WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -113,6 +137,10 @@ pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
|||
}
|
||||
|
||||
/// Delete a starred job by user and job details
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails or starred job not found.
|
||||
pub async fn delete_by_job(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
|
|
@ -137,6 +165,10 @@ pub async fn delete_by_job(
|
|||
}
|
||||
|
||||
/// Delete all starred jobs for a user (when user is deleted)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails.
|
||||
pub async fn delete_all_for_user(pool: &PgPool, user_id: Uuid) -> Result<()> {
|
||||
sqlx::query("DELETE FROM starred_jobs WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ use crate::{
|
|||
};
|
||||
|
||||
/// Hash a password using argon2id
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if password hashing fails.
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
use argon2::{
|
||||
Argon2,
|
||||
|
|
@ -33,6 +37,10 @@ pub fn hash_password(password: &str) -> Result<String> {
|
|||
}
|
||||
|
||||
/// Verify a password against a hash
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if password hash parsing fails.
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||
|
||||
|
|
@ -47,6 +55,10 @@ pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
|||
}
|
||||
|
||||
/// Create a new user with validation
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if validation fails or database insert fails.
|
||||
pub async fn create(pool: &PgPool, data: &CreateUser) -> Result<User> {
|
||||
// Validate username
|
||||
validate_username(&data.username)
|
||||
|
|
@ -94,6 +106,10 @@ pub async fn create(pool: &PgPool, data: &CreateUser) -> Result<User> {
|
|||
}
|
||||
|
||||
/// Authenticate a user with username and password
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if credentials are invalid or database query fails.
|
||||
pub async fn authenticate(
|
||||
pool: &PgPool,
|
||||
creds: &LoginCredentials,
|
||||
|
|
@ -129,6 +145,10 @@ pub async fn authenticate(
|
|||
}
|
||||
|
||||
/// Get a user by ID
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails or user not found.
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<User> {
|
||||
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -145,6 +165,10 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<User> {
|
|||
}
|
||||
|
||||
/// Get a user by username
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_by_username(
|
||||
pool: &PgPool,
|
||||
username: &str,
|
||||
|
|
@ -157,6 +181,10 @@ pub async fn get_by_username(
|
|||
}
|
||||
|
||||
/// Get a user by email
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_by_email(pool: &PgPool, email: &str) -> Result<Option<User>> {
|
||||
sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
|
||||
.bind(email)
|
||||
|
|
@ -166,6 +194,10 @@ pub async fn get_by_email(pool: &PgPool, email: &str) -> Result<Option<User>> {
|
|||
}
|
||||
|
||||
/// List all users with pagination
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list(pool: &PgPool, limit: i64, offset: i64) -> Result<Vec<User>> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2",
|
||||
|
|
@ -178,6 +210,10 @@ pub async fn list(pool: &PgPool, limit: i64, offset: i64) -> Result<Vec<User>> {
|
|||
}
|
||||
|
||||
/// Count total users
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn count(pool: &PgPool) -> Result<i64> {
|
||||
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(pool)
|
||||
|
|
@ -186,6 +222,10 @@ pub async fn count(pool: &PgPool) -> Result<i64> {
|
|||
}
|
||||
|
||||
/// Update a user with the provided data
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if validation fails or database update fails.
|
||||
pub async fn update(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
|
|
@ -220,6 +260,10 @@ pub async fn update(
|
|||
}
|
||||
|
||||
/// Update user email with validation
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if validation fails or database update fails.
|
||||
pub async fn update_email(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
|
|
@ -245,6 +289,10 @@ pub async fn update_email(
|
|||
}
|
||||
|
||||
/// Update user full name with validation
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if validation fails or database update fails.
|
||||
pub async fn update_full_name(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
|
|
@ -263,6 +311,10 @@ pub async fn update_full_name(
|
|||
}
|
||||
|
||||
/// Update user password with validation
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if validation fails or database update fails.
|
||||
pub async fn update_password(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
|
|
@ -281,6 +333,10 @@ pub async fn update_password(
|
|||
}
|
||||
|
||||
/// Update user role with validation
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if validation fails or database update fails.
|
||||
pub async fn update_role(pool: &PgPool, id: Uuid, role: &str) -> Result<()> {
|
||||
validate_role(role, VALID_ROLES)
|
||||
.map_err(|e| CiError::Validation(e.to_string()))?;
|
||||
|
|
@ -294,6 +350,10 @@ pub async fn update_role(pool: &PgPool, id: Uuid, role: &str) -> Result<()> {
|
|||
}
|
||||
|
||||
/// Enable/disable user
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails.
|
||||
pub async fn set_enabled(pool: &PgPool, id: Uuid, enabled: bool) -> Result<()> {
|
||||
sqlx::query("UPDATE users SET enabled = $1 WHERE id = $2")
|
||||
.bind(enabled)
|
||||
|
|
@ -304,6 +364,10 @@ pub async fn set_enabled(pool: &PgPool, id: Uuid, enabled: bool) -> Result<()> {
|
|||
}
|
||||
|
||||
/// Set public dashboard preference
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database update fails.
|
||||
pub async fn set_public_dashboard(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
|
|
@ -318,6 +382,10 @@ pub async fn set_public_dashboard(
|
|||
}
|
||||
|
||||
/// Delete a user
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails or user not found.
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM users WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -330,6 +398,10 @@ pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
|||
}
|
||||
|
||||
/// Create or update OAuth user
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if validation fails or database operation fails.
|
||||
pub async fn upsert_oauth_user(
|
||||
pool: &PgPool,
|
||||
username: &str,
|
||||
|
|
@ -399,6 +471,10 @@ pub async fn upsert_oauth_user(
|
|||
}
|
||||
|
||||
/// Create a new session for a user. Returns (`session_token`, `session_id`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails.
|
||||
pub async fn create_session(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
|
|
@ -427,6 +503,10 @@ pub async fn create_session(
|
|||
}
|
||||
|
||||
/// Validate a session token and return the associated user if valid.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn validate_session(
|
||||
pool: &PgPool,
|
||||
token: &str,
|
||||
|
|
@ -444,17 +524,16 @@ pub async fn validate_session(
|
|||
.await?;
|
||||
|
||||
// Update last_used_at
|
||||
if result.is_some() {
|
||||
if let Err(e) = sqlx::query(
|
||||
if result.is_some()
|
||||
&& let Err(e) = sqlx::query(
|
||||
"UPDATE user_sessions SET last_used_at = NOW() WHERE session_token_hash \
|
||||
= $1",
|
||||
)
|
||||
.bind(&token_hash)
|
||||
.execute(pool)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(token_hash = %token_hash, "Failed to update session last_used_at: {e}");
|
||||
}
|
||||
{
|
||||
tracing::warn!(token_hash = %token_hash, "Failed to update session last_used_at: {e}");
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ use crate::{
|
|||
models::{CreateWebhookConfig, WebhookConfig},
|
||||
};
|
||||
|
||||
/// Create a new webhook config.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database insert fails or config already exists.
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
input: CreateWebhookConfig,
|
||||
|
|
@ -34,6 +39,11 @@ pub async fn create(
|
|||
})
|
||||
}
|
||||
|
||||
/// Get a webhook config by ID.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails or config not found.
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<WebhookConfig> {
|
||||
sqlx::query_as::<_, WebhookConfig>(
|
||||
"SELECT * FROM webhook_configs WHERE id = $1",
|
||||
|
|
@ -44,6 +54,11 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<WebhookConfig> {
|
|||
.ok_or_else(|| CiError::NotFound(format!("Webhook config {id} not found")))
|
||||
}
|
||||
|
||||
/// List all webhook configs for a project.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn list_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -58,6 +73,11 @@ pub async fn list_for_project(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Get a webhook config by project and forge type.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database query fails.
|
||||
pub async fn get_by_project_and_forge(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -74,6 +94,11 @@ pub async fn get_by_project_and_forge(
|
|||
.map_err(CiError::Database)
|
||||
}
|
||||
|
||||
/// Delete a webhook config.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database delete fails or config not found.
|
||||
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
||||
let result = sqlx::query("DELETE FROM webhook_configs WHERE id = $1")
|
||||
.bind(id)
|
||||
|
|
@ -86,6 +111,10 @@ pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
|
|||
}
|
||||
|
||||
/// Upsert a webhook config (insert or update on conflict).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operation fails.
|
||||
pub async fn upsert(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
@ -110,6 +139,10 @@ pub async fn upsert(
|
|||
|
||||
/// Sync webhook configs from declarative config.
|
||||
/// Deletes configs not in the declarative list and upserts those that are.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operations fail.
|
||||
pub async fn sync_for_project(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -82,14 +82,12 @@ fn is_internal_host(host: &str) -> bool {
|
|||
return true;
|
||||
}
|
||||
// Block 172.16-31.x.x
|
||||
if host.starts_with("172.") {
|
||||
if let Some(second_octet) = host.split('.').nth(1) {
|
||||
if let Ok(n) = second_octet.parse::<u8>() {
|
||||
if (16..=31).contains(&n) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if host.starts_with("172.")
|
||||
&& let Some(second_octet) = host.split('.').nth(1)
|
||||
&& let Ok(n) = second_octet.parse::<u8>()
|
||||
&& (16..=31).contains(&n)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Block 192.168.x.x
|
||||
if host.starts_with("192.168.") {
|
||||
|
|
@ -100,6 +98,11 @@ fn is_internal_host(host: &str) -> bool {
|
|||
|
||||
/// Trait for validating request DTOs before persisting.
|
||||
pub trait Validate {
|
||||
/// Validate the DTO.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if validation fails.
|
||||
fn validate(&self) -> Result<(), String>;
|
||||
}
|
||||
|
||||
|
|
@ -129,19 +132,23 @@ fn validate_repository_url(url: &str) -> Result<(), String> {
|
|||
);
|
||||
}
|
||||
// Reject URLs targeting common internal/metadata endpoints
|
||||
if let Some(host) = extract_host_from_url(url) {
|
||||
if is_internal_host(&host) {
|
||||
return Err(
|
||||
"repository_url must not target internal or metadata addresses"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if let Some(host) = extract_host_from_url(url)
|
||||
&& is_internal_host(&host)
|
||||
{
|
||||
return Err(
|
||||
"repository_url must not target internal or metadata addresses"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that a URL uses one of the allowed schemes.
|
||||
/// Logs a warning when insecure schemes (`file`, `http`) are used.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if URL scheme is not in the allowed list.
|
||||
pub fn validate_url_scheme(
|
||||
url: &str,
|
||||
allowed_schemes: &[String],
|
||||
|
|
@ -187,6 +194,11 @@ fn validate_description(desc: &str) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate nix expression format.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if expression contains invalid characters or path traversal.
|
||||
pub fn validate_nix_expression(expr: &str) -> Result<(), String> {
|
||||
if expr.is_empty() {
|
||||
return Err("nix_expression cannot be empty".to_string());
|
||||
|
|
@ -465,7 +477,7 @@ mod tests {
|
|||
#[test]
|
||||
fn store_path_rejects_just_prefix() {
|
||||
// "/nix/store/" alone has no hash, but structurally starts_with and has no
|
||||
// .., so it passes. This is fine — the DB lookup won't find anything
|
||||
// .., so it passes. This is fine - the DB lookup won't find anything
|
||||
// for it.
|
||||
assert!(is_valid_store_path("/nix/store/"));
|
||||
}
|
||||
|
|
@ -554,7 +566,7 @@ mod tests {
|
|||
#[test]
|
||||
fn test_create_project_invalid_name() {
|
||||
let p = CreateProject {
|
||||
name: "".to_string(),
|
||||
name: String::new(),
|
||||
description: None,
|
||||
repository_url: "https://github.com/test/repo".to_string(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ impl std::error::Error for ValidationError {}
|
|||
/// Requirements:
|
||||
/// - 3-32 characters
|
||||
/// - Alphanumeric, underscore, hyphen only
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if username format is invalid.
|
||||
pub fn validate_username(username: &str) -> Result<(), ValidationError> {
|
||||
if username.is_empty() {
|
||||
return Err(ValidationError {
|
||||
|
|
@ -55,6 +59,10 @@ pub fn validate_username(username: &str) -> Result<(), ValidationError> {
|
|||
}
|
||||
|
||||
/// Validate email format
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if email format is invalid.
|
||||
pub fn validate_email(email: &str) -> Result<(), ValidationError> {
|
||||
if email.is_empty() {
|
||||
return Err(ValidationError {
|
||||
|
|
@ -80,6 +88,10 @@ pub fn validate_email(email: &str) -> Result<(), ValidationError> {
|
|||
/// - At least one lowercase letter
|
||||
/// - At least one number
|
||||
/// - At least one special character
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if password does not meet requirements.
|
||||
pub fn validate_password(password: &str) -> Result<(), ValidationError> {
|
||||
if password.len() < 12 {
|
||||
return Err(ValidationError {
|
||||
|
|
@ -128,6 +140,10 @@ pub fn validate_password(password: &str) -> Result<(), ValidationError> {
|
|||
}
|
||||
|
||||
/// Validate role against allowed roles
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if role is not in the allowed list.
|
||||
pub fn validate_role(
|
||||
role: &str,
|
||||
allowed: &[&str],
|
||||
|
|
@ -152,6 +168,10 @@ pub fn validate_role(
|
|||
/// Validate full name (optional field)
|
||||
/// - Max 255 characters
|
||||
/// - Must not contain control characters
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if full name contains invalid characters or is too long.
|
||||
pub fn validate_full_name(name: &str) -> Result<(), ValidationError> {
|
||||
if name.len() > 255 {
|
||||
return Err(ValidationError {
|
||||
|
|
@ -174,6 +194,10 @@ pub fn validate_full_name(name: &str) -> Result<(), ValidationError> {
|
|||
/// Requirements:
|
||||
/// - 1-255 characters
|
||||
/// - Alphanumeric + common path characters
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if job name format is invalid.
|
||||
pub fn validate_job_name(name: &str) -> Result<(), ValidationError> {
|
||||
if name.is_empty() {
|
||||
return Err(ValidationError {
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ async fn test_database_connection() -> anyhow::Result<()> {
|
|||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_database_connection: no PostgreSQL instance available \
|
||||
- {}",
|
||||
e
|
||||
- {e}"
|
||||
);
|
||||
return Ok(());
|
||||
},
|
||||
|
|
@ -38,7 +37,7 @@ async fn test_database_connection() -> anyhow::Result<()> {
|
|||
assert!(!info.version.is_empty());
|
||||
|
||||
// Test pool stats
|
||||
let stats = db.get_pool_stats().await;
|
||||
let stats = db.get_pool_stats();
|
||||
assert!(stats.size >= 1);
|
||||
|
||||
db.close().await;
|
||||
|
|
@ -58,8 +57,7 @@ async fn test_database_health_check() -> anyhow::Result<()> {
|
|||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_database_health_check: no PostgreSQL instance \
|
||||
available - {}",
|
||||
e
|
||||
available - {e}"
|
||||
);
|
||||
return Ok(());
|
||||
},
|
||||
|
|
@ -83,8 +81,7 @@ async fn test_connection_info() -> anyhow::Result<()> {
|
|||
Ok(pool) => pool,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_connection_info: no PostgreSQL instance available - {}",
|
||||
e
|
||||
"Skipping test_connection_info: no PostgreSQL instance available - {e}"
|
||||
);
|
||||
return Ok(());
|
||||
},
|
||||
|
|
@ -104,8 +101,7 @@ async fn test_connection_info() -> anyhow::Result<()> {
|
|||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_connection_info: database connection failed - {}",
|
||||
e
|
||||
"Skipping test_connection_info: database connection failed - {e}"
|
||||
);
|
||||
pool.close().await;
|
||||
return Ok(());
|
||||
|
|
@ -141,14 +137,13 @@ async fn test_pool_stats() -> anyhow::Result<()> {
|
|||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Skipping test_pool_stats: no PostgreSQL instance available - {}",
|
||||
e
|
||||
"Skipping test_pool_stats: no PostgreSQL instance available - {e}"
|
||||
);
|
||||
return Ok(());
|
||||
},
|
||||
};
|
||||
|
||||
let stats = db.get_pool_stats().await;
|
||||
let stats = db.get_pool_stats();
|
||||
|
||||
assert!(stats.size >= 1);
|
||||
assert!(stats.idle >= 1);
|
||||
|
|
@ -173,12 +168,12 @@ async fn test_database_config_validation() -> anyhow::Result<()> {
|
|||
assert!(config.validate().is_ok());
|
||||
|
||||
// Invalid URL
|
||||
let mut config = config.clone();
|
||||
let mut config = config;
|
||||
config.url = "invalid://url".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Empty URL
|
||||
config.url = "".to_string();
|
||||
config.url = String::new();
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Zero max connections
|
||||
|
|
|
|||
|
|
@ -20,12 +20,9 @@ async fn test_database_connection_full() -> anyhow::Result<()> {
|
|||
};
|
||||
|
||||
// Try to connect, skip test if database is not available
|
||||
let db = match Database::new(config).await {
|
||||
Ok(db) => db,
|
||||
Err(_) => {
|
||||
println!("Skipping database test: no PostgreSQL instance available");
|
||||
return Ok(());
|
||||
},
|
||||
let Ok(db) = Database::new(config).await else {
|
||||
println!("Skipping database test: no PostgreSQL instance available");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Test health check
|
||||
|
|
@ -38,7 +35,7 @@ async fn test_database_connection_full() -> anyhow::Result<()> {
|
|||
assert!(!info.version.is_empty());
|
||||
|
||||
// Test pool stats
|
||||
let stats = db.get_pool_stats().await;
|
||||
let stats = db.get_pool_stats();
|
||||
assert!(stats.size >= 1);
|
||||
assert!(stats.idle >= 1);
|
||||
assert_eq!(stats.size, stats.idle + stats.active);
|
||||
|
|
@ -67,21 +64,21 @@ fn test_config_loading() -> anyhow::Result<()> {
|
|||
#[test]
|
||||
fn test_config_validation() -> anyhow::Result<()> {
|
||||
// Test valid config
|
||||
let config = Config::default();
|
||||
assert!(config.validate().is_ok());
|
||||
let base_config = Config::default();
|
||||
assert!(base_config.validate().is_ok());
|
||||
|
||||
// Test invalid database URL
|
||||
let mut config = config.clone();
|
||||
let mut config = base_config.clone();
|
||||
config.database.url = "invalid://url".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Test invalid port
|
||||
let mut config = config.clone();
|
||||
let mut config = base_config.clone();
|
||||
config.server.port = 0;
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Test invalid connections
|
||||
let mut config = config.clone();
|
||||
let mut config = base_config.clone();
|
||||
config.database.max_connections = 0;
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
|
|
@ -90,12 +87,12 @@ fn test_config_validation() -> anyhow::Result<()> {
|
|||
assert!(config.validate().is_err());
|
||||
|
||||
// Test invalid evaluator settings
|
||||
let mut config = config.clone();
|
||||
let mut config = base_config.clone();
|
||||
config.evaluator.poll_interval = 0;
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Test invalid queue runner settings
|
||||
let mut config = config.clone();
|
||||
let mut config = base_config;
|
||||
config.queue_runner.workers = 0;
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
|
|
@ -109,12 +106,12 @@ fn test_database_config_validation() -> anyhow::Result<()> {
|
|||
assert!(config.validate().is_ok());
|
||||
|
||||
// Test invalid URL
|
||||
let mut config = config.clone();
|
||||
let mut config = config;
|
||||
config.url = "invalid://url".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Test empty URL
|
||||
config.url = "".to_string();
|
||||
config.url = String::new();
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Test zero max connections
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
//! Integration tests for repository CRUD operations.
|
||||
//! Requires TEST_DATABASE_URL to be set to a PostgreSQL connection string.
|
||||
//! Requires `TEST_DATABASE_URL` to be set to a `PostgreSQL` connection string.
|
||||
|
||||
use fc_common::{models::*, repo};
|
||||
|
||||
async fn get_pool() -> Option<sqlx::PgPool> {
|
||||
let url = match std::env::var("TEST_DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
println!("Skipping repo test: TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
},
|
||||
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
|
||||
println!("Skipping repo test: TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -85,7 +82,7 @@ async fn create_test_build(
|
|||
evaluation_id: eval_id,
|
||||
job_name: job_name.to_string(),
|
||||
drv_path: drv_path.to_string(),
|
||||
system: system.map(|s| s.to_string()),
|
||||
system: system.map(std::string::ToString::to_string),
|
||||
outputs: None,
|
||||
is_aggregate: None,
|
||||
constituents: None,
|
||||
|
|
@ -98,9 +95,8 @@ async fn create_test_build(
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_project_crud() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Create
|
||||
|
|
@ -148,9 +144,8 @@ async fn test_project_crud() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_project_unique_constraint() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let name = format!("unique-test-{}", uuid::Uuid::new_v4());
|
||||
|
|
@ -176,9 +171,8 @@ async fn test_project_unique_constraint() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_jobset_crud() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let project = create_test_project(&pool, "jobset").await;
|
||||
|
|
@ -242,9 +236,8 @@ async fn test_jobset_crud() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_evaluation_and_build_lifecycle() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Set up project and jobset
|
||||
|
|
@ -391,9 +384,8 @@ async fn test_evaluation_and_build_lifecycle() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_not_found_errors() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let fake_id = uuid::Uuid::new_v4();
|
||||
|
|
@ -423,9 +415,8 @@ async fn test_not_found_errors() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_batch_get_completed_by_drv_paths() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let project = create_test_project(&pool, "batch-drv").await;
|
||||
|
|
@ -493,9 +484,8 @@ async fn test_batch_get_completed_by_drv_paths() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_batch_check_deps_for_builds() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let project = create_test_project(&pool, "batch-deps").await;
|
||||
|
|
@ -568,9 +558,8 @@ async fn test_batch_check_deps_for_builds() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_list_filtered_with_system_filter() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let project = create_test_project(&pool, "filter-sys").await;
|
||||
|
|
@ -641,9 +630,8 @@ async fn test_list_filtered_with_system_filter() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_list_filtered_with_job_name_filter() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let project = create_test_project(&pool, "filter-job").await;
|
||||
|
|
@ -705,9 +693,8 @@ async fn test_list_filtered_with_job_name_filter() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_reset_orphaned_batch_limit() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let project = create_test_project(&pool, "orphan").await;
|
||||
|
|
@ -747,9 +734,8 @@ async fn test_reset_orphaned_batch_limit() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_build_cancel_cascade() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let project = create_test_project(&pool, "cancel-cascade").await;
|
||||
|
|
@ -786,9 +772,8 @@ async fn test_build_cancel_cascade() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_dedup_by_drv_path() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let project = create_test_project(&pool, "dedup").await;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
//! Integration tests for advanced search functionality
|
||||
//! Requires TEST_DATABASE_URL to be set to a PostgreSQL connection string.
|
||||
//! Requires `TEST_DATABASE_URL` to be set to a `PostgreSQL` connection string.
|
||||
|
||||
use fc_common::{BuildStatus, models::*, repo, repo::search::*};
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn get_pool() -> Option<sqlx::PgPool> {
|
||||
let url = match std::env::var("TEST_DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
println!("Skipping search test: TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
},
|
||||
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
|
||||
println!("Skipping search test: TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -27,9 +24,8 @@ async fn get_pool() -> Option<sqlx::PgPool> {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_project_search() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Create test projects
|
||||
|
|
@ -93,9 +89,8 @@ async fn test_project_search() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_build_search_with_filters() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Setup: project -> jobset -> evaluation -> builds
|
||||
|
|
@ -209,7 +204,7 @@ async fn test_build_search_with_filters() {
|
|||
|
||||
// Search with status filter (succeeded)
|
||||
let params = SearchParams {
|
||||
query: "".to_string(),
|
||||
query: String::new(),
|
||||
entities: vec![SearchEntity::Builds],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
|
|
@ -240,9 +235,8 @@ async fn test_build_search_with_filters() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_multi_entity_search() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Create project with jobset, evaluation, and build
|
||||
|
|
@ -324,9 +318,8 @@ async fn test_multi_entity_search() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_search_pagination() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Create multiple projects
|
||||
|
|
@ -334,7 +327,7 @@ async fn test_search_pagination() {
|
|||
for i in 0..5 {
|
||||
let project = repo::projects::create(&pool, CreateProject {
|
||||
name: format!("page-test-{}-{}", i, Uuid::new_v4().simple()),
|
||||
description: Some(format!("Page test project {}", i)),
|
||||
description: Some(format!("Page test project {i}")),
|
||||
repository_url: "https://github.com/test/page".to_string(),
|
||||
})
|
||||
.await
|
||||
|
|
@ -385,9 +378,8 @@ async fn test_search_pagination() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_search_sorting() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Create projects in reverse alphabetical order
|
||||
|
|
@ -433,14 +425,13 @@ async fn test_search_sorting() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_empty_search() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Empty query should return all entities (up to limit)
|
||||
let params = SearchParams {
|
||||
query: "".to_string(),
|
||||
query: String::new(),
|
||||
entities: vec![SearchEntity::Projects],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
|
|
@ -459,9 +450,8 @@ async fn test_empty_search() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_quick_search() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Create test data: project -> jobset -> evaluation -> build
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
//! Integration tests for user management - CRUD, authentication, and
|
||||
//! relationships. Requires TEST_DATABASE_URL to be set to a PostgreSQL
|
||||
//! relationships. Requires `TEST_DATABASE_URL` to be set to a `PostgreSQL`
|
||||
//! connection string.
|
||||
|
||||
use fc_common::{models::*, repo};
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn get_pool() -> Option<sqlx::PgPool> {
|
||||
let url = match std::env::var("TEST_DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
println!("Skipping repo test: TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
},
|
||||
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
|
||||
println!("Skipping repo test: TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -28,13 +25,12 @@ async fn get_pool() -> Option<sqlx::PgPool> {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_user_crud() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let username = format!("test-user-{}", Uuid::new_v4().simple());
|
||||
let email = format!("{}@example.com", username);
|
||||
let email = format!("{username}@example.com");
|
||||
|
||||
// Create user
|
||||
let user = repo::users::create(&pool, &CreateUser {
|
||||
|
|
@ -82,7 +78,7 @@ async fn test_user_crud() {
|
|||
assert!(count > 0);
|
||||
|
||||
// Update email
|
||||
let new_email = format!("updated-{}", email);
|
||||
let new_email = format!("updated-{email}");
|
||||
let updated = repo::users::update_email(&pool, user.id, &new_email)
|
||||
.await
|
||||
.expect("update email");
|
||||
|
|
@ -135,9 +131,8 @@ async fn test_user_crud() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_user_authentication() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let username = format!("auth-test-{}", Uuid::new_v4().simple());
|
||||
|
|
@ -146,7 +141,7 @@ async fn test_user_authentication() {
|
|||
// Create user
|
||||
let user = repo::users::create(&pool, &CreateUser {
|
||||
username: username.clone(),
|
||||
email: format!("{}@example.com", username),
|
||||
email: format!("{username}@example.com"),
|
||||
full_name: None,
|
||||
password: password.to_string(),
|
||||
role: None,
|
||||
|
|
@ -234,13 +229,12 @@ async fn test_password_hashing() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_user_unique_constraints() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let username = format!("unique-{}", Uuid::new_v4().simple());
|
||||
let email = format!("{}@example.com", username);
|
||||
let email = format!("{username}@example.com");
|
||||
|
||||
// Create first user
|
||||
let _ = repo::users::create(&pool, &CreateUser {
|
||||
|
|
@ -256,7 +250,7 @@ async fn test_user_unique_constraints() {
|
|||
// Try to create with same username
|
||||
let result = repo::users::create(&pool, &CreateUser {
|
||||
username: username.clone(),
|
||||
email: format!("other-{}", email),
|
||||
email: format!("other-{email}"),
|
||||
full_name: None,
|
||||
password: "password".to_string(),
|
||||
role: None,
|
||||
|
|
@ -266,7 +260,7 @@ async fn test_user_unique_constraints() {
|
|||
|
||||
// Try to create with same email
|
||||
let result = repo::users::create(&pool, &CreateUser {
|
||||
username: format!("other-{}", username),
|
||||
username: format!("other-{username}"),
|
||||
email: email.clone(),
|
||||
full_name: None,
|
||||
password: "password".to_string(),
|
||||
|
|
@ -285,13 +279,12 @@ async fn test_user_unique_constraints() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_oauth_user_creation() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let username = format!("oauth-user-{}", Uuid::new_v4().simple());
|
||||
let email = format!("{}@github.com", username);
|
||||
let email = format!("{username}@github.com");
|
||||
let oauth_provider_id = format!("github_{}", Uuid::new_v4().simple());
|
||||
|
||||
// Create OAuth user
|
||||
|
|
@ -330,9 +323,8 @@ async fn test_oauth_user_creation() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_starred_jobs_crud() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Create prerequisite data
|
||||
|
|
@ -442,9 +434,8 @@ async fn test_starred_jobs_crud() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_starred_jobs_delete_by_job() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Setup
|
||||
|
|
@ -516,9 +507,8 @@ async fn test_starred_jobs_delete_by_job() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_project_members_crud() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Setup
|
||||
|
|
@ -615,9 +605,8 @@ async fn test_project_members_crud() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_project_members_permissions() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Setup
|
||||
|
|
@ -809,9 +798,8 @@ async fn test_project_members_permissions() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_user_not_found_errors() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let fake_id = Uuid::new_v4();
|
||||
|
|
|
|||
|
|
@ -20,9 +20,15 @@ use tokio::sync::Notify;
|
|||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Main evaluator loop. Polls jobsets and runs nix evaluations.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if evaluation cycle fails and `strict_errors` is enabled.
|
||||
pub async fn run(
|
||||
pool: PgPool,
|
||||
config: EvaluatorConfig,
|
||||
notifications_config: fc_common::config::NotificationsConfig,
|
||||
wakeup: Arc<Notify>,
|
||||
) -> anyhow::Result<()> {
|
||||
let poll_interval = Duration::from_secs(config.poll_interval);
|
||||
|
|
@ -32,7 +38,15 @@ pub async fn run(
|
|||
let strict = config.strict_errors;
|
||||
|
||||
loop {
|
||||
if let Err(e) = run_cycle(&pool, &config, nix_timeout, git_timeout).await {
|
||||
if let Err(e) = run_cycle(
|
||||
&pool,
|
||||
&config,
|
||||
¬ifications_config,
|
||||
nix_timeout,
|
||||
git_timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if strict {
|
||||
return Err(e);
|
||||
}
|
||||
|
|
@ -46,6 +60,7 @@ pub async fn run(
|
|||
async fn run_cycle(
|
||||
pool: &PgPool,
|
||||
config: &EvaluatorConfig,
|
||||
notifications_config: &fc_common::config::NotificationsConfig,
|
||||
nix_timeout: Duration,
|
||||
git_timeout: Duration,
|
||||
) -> anyhow::Result<()> {
|
||||
|
|
@ -57,13 +72,10 @@ async fn run_cycle(
|
|||
let ready: Vec<_> = active
|
||||
.into_iter()
|
||||
.filter(|js| {
|
||||
match js.last_checked_at {
|
||||
Some(last) => {
|
||||
let elapsed = (now - last).num_seconds();
|
||||
elapsed >= i64::from(js.check_interval)
|
||||
},
|
||||
None => true, // Never checked, evaluate now
|
||||
}
|
||||
js.last_checked_at.is_none_or(|last| {
|
||||
let elapsed = (now - last).num_seconds();
|
||||
elapsed >= i64::from(js.check_interval)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -74,8 +86,15 @@ async fn run_cycle(
|
|||
stream::iter(ready)
|
||||
.for_each_concurrent(max_concurrent, |jobset| {
|
||||
async move {
|
||||
if let Err(e) =
|
||||
evaluate_jobset(pool, &jobset, config, nix_timeout, git_timeout).await
|
||||
if let Err(e) = evaluate_jobset(
|
||||
pool,
|
||||
&jobset,
|
||||
config,
|
||||
notifications_config,
|
||||
nix_timeout,
|
||||
git_timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
jobset_id = %jobset.id,
|
||||
|
|
@ -91,11 +110,10 @@ async fn run_cycle(
|
|||
|| msg.contains("sqlite")
|
||||
{
|
||||
tracing::error!(
|
||||
"DISK SPACE ISSUE DETECTED: Evaluation failed due to disk space \
|
||||
problems. Please free up space on the server:\n- Run \
|
||||
`nix-collect-garbage -d` to clean the Nix store\n- Clear \
|
||||
/tmp/fc-evaluator directory\n- Check build logs directory if \
|
||||
configured"
|
||||
"Evaluation failed due to disk space problems. Please free up \
|
||||
space on the server:\n- Run `nix-collect-garbage -d` to clean \
|
||||
the Nix store\n- Clear /tmp/fc-evaluator directory\n- Check \
|
||||
build logs directory if configured"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -110,6 +128,7 @@ async fn evaluate_jobset(
|
|||
pool: &PgPool,
|
||||
jobset: &fc_common::models::ActiveJobset,
|
||||
config: &EvaluatorConfig,
|
||||
notifications_config: &fc_common::config::NotificationsConfig,
|
||||
nix_timeout: Duration,
|
||||
git_timeout: Duration,
|
||||
) -> anyhow::Result<()> {
|
||||
|
|
@ -129,13 +148,13 @@ async fn evaluate_jobset(
|
|||
if info.is_critical() {
|
||||
tracing::error!(
|
||||
jobset = %jobset.name,
|
||||
"CRITICAL: Less than 1GB disk space available. {}",
|
||||
"Less than 1GB disk space available. {}",
|
||||
info.summary()
|
||||
);
|
||||
} else if info.is_low() {
|
||||
tracing::warn!(
|
||||
jobset = %jobset.name,
|
||||
"LOW: Less than 5GB disk space available. {}",
|
||||
"Less than 5GB disk space available. {}",
|
||||
info.summary()
|
||||
);
|
||||
}
|
||||
|
|
@ -277,13 +296,12 @@ async fn evaluate_jobset(
|
|||
);
|
||||
}
|
||||
return Ok(());
|
||||
} else {
|
||||
info!(
|
||||
"Evaluation completed but has 0 builds, re-running nix evaluation \
|
||||
jobset={} commit={}",
|
||||
jobset.name, commit_hash
|
||||
);
|
||||
}
|
||||
info!(
|
||||
"Evaluation completed but has 0 builds, re-running nix evaluation \
|
||||
jobset={} commit={}",
|
||||
jobset.name, commit_hash
|
||||
);
|
||||
}
|
||||
existing
|
||||
},
|
||||
|
|
@ -326,6 +344,41 @@ async fn evaluate_jobset(
|
|||
|
||||
create_builds_from_eval(pool, eval.id, &eval_result).await?;
|
||||
|
||||
// Dispatch pending notifications for created builds
|
||||
if notifications_config.enable_retry_queue {
|
||||
if let Ok(project) = repo::projects::get(pool, jobset.project_id).await
|
||||
{
|
||||
if let Ok(builds) =
|
||||
repo::builds::list_for_evaluation(pool, eval.id).await
|
||||
{
|
||||
for build in builds {
|
||||
// Skip aggregate builds (they complete later when constituents
|
||||
// finish)
|
||||
if !build.is_aggregate {
|
||||
fc_common::notifications::dispatch_build_created(
|
||||
pool,
|
||||
&build,
|
||||
&project,
|
||||
&eval.commit_hash,
|
||||
notifications_config,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
eval_id = %eval.id,
|
||||
"Failed to fetch builds for pending notifications"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
project_id = %jobset.project_id,
|
||||
"Failed to fetch project for pending notifications"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
repo::evaluations::update_status(
|
||||
pool,
|
||||
eval.id,
|
||||
|
|
@ -420,12 +473,10 @@ async fn create_builds_from_eval(
|
|||
for dep_drv in input_drvs.keys() {
|
||||
if let Some(&dep_build_id) = drv_to_build.get(dep_drv)
|
||||
&& dep_build_id != build_id
|
||||
{
|
||||
if let Err(e) =
|
||||
&& let Err(e) =
|
||||
repo::build_dependencies::create(pool, build_id, dep_build_id).await
|
||||
{
|
||||
tracing::warn!(build_id = %build_id, dep = %dep_build_id, "Failed to create build dependency: {e}");
|
||||
}
|
||||
{
|
||||
tracing::warn!(build_id = %build_id, dep = %dep_build_id, "Failed to create build dependency: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -435,12 +486,10 @@ async fn create_builds_from_eval(
|
|||
for constituent_name in constituents {
|
||||
if let Some(&dep_build_id) = name_to_build.get(constituent_name)
|
||||
&& dep_build_id != build_id
|
||||
{
|
||||
if let Err(e) =
|
||||
&& let Err(e) =
|
||||
repo::build_dependencies::create(pool, build_id, dep_build_id).await
|
||||
{
|
||||
tracing::warn!(build_id = %build_id, dep = %dep_build_id, "Failed to create constituent dependency: {e}");
|
||||
}
|
||||
{
|
||||
tracing::warn!(build_id = %build_id, dep = %dep_build_id, "Failed to create constituent dependency: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -450,7 +499,7 @@ async fn create_builds_from_eval(
|
|||
}
|
||||
|
||||
/// Compute a deterministic hash over the commit and all jobset inputs.
|
||||
/// Used for evaluation caching — skip re-eval when inputs haven't changed.
|
||||
/// Used for evaluation caching, so skip re-eval when inputs haven't changed.
|
||||
fn compute_inputs_hash(commit_hash: &str, inputs: &[JobsetInput]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
|
|
@ -480,6 +529,20 @@ async fn check_declarative_config(
|
|||
repo_path: &std::path::Path,
|
||||
project_id: Uuid,
|
||||
) {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DeclarativeConfig {
|
||||
jobsets: Option<Vec<DeclarativeJobset>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DeclarativeJobset {
|
||||
name: String,
|
||||
nix_expression: String,
|
||||
flake_mode: Option<bool>,
|
||||
check_interval: Option<i32>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
let config_path = repo_path.join(".fc.toml");
|
||||
let alt_config_path = repo_path.join(".fc/config.toml");
|
||||
|
||||
|
|
@ -502,20 +565,6 @@ async fn check_declarative_config(
|
|||
},
|
||||
};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DeclarativeConfig {
|
||||
jobsets: Option<Vec<DeclarativeJobset>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DeclarativeJobset {
|
||||
name: String,
|
||||
nix_expression: String,
|
||||
flake_mode: Option<bool>,
|
||||
check_interval: Option<i32>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
let config: DeclarativeConfig = match toml::from_str(&content) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ use git2::Repository;
|
|||
///
|
||||
/// If `branch` is `Some`, resolve `refs/remotes/origin/<branch>` instead of
|
||||
/// HEAD.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if git operations fail.
|
||||
#[tracing::instrument(skip(work_dir))]
|
||||
pub fn clone_or_fetch(
|
||||
url: &str,
|
||||
|
|
@ -20,7 +24,7 @@ pub fn clone_or_fetch(
|
|||
|
||||
let repo = if is_fetch {
|
||||
let repo = Repository::open(&repo_path)?;
|
||||
// Fetch origin — scope the borrow so `remote` is dropped before we move
|
||||
// Fetch origin. Scope the borrow so `remote` is dropped before we move
|
||||
// `repo`
|
||||
{
|
||||
let mut remote = repo.find_remote("origin")?;
|
||||
|
|
@ -34,12 +38,11 @@ pub fn clone_or_fetch(
|
|||
// Resolve commit from remote refs (which are always up-to-date after fetch).
|
||||
// When no branch is specified, detect the default branch from local HEAD's
|
||||
// tracking target.
|
||||
let branch_name = match branch {
|
||||
Some(b) => b.to_string(),
|
||||
None => {
|
||||
let head = repo.head()?;
|
||||
head.shorthand().unwrap_or("master").to_string()
|
||||
},
|
||||
let branch_name = if let Some(b) = branch {
|
||||
b.to_string()
|
||||
} else {
|
||||
let head = repo.head()?;
|
||||
head.shorthand().unwrap_or("master").to_string()
|
||||
};
|
||||
|
||||
let remote_ref = format!("refs/remotes/origin/{branch_name}");
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
let pool = db.pool().clone();
|
||||
let eval_config = config.evaluator;
|
||||
let notifications_config = config.notifications;
|
||||
|
||||
let wakeup = Arc::new(tokio::sync::Notify::new());
|
||||
let listener_handle = fc_common::pg_notify::spawn_listener(
|
||||
|
|
@ -39,7 +40,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
);
|
||||
|
||||
tokio::select! {
|
||||
result = fc_evaluator::eval_loop::run(pool, eval_config, wakeup) => {
|
||||
result = fc_evaluator::eval_loop::run(pool, eval_config, notifications_config, wakeup) => {
|
||||
if let Err(e) = result {
|
||||
tracing::error!("Evaluator loop failed: {e}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,10 @@ pub fn parse_eval_output(stdout: &str) -> EvalResult {
|
|||
/// Evaluate nix expressions and return discovered jobs.
|
||||
/// If `flake_mode` is true, uses nix-eval-jobs with --flake flag.
|
||||
/// If `flake_mode` is false, evaluates a legacy expression file.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if nix evaluation command fails or times out.
|
||||
#[tracing::instrument(skip(config, inputs), fields(flake_mode, nix_expression))]
|
||||
pub async fn evaluate(
|
||||
repo_path: &Path,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Tests for the git clone/fetch module.
|
||||
//! Uses git2 to create a temporary repository, then exercises clone_or_fetch.
|
||||
//! Uses git2 to create a temporary repository, then exercises `clone_or_fetch`.
|
||||
|
||||
use git2::{Repository, Signature};
|
||||
use tempfile::TempDir;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ const MAX_LOG_SIZE: usize = 100 * 1024 * 1024; // 100MB
|
|||
skip(work_dir, live_log_path),
|
||||
fields(drv_path, store_uri)
|
||||
)]
|
||||
/// Run a nix build on a remote builder via SSH.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if nix build command fails or times out.
|
||||
pub async fn run_nix_build_remote(
|
||||
drv_path: &str,
|
||||
work_dir: &Path,
|
||||
|
|
@ -120,14 +125,11 @@ pub async fn run_nix_build_remote(
|
|||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(inner) => inner,
|
||||
Err(_) => {
|
||||
Err(CiError::Timeout(format!(
|
||||
"Remote build timed out after {timeout:?}"
|
||||
)))
|
||||
},
|
||||
}
|
||||
result.unwrap_or_else(|_| {
|
||||
Err(CiError::Timeout(format!(
|
||||
"Remote build timed out after {timeout:?}"
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
pub struct BuildResult {
|
||||
|
|
@ -165,6 +167,10 @@ pub fn parse_nix_log_line(line: &str) -> Option<(&'static str, String)> {
|
|||
/// Run `nix build` for a derivation path.
|
||||
/// If `live_log_path` is provided, build output is streamed to that file
|
||||
/// incrementally.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if nix build command fails or times out.
|
||||
#[tracing::instrument(skip(work_dir, live_log_path), fields(drv_path))]
|
||||
pub async fn run_nix_build(
|
||||
drv_path: &str,
|
||||
|
|
@ -299,12 +305,9 @@ pub async fn run_nix_build(
|
|||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(inner) => inner,
|
||||
Err(_) => {
|
||||
Err(CiError::Timeout(format!(
|
||||
"Build timed out after {timeout:?}"
|
||||
)))
|
||||
},
|
||||
}
|
||||
result.unwrap_or_else(|_| {
|
||||
Err(CiError::Timeout(format!(
|
||||
"Build timed out after {timeout:?}"
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
let active_builds = worker_pool.active_builds().clone();
|
||||
|
||||
tokio::select! {
|
||||
result = fc_queue_runner::runner_loop::run(db.pool().clone(), worker_pool, poll_interval, wakeup, strict_errors, failed_paths_cache) => {
|
||||
result = fc_queue_runner::runner_loop::run(db.pool().clone(), worker_pool, poll_interval, wakeup, strict_errors, failed_paths_cache, notifications_config.clone()) => {
|
||||
if let Err(e) = result {
|
||||
tracing::error!("Runner loop failed: {e}");
|
||||
}
|
||||
|
|
@ -175,7 +175,7 @@ async fn failed_paths_cleanup_loop(
|
|||
return std::future::pending().await;
|
||||
}
|
||||
|
||||
let interval = std::time::Duration::from_secs(3600);
|
||||
let interval = std::time::Duration::from_hours(1);
|
||||
loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
match fc_common::repo::failed_paths_cache::cleanup_expired(&pool, ttl).await
|
||||
|
|
@ -233,7 +233,7 @@ async fn notification_retry_loop(
|
|||
|
||||
let cleanup_pool = pool.clone();
|
||||
tokio::spawn(async move {
|
||||
let cleanup_interval = std::time::Duration::from_secs(3600);
|
||||
let cleanup_interval = std::time::Duration::from_hours(1);
|
||||
loop {
|
||||
tokio::time::sleep(cleanup_interval).await;
|
||||
match repo::notification_tasks::cleanup_old_tasks(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use fc_common::{
|
||||
models::{BuildStatus, JobsetState},
|
||||
models::{Build, BuildStatus, JobsetState},
|
||||
repo,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
|
@ -9,6 +9,27 @@ use tokio::sync::Notify;
|
|||
|
||||
use crate::worker::WorkerPool;
|
||||
|
||||
/// Fetch project and commit hash for a build by traversing:
|
||||
///
|
||||
/// Build -> Evaluation -> Jobset -> Project.
|
||||
async fn get_project_for_build(
|
||||
pool: &PgPool,
|
||||
build: &Build,
|
||||
) -> Option<(fc_common::models::Project, String)> {
|
||||
let eval = repo::evaluations::get(pool, build.evaluation_id)
|
||||
.await
|
||||
.ok()?;
|
||||
let jobset = repo::jobsets::get(pool, eval.jobset_id).await.ok()?;
|
||||
let project = repo::projects::get(pool, jobset.project_id).await.ok()?;
|
||||
Some((project, eval.commit_hash))
|
||||
}
|
||||
|
||||
/// Main queue runner loop. Polls for pending builds and dispatches them to
|
||||
/// workers.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns error if database operations fail and `strict_errors` is enabled.
|
||||
pub async fn run(
|
||||
pool: PgPool,
|
||||
worker_pool: Arc<WorkerPool>,
|
||||
|
|
@ -16,6 +37,7 @@ pub async fn run(
|
|||
wakeup: Arc<Notify>,
|
||||
strict_errors: bool,
|
||||
failed_paths_cache: bool,
|
||||
notifications_config: fc_common::config::NotificationsConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
// Reset orphaned builds from previous crashes (older than 5 minutes)
|
||||
match repo::builds::reset_orphaned(&pool, 300).await {
|
||||
|
|
@ -42,7 +64,7 @@ pub async fn run(
|
|||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
// All constituents done — mark aggregate as completed
|
||||
// All constituents done, mark aggregate as completed
|
||||
tracing::info!(
|
||||
build_id = %build.id,
|
||||
job = %build.job_name,
|
||||
|
|
@ -62,6 +84,23 @@ pub async fn run(
|
|||
.await
|
||||
{
|
||||
tracing::warn!(build_id = %build.id, "Failed to complete aggregate build: {e}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dispatch completion notification for aggregate build
|
||||
if let Ok(updated_build) =
|
||||
repo::builds::get(&pool, build.id).await
|
||||
&& let Some((project, commit_hash)) =
|
||||
get_project_for_build(&pool, &updated_build).await
|
||||
{
|
||||
fc_common::notifications::dispatch_build_finished(
|
||||
Some(&pool),
|
||||
&updated_build,
|
||||
&project,
|
||||
&commit_hash,
|
||||
¬ifications_config,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
continue;
|
||||
},
|
||||
|
|
@ -115,34 +154,36 @@ pub async fn run(
|
|||
}
|
||||
|
||||
// Failed paths cache: skip known-failing derivations
|
||||
if failed_paths_cache {
|
||||
if let Ok(true) = repo::failed_paths_cache::is_cached_failure(
|
||||
if failed_paths_cache
|
||||
&& matches!(
|
||||
repo::failed_paths_cache::is_cached_failure(
|
||||
&pool,
|
||||
&build.drv_path,
|
||||
)
|
||||
.await,
|
||||
Ok(true)
|
||||
)
|
||||
{
|
||||
tracing::info!(
|
||||
build_id = %build.id, drv = %build.drv_path,
|
||||
"Cached failure: skipping known-failing derivation"
|
||||
);
|
||||
if let Err(e) = repo::builds::start(&pool, build.id).await {
|
||||
tracing::warn!(build_id = %build.id, "Failed to start cached-failure build: {e}");
|
||||
}
|
||||
if let Err(e) = repo::builds::complete(
|
||||
&pool,
|
||||
&build.drv_path,
|
||||
build.id,
|
||||
BuildStatus::CachedFailure,
|
||||
None,
|
||||
None,
|
||||
Some("Build skipped: derivation is in failed paths cache"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::info!(
|
||||
build_id = %build.id, drv = %build.drv_path,
|
||||
"Cached failure: skipping known-failing derivation"
|
||||
);
|
||||
if let Err(e) = repo::builds::start(&pool, build.id).await {
|
||||
tracing::warn!(build_id = %build.id, "Failed to start cached-failure build: {e}");
|
||||
}
|
||||
if let Err(e) = repo::builds::complete(
|
||||
&pool,
|
||||
build.id,
|
||||
BuildStatus::CachedFailure,
|
||||
None,
|
||||
None,
|
||||
Some("Build skipped: derivation is in failed paths cache"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(build_id = %build.id, "Failed to complete cached-failure build: {e}");
|
||||
}
|
||||
continue;
|
||||
tracing::warn!(build_id = %build.id, "Failed to complete cached-failure build: {e}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dependency-aware scheduling: skip if deps not met
|
||||
|
|
|
|||
|
|
@ -102,11 +102,13 @@ impl WorkerPool {
|
|||
.await;
|
||||
}
|
||||
|
||||
pub fn worker_count(&self) -> usize {
|
||||
#[must_use]
|
||||
pub const fn worker_count(&self) -> usize {
|
||||
self.worker_count
|
||||
}
|
||||
|
||||
pub fn active_builds(&self) -> &ActiveBuilds {
|
||||
#[must_use]
|
||||
pub const fn active_builds(&self) -> &ActiveBuilds {
|
||||
&self.active_builds
|
||||
}
|
||||
|
||||
|
|
@ -135,9 +137,8 @@ impl WorkerPool {
|
|||
|
||||
tokio::spawn(async move {
|
||||
let result = async {
|
||||
let _permit = match semaphore.acquire().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
let Ok(_permit) = semaphore.acquire().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(e) = run_build(
|
||||
|
|
@ -287,7 +288,7 @@ async fn push_to_cache(
|
|||
|
||||
/// Build S3 store URI with configuration options.
|
||||
/// Nix S3 URIs support query parameters for configuration:
|
||||
/// s3://bucket?region=us-east-1&endpoint=https://minio.example.com
|
||||
/// <s3://bucket?region=us-east-1&endpoint=https://minio.example.com>
|
||||
fn build_s3_store_uri(
|
||||
base_uri: &str,
|
||||
config: Option<&fc_common::config::S3CacheConfig>,
|
||||
|
|
@ -325,66 +326,6 @@ fn build_s3_store_uri(
|
|||
format!("{base_uri}?{query}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fc_common::config::S3CacheConfig;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_s3_store_uri_no_config() {
|
||||
let result = build_s3_store_uri("s3://my-bucket", None);
|
||||
assert_eq!(result, "s3://my-bucket");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_s3_store_uri_empty_config() {
|
||||
let cfg = S3CacheConfig::default();
|
||||
let result = build_s3_store_uri("s3://my-bucket", Some(&cfg));
|
||||
assert_eq!(result, "s3://my-bucket");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_s3_store_uri_with_region() {
|
||||
let cfg = S3CacheConfig {
|
||||
region: Some("us-east-1".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_s3_store_uri("s3://my-bucket", Some(&cfg));
|
||||
assert_eq!(result, "s3://my-bucket?region=us-east-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_s3_store_uri_with_endpoint_and_path_style() {
|
||||
let cfg = S3CacheConfig {
|
||||
endpoint_url: Some("https://minio.example.com".to_string()),
|
||||
use_path_style: true,
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_s3_store_uri("s3://my-bucket", Some(&cfg));
|
||||
assert!(result.starts_with("s3://my-bucket?"));
|
||||
assert!(result.contains("endpoint=https%3A%2F%2Fminio.example.com"));
|
||||
assert!(result.contains("use-path-style=true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_s3_store_uri_all_params() {
|
||||
let cfg = S3CacheConfig {
|
||||
region: Some("eu-west-1".to_string()),
|
||||
endpoint_url: Some("https://s3.example.com".to_string()),
|
||||
use_path_style: true,
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_s3_store_uri("s3://cache-bucket", Some(&cfg));
|
||||
assert!(result.starts_with("s3://cache-bucket?"));
|
||||
assert!(result.contains("region=eu-west-1"));
|
||||
assert!(result.contains("endpoint=https%3A%2F%2Fs3.example.com"));
|
||||
assert!(result.contains("use-path-style=true"));
|
||||
// Verify params are joined with &
|
||||
assert_eq!(result.matches('&').count(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to run the build on a remote builder if one is available for the build's
|
||||
/// system.
|
||||
async fn try_remote_build(
|
||||
|
|
@ -478,7 +419,7 @@ async fn collect_metrics_and_alert(
|
|||
}
|
||||
}
|
||||
|
||||
for path in output_paths.iter() {
|
||||
for path in output_paths {
|
||||
if let Ok(meta) = tokio::fs::metadata(path).await {
|
||||
let size = meta.len();
|
||||
if let Err(e) = repo::build_metrics::upsert(
|
||||
|
|
@ -497,21 +438,18 @@ async fn collect_metrics_and_alert(
|
|||
}
|
||||
}
|
||||
|
||||
let manager = match alert_manager {
|
||||
Some(m) => m,
|
||||
None => return,
|
||||
let Some(manager) = alert_manager else {
|
||||
return;
|
||||
};
|
||||
|
||||
if manager.is_enabled() {
|
||||
if let Ok(evaluation) =
|
||||
if manager.is_enabled()
|
||||
&& let Ok(evaluation) =
|
||||
repo::evaluations::get(pool, build.evaluation_id).await
|
||||
{
|
||||
if let Ok(jobset) = repo::jobsets::get(pool, evaluation.jobset_id).await {
|
||||
manager
|
||||
.check_and_alert(pool, Some(jobset.project_id), Some(jobset.id))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
&& let Ok(jobset) = repo::jobsets::get(pool, evaluation.jobset_id).await
|
||||
{
|
||||
manager
|
||||
.check_and_alert(pool, Some(jobset.project_id), Some(jobset.id))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -536,6 +474,22 @@ async fn run_build(
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
let claimed_build = claimed.unwrap(); // Safe: we checked is_some()
|
||||
|
||||
// Dispatch build started notification
|
||||
if let Some((project, commit_hash)) =
|
||||
get_project_for_build(pool, &claimed_build).await
|
||||
{
|
||||
fc_common::notifications::dispatch_build_started(
|
||||
pool,
|
||||
&claimed_build,
|
||||
&project,
|
||||
&commit_hash,
|
||||
notifications_config,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!(build_id = %build.id, job = %build.job_name, "Starting build");
|
||||
|
||||
// Create a build step record
|
||||
|
|
@ -561,7 +515,7 @@ async fn run_build(
|
|||
{
|
||||
Some(r) => Ok(r),
|
||||
None => {
|
||||
// No remote builder available or all failed — build locally
|
||||
// No remote builder available or all failed, build locally
|
||||
crate::builder::run_nix_build(
|
||||
&build.drv_path,
|
||||
work_dir,
|
||||
|
|
@ -705,10 +659,10 @@ async fn run_build(
|
|||
}
|
||||
|
||||
// Sign outputs at build time
|
||||
if sign_outputs(&build_result.output_paths, signing_config).await {
|
||||
if let Err(e) = repo::builds::mark_signed(pool, build.id).await {
|
||||
tracing::warn!(build_id = %build.id, "Failed to mark build as signed: {e}");
|
||||
}
|
||||
if sign_outputs(&build_result.output_paths, signing_config).await
|
||||
&& let Err(e) = repo::builds::mark_signed(pool, build.id).await
|
||||
{
|
||||
tracing::warn!(build_id = %build.id, "Failed to mark build as signed: {e}");
|
||||
}
|
||||
|
||||
// Push to external binary cache if configured
|
||||
|
|
@ -740,9 +694,9 @@ async fn run_build(
|
|||
|
||||
collect_metrics_and_alert(
|
||||
pool,
|
||||
&build,
|
||||
build,
|
||||
&build_result.output_paths,
|
||||
&alert_manager,
|
||||
alert_manager,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -775,8 +729,7 @@ async fn run_build(
|
|||
|
||||
let failure_status = build_result
|
||||
.exit_code
|
||||
.map(BuildStatus::from_exit_code)
|
||||
.unwrap_or(BuildStatus::Failed);
|
||||
.map_or(BuildStatus::Failed, BuildStatus::from_exit_code);
|
||||
repo::builds::complete(
|
||||
pool,
|
||||
build.id,
|
||||
|
|
@ -805,10 +758,10 @@ async fn run_build(
|
|||
let msg = e.to_string();
|
||||
|
||||
// Write error log
|
||||
if let Some(ref storage) = log_storage {
|
||||
if let Err(e) = storage.write_log(&build.id, "", &msg) {
|
||||
tracing::warn!(build_id = %build.id, "Failed to write error log: {e}");
|
||||
}
|
||||
if let Some(ref storage) = log_storage
|
||||
&& let Err(e) = storage.write_log(&build.id, "", &msg)
|
||||
{
|
||||
tracing::warn!(build_id = %build.id, "Failed to write error log: {e}");
|
||||
}
|
||||
// Clean up live log
|
||||
let _ = tokio::fs::remove_file(&live_log_path).await;
|
||||
|
|
@ -846,15 +799,73 @@ async fn run_build(
|
|||
// Auto-promote channels if all builds in the evaluation are done
|
||||
if updated_build.status.is_success()
|
||||
&& let Ok(eval) = repo::evaluations::get(pool, build.evaluation_id).await
|
||||
{
|
||||
if let Err(e) =
|
||||
&& let Err(e) =
|
||||
repo::channels::auto_promote_if_complete(pool, eval.jobset_id, eval.id)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(build_id = %build.id, "Failed to auto-promote channels: {e}");
|
||||
}
|
||||
{
|
||||
tracing::warn!(build_id = %build.id, "Failed to auto-promote channels: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fc_common::config::S3CacheConfig;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_s3_store_uri_no_config() {
|
||||
let result = build_s3_store_uri("s3://my-bucket", None);
|
||||
assert_eq!(result, "s3://my-bucket");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_s3_store_uri_empty_config() {
|
||||
let cfg = S3CacheConfig::default();
|
||||
let result = build_s3_store_uri("s3://my-bucket", Some(&cfg));
|
||||
assert_eq!(result, "s3://my-bucket");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_s3_store_uri_with_region() {
|
||||
let cfg = S3CacheConfig {
|
||||
region: Some("us-east-1".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_s3_store_uri("s3://my-bucket", Some(&cfg));
|
||||
assert_eq!(result, "s3://my-bucket?region=us-east-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_s3_store_uri_with_endpoint_and_path_style() {
|
||||
let cfg = S3CacheConfig {
|
||||
endpoint_url: Some("https://minio.example.com".to_string()),
|
||||
use_path_style: true,
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_s3_store_uri("s3://my-bucket", Some(&cfg));
|
||||
assert!(result.starts_with("s3://my-bucket?"));
|
||||
assert!(result.contains("endpoint=https%3A%2F%2Fminio.example.com"));
|
||||
assert!(result.contains("use-path-style=true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_s3_store_uri_all_params() {
|
||||
let cfg = S3CacheConfig {
|
||||
region: Some("eu-west-1".to_string()),
|
||||
endpoint_url: Some("https://s3.example.com".to_string()),
|
||||
use_path_style: true,
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_s3_store_uri("s3://cache-bucket", Some(&cfg));
|
||||
assert!(result.starts_with("s3://cache-bucket?"));
|
||||
assert!(result.contains("region=eu-west-1"));
|
||||
assert!(result.contains("endpoint=https%3A%2F%2Fs3.example.com"));
|
||||
assert!(result.contains("use-path-style=true"));
|
||||
// Verify params are joined with &
|
||||
assert_eq!(result.matches('&').count(), 2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Tests for the queue runner.
|
||||
//! Nix log parsing tests require no external binaries.
|
||||
//! Database tests require TEST_DATABASE_URL.
|
||||
//! Database tests require `TEST_DATABASE_URL`.
|
||||
|
||||
// Nix log line parsing
|
||||
|
||||
|
|
@ -65,12 +65,9 @@ fn test_parse_nix_log_empty_line() {
|
|||
#[tokio::test]
|
||||
async fn test_worker_pool_drain_stops_dispatch() {
|
||||
// Create a minimal worker pool
|
||||
let url = match std::env::var("TEST_DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
println!("Skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
},
|
||||
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
|
||||
println!("Skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -83,7 +80,7 @@ async fn test_worker_pool_drain_stops_dispatch() {
|
|||
pool,
|
||||
2,
|
||||
std::env::temp_dir(),
|
||||
std::time::Duration::from_secs(60),
|
||||
std::time::Duration::from_mins(1),
|
||||
fc_common::config::LogConfig::default(),
|
||||
fc_common::config::GcConfig::default(),
|
||||
fc_common::config::NotificationsConfig::default(),
|
||||
|
|
@ -153,7 +150,7 @@ async fn test_cancellation_token_aborts_select() {
|
|||
|
||||
// Simulate a long-running build
|
||||
let build_future = async {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_mins(1)).await;
|
||||
"completed"
|
||||
};
|
||||
|
||||
|
|
@ -176,12 +173,9 @@ async fn test_cancellation_token_aborts_select() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_worker_pool_active_builds_cancel() {
|
||||
let url = match std::env::var("TEST_DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
println!("Skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
},
|
||||
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
|
||||
println!("Skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -194,7 +188,7 @@ async fn test_worker_pool_active_builds_cancel() {
|
|||
pool,
|
||||
2,
|
||||
std::env::temp_dir(),
|
||||
std::time::Duration::from_secs(60),
|
||||
std::time::Duration::from_mins(1),
|
||||
fc_common::config::LogConfig::default(),
|
||||
fc_common::config::GcConfig::default(),
|
||||
fc_common::config::NotificationsConfig::default(),
|
||||
|
|
@ -228,12 +222,9 @@ async fn test_worker_pool_active_builds_cancel() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_fair_share_scheduling() {
|
||||
let url = match std::env::var("TEST_DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
println!("Skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
},
|
||||
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
|
||||
println!("Skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -447,12 +438,9 @@ async fn test_fair_share_scheduling() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_atomic_build_claiming() {
|
||||
let url = match std::env::var("TEST_DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
println!("Skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
},
|
||||
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
|
||||
println!("Skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -541,12 +529,9 @@ async fn test_atomic_build_claiming() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_orphan_build_reset() {
|
||||
let url = match std::env::var("TEST_DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
println!("Skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
},
|
||||
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
|
||||
println!("Skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -647,12 +632,9 @@ async fn test_orphan_build_reset() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_get_cancelled_among() {
|
||||
let url = match std::env::var("TEST_DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
println!("Skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
},
|
||||
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
|
||||
println!("Skipping: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ use crate::state::AppState;
|
|||
/// Write endpoints (POST/PUT/DELETE/PATCH) require a valid key.
|
||||
/// Read endpoints (GET/HEAD/OPTIONS) try to extract optionally (for
|
||||
/// dashboard admin UI).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns unauthorized status if no valid authentication is found for write
|
||||
/// operations.
|
||||
pub async fn require_api_key(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
|
|
@ -164,6 +169,12 @@ impl FromRequestParts<AppState> for RequireAdmin {
|
|||
pub struct RequireRoles;
|
||||
|
||||
impl RequireRoles {
|
||||
/// Check if the session has one of the allowed roles. Admin always passes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns unauthorized or forbidden status if authentication fails or role
|
||||
/// is insufficient.
|
||||
pub fn check(
|
||||
extensions: &axum::http::Extensions,
|
||||
allowed: &[&str],
|
||||
|
|
@ -212,18 +223,29 @@ pub async fn extract_session(
|
|||
.and_then(|v| v.to_str().ok())
|
||||
.map(String::from);
|
||||
|
||||
if let Some(ref auth_header) = auth_header {
|
||||
if let Some(token) = auth_header.strip_prefix("Bearer ") {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
let key_hash = hex::encode(hasher.finalize());
|
||||
if let Some(ref auth_header) = auth_header
|
||||
&& let Some(token) = auth_header.strip_prefix("Bearer ")
|
||||
{
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
let key_hash = hex::encode(hasher.finalize());
|
||||
|
||||
if let Ok(Some(api_key)) =
|
||||
fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await
|
||||
{
|
||||
request.extensions_mut().insert(api_key.clone());
|
||||
}
|
||||
if let Ok(Some(api_key)) =
|
||||
fc_common::repo::api_keys::get_by_hash(&state.pool, &key_hash).await
|
||||
{
|
||||
// Update last used timestamp asynchronously
|
||||
let pool = state.pool.clone();
|
||||
let key_id = api_key.id;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) =
|
||||
fc_common::repo::api_keys::touch_last_used(&pool, key_id).await
|
||||
{
|
||||
tracing::warn!(error = %e, "Failed to update API key last_used timestamp");
|
||||
}
|
||||
});
|
||||
|
||||
request.extensions_mut().insert(api_key);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -273,16 +295,13 @@ pub async fn extract_session(
|
|||
}
|
||||
|
||||
fn parse_cookie(header: &str, name: &str) -> Option<String> {
|
||||
header
|
||||
.split(';')
|
||||
.filter_map(|pair| {
|
||||
let pair = pair.trim();
|
||||
let (k, v) = pair.split_once('=')?;
|
||||
if k.trim() == name {
|
||||
Some(v.trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.next()
|
||||
header.split(';').find_map(|pair| {
|
||||
let pair = pair.trim();
|
||||
let (k, v) = pair.split_once('=')?;
|
||||
if k.trim() == name {
|
||||
Some(v.trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ async fn system_status(
|
|||
.await
|
||||
.map_err(|e| ApiError(fc_common::CiError::Database(e)))?;
|
||||
|
||||
let stats = fc_common::repo::builds::get_stats(pool)
|
||||
let build_stats = fc_common::repo::builds::get_stats(pool)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
let builders = fc_common::repo::remote_builders::count(pool)
|
||||
|
|
@ -112,10 +112,10 @@ async fn system_status(
|
|||
projects_count: projects.0,
|
||||
jobsets_count: jobsets.0,
|
||||
evaluations_count: evaluations.0,
|
||||
builds_pending: stats.pending_builds.unwrap_or(0),
|
||||
builds_running: stats.running_builds.unwrap_or(0),
|
||||
builds_completed: stats.completed_builds.unwrap_or(0),
|
||||
builds_failed: stats.failed_builds.unwrap_or(0),
|
||||
builds_pending: build_stats.pending_builds.unwrap_or(0),
|
||||
builds_running: build_stats.running_builds.unwrap_or(0),
|
||||
builds_completed: build_stats.completed_builds.unwrap_or(0),
|
||||
builds_failed: build_stats.failed_builds.unwrap_or(0),
|
||||
remote_builders: builders,
|
||||
channels_count: channels.0,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -29,11 +29,8 @@ async fn build_badge(
|
|||
.map_err(ApiError)?;
|
||||
|
||||
let jobset = jobsets.iter().find(|j| j.name == jobset_name);
|
||||
let jobset = match jobset {
|
||||
Some(j) => j,
|
||||
None => {
|
||||
return Ok(shield_svg("build", "not found", "#9f9f9f").into_response());
|
||||
},
|
||||
let Some(jobset) = jobset else {
|
||||
return Ok(shield_svg("build", "not found", "#9f9f9f").into_response());
|
||||
};
|
||||
|
||||
// Get latest evaluation
|
||||
|
|
@ -41,13 +38,10 @@ async fn build_badge(
|
|||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let eval = match eval {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
return Ok(
|
||||
shield_svg("build", "no evaluations", "#9f9f9f").into_response(),
|
||||
);
|
||||
},
|
||||
let Some(eval) = eval else {
|
||||
return Ok(
|
||||
shield_svg("build", "no evaluations", "#9f9f9f").into_response(),
|
||||
);
|
||||
};
|
||||
|
||||
// Find the build for this job
|
||||
|
|
@ -58,31 +52,24 @@ async fn build_badge(
|
|||
|
||||
let build = builds.iter().find(|b| b.job_name == job_name);
|
||||
|
||||
let (label, color) = match build {
|
||||
Some(b) => {
|
||||
match b.status {
|
||||
fc_common::BuildStatus::Succeeded => ("passing", "#4c1"),
|
||||
fc_common::BuildStatus::Failed => ("failing", "#e05d44"),
|
||||
fc_common::BuildStatus::Running => ("building", "#dfb317"),
|
||||
fc_common::BuildStatus::Pending => ("queued", "#dfb317"),
|
||||
fc_common::BuildStatus::Cancelled => ("cancelled", "#9f9f9f"),
|
||||
fc_common::BuildStatus::DependencyFailed => ("dep failed", "#e05d44"),
|
||||
fc_common::BuildStatus::Aborted => ("aborted", "#9f9f9f"),
|
||||
fc_common::BuildStatus::FailedWithOutput => {
|
||||
("failed output", "#e05d44")
|
||||
},
|
||||
fc_common::BuildStatus::Timeout => ("timeout", "#e05d44"),
|
||||
fc_common::BuildStatus::CachedFailure => ("cached fail", "#e05d44"),
|
||||
fc_common::BuildStatus::UnsupportedSystem => ("unsupported", "#9f9f9f"),
|
||||
fc_common::BuildStatus::LogLimitExceeded => ("log limit", "#e05d44"),
|
||||
fc_common::BuildStatus::NarSizeLimitExceeded => {
|
||||
("nar limit", "#e05d44")
|
||||
},
|
||||
fc_common::BuildStatus::NonDeterministic => ("non-det", "#e05d44"),
|
||||
}
|
||||
},
|
||||
None => ("not found", "#9f9f9f"),
|
||||
};
|
||||
let (label, color) = build.map_or(("not found", "#9f9f9f"), |b| {
|
||||
match b.status {
|
||||
fc_common::BuildStatus::Succeeded => ("passing", "#4c1"),
|
||||
fc_common::BuildStatus::Failed => ("failing", "#e05d44"),
|
||||
fc_common::BuildStatus::Running => ("building", "#dfb317"),
|
||||
fc_common::BuildStatus::Pending => ("queued", "#dfb317"),
|
||||
fc_common::BuildStatus::Cancelled => ("cancelled", "#9f9f9f"),
|
||||
fc_common::BuildStatus::DependencyFailed => ("dep failed", "#e05d44"),
|
||||
fc_common::BuildStatus::Aborted => ("aborted", "#9f9f9f"),
|
||||
fc_common::BuildStatus::FailedWithOutput => ("failed output", "#e05d44"),
|
||||
fc_common::BuildStatus::Timeout => ("timeout", "#e05d44"),
|
||||
fc_common::BuildStatus::CachedFailure => ("cached fail", "#e05d44"),
|
||||
fc_common::BuildStatus::UnsupportedSystem => ("unsupported", "#9f9f9f"),
|
||||
fc_common::BuildStatus::LogLimitExceeded => ("log limit", "#e05d44"),
|
||||
fc_common::BuildStatus::NarSizeLimitExceeded => ("nar limit", "#e05d44"),
|
||||
fc_common::BuildStatus::NonDeterministic => ("non-det", "#e05d44"),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(
|
||||
(
|
||||
|
|
@ -117,24 +104,16 @@ async fn latest_build(
|
|||
.map_err(ApiError)?;
|
||||
|
||||
let jobset = jobsets.iter().find(|j| j.name == jobset_name);
|
||||
let jobset = match jobset {
|
||||
Some(j) => j,
|
||||
None => {
|
||||
return Ok((StatusCode::NOT_FOUND, "Jobset not found").into_response());
|
||||
},
|
||||
let Some(jobset) = jobset else {
|
||||
return Ok((StatusCode::NOT_FOUND, "Jobset not found").into_response());
|
||||
};
|
||||
|
||||
let eval = fc_common::repo::evaluations::get_latest(&state.pool, jobset.id)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let eval = match eval {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
return Ok(
|
||||
(StatusCode::NOT_FOUND, "No evaluations found").into_response(),
|
||||
);
|
||||
},
|
||||
let Some(eval) = eval else {
|
||||
return Ok((StatusCode::NOT_FOUND, "No evaluations found").into_response());
|
||||
};
|
||||
|
||||
let builds =
|
||||
|
|
@ -143,10 +122,10 @@ async fn latest_build(
|
|||
.map_err(ApiError)?;
|
||||
|
||||
let build = builds.iter().find(|b| b.job_name == job_name);
|
||||
match build {
|
||||
Some(b) => Ok(axum::Json(b.clone()).into_response()),
|
||||
None => Ok((StatusCode::NOT_FOUND, "Build not found").into_response()),
|
||||
}
|
||||
build.map_or_else(
|
||||
|| Ok((StatusCode::NOT_FOUND, "Build not found").into_response()),
|
||||
|b| Ok(axum::Json(b.clone()).into_response()),
|
||||
)
|
||||
}
|
||||
|
||||
fn shield_svg(subject: &str, status: &str, color: &str) -> String {
|
||||
|
|
|
|||
|
|
@ -133,10 +133,10 @@ async fn list_build_products(
|
|||
async fn build_stats(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<fc_common::BuildStats>, ApiError> {
|
||||
let stats = fc_common::repo::builds::get_stats(&state.pool)
|
||||
let build_stats = fc_common::repo::builds::get_stats(&state.pool)
|
||||
.await
|
||||
.map_err(ApiError)?;
|
||||
Ok(Json(stats))
|
||||
Ok(Json(build_stats))
|
||||
}
|
||||
|
||||
async fn recent_builds(
|
||||
|
|
@ -242,13 +242,10 @@ async fn download_build_product(
|
|||
},
|
||||
};
|
||||
|
||||
let stdout = match child.stdout.take() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(ApiError(fc_common::CiError::Build(
|
||||
"Failed to capture output".to_string(),
|
||||
)));
|
||||
},
|
||||
let Some(stdout) = child.stdout.take() else {
|
||||
return Err(ApiError(fc_common::CiError::Build(
|
||||
"Failed to capture output".to_string(),
|
||||
)));
|
||||
};
|
||||
|
||||
let stream = tokio_util::io::ReaderStream::new(stdout);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ fn first_path_info_entry(
|
|||
}
|
||||
}
|
||||
|
||||
/// Look up a store path by its nix hash, checking both build_products and
|
||||
/// Look up a store path by its nix hash, checking both `build_products` and
|
||||
/// builds tables.
|
||||
async fn find_store_path(
|
||||
pool: &sqlx::PgPool,
|
||||
|
|
@ -64,6 +64,8 @@ async fn narinfo(
|
|||
State(state): State<AppState>,
|
||||
Path(hash): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
use std::fmt::Write;
|
||||
|
||||
if !state.config.cache.enabled {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
}
|
||||
|
|
@ -97,9 +99,8 @@ async fn narinfo(
|
|||
Err(_) => return Ok(StatusCode::NOT_FOUND.into_response()),
|
||||
};
|
||||
|
||||
let (entry, path_from_info) = match first_path_info_entry(&parsed) {
|
||||
Some(e) => e,
|
||||
None => return Ok(StatusCode::NOT_FOUND.into_response()),
|
||||
let Some((entry, path_from_info)) = first_path_info_entry(&parsed) else {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
};
|
||||
|
||||
let nar_hash = entry.get("narHash").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
|
@ -132,8 +133,6 @@ async fn narinfo(
|
|||
|
||||
let file_hash = nar_hash;
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
let refs_joined = refs.join(" ");
|
||||
let mut narinfo_text = format!(
|
||||
"StorePath: {store_path}\nURL: nar/{hash}.nar.zst\nCompression: \
|
||||
|
|
@ -142,10 +141,10 @@ async fn narinfo(
|
|||
);
|
||||
|
||||
if let Some(deriver) = deriver {
|
||||
let _ = write!(narinfo_text, "Deriver: {deriver}\n");
|
||||
let _ = writeln!(narinfo_text, "Deriver: {deriver}");
|
||||
}
|
||||
if let Some(ca) = ca {
|
||||
let _ = write!(narinfo_text, "CA: {ca}\n");
|
||||
let _ = writeln!(narinfo_text, "CA: {ca}");
|
||||
}
|
||||
|
||||
// Optionally sign if secret key is configured
|
||||
|
|
@ -177,9 +176,8 @@ async fn sign_narinfo(narinfo: &str, key_file: &std::path::Path) -> String {
|
|||
.find(|l| l.starts_with("StorePath: "))
|
||||
.and_then(|l| l.strip_prefix("StorePath: "));
|
||||
|
||||
let store_path = match store_path {
|
||||
Some(p) => p,
|
||||
None => return narinfo.to_string(),
|
||||
let Some(store_path) = store_path else {
|
||||
return narinfo.to_string();
|
||||
};
|
||||
|
||||
let output = Command::new("nix")
|
||||
|
|
@ -260,9 +258,8 @@ async fn serve_nar_zst(
|
|||
))
|
||||
})?;
|
||||
|
||||
let nix_stdout = match nix_child.stdout.take() {
|
||||
Some(s) => s,
|
||||
None => return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()),
|
||||
let Some(nix_stdout) = nix_child.stdout.take() else {
|
||||
return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response());
|
||||
};
|
||||
|
||||
let mut zstd_child = Command::new("zstd")
|
||||
|
|
@ -278,9 +275,8 @@ async fn serve_nar_zst(
|
|||
))
|
||||
})?;
|
||||
|
||||
let zstd_stdout = match zstd_child.stdout.take() {
|
||||
Some(s) => s,
|
||||
None => return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()),
|
||||
let Some(zstd_stdout) = zstd_child.stdout.take() else {
|
||||
return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response());
|
||||
};
|
||||
|
||||
let stream = tokio_util::io::ReaderStream::new(zstd_stdout);
|
||||
|
|
@ -320,14 +316,12 @@ async fn serve_nar(
|
|||
.kill_on_drop(true)
|
||||
.spawn();
|
||||
|
||||
let mut child = match child {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()),
|
||||
let Ok(mut child) = child else {
|
||||
return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response());
|
||||
};
|
||||
|
||||
let stdout = match child.stdout.take() {
|
||||
Some(s) => s,
|
||||
None => return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()),
|
||||
let Some(stdout) = child.stdout.take() else {
|
||||
return Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response());
|
||||
};
|
||||
|
||||
let stream = tokio_util::io::ReaderStream::new(stdout);
|
||||
|
|
@ -343,7 +337,7 @@ async fn serve_nar(
|
|||
)
|
||||
}
|
||||
|
||||
/// Combined NAR handler — dispatches to zstd or plain based on suffix.
|
||||
/// Dispatches to zstd or plain based on suffix.
|
||||
/// GET /nix-cache/nar/{hash} where hash includes .nar.zst or .nar suffix
|
||||
async fn serve_nar_combined(
|
||||
state: State<AppState>,
|
||||
|
|
|
|||
|
|
@ -63,18 +63,15 @@ async fn create_channel(
|
|||
// Catch-up: if the jobset already has a completed evaluation, promote now
|
||||
if let Ok(Some(eval)) =
|
||||
fc_common::repo::evaluations::get_latest(&state.pool, jobset_id).await
|
||||
&& eval.status == fc_common::models::EvaluationStatus::Completed
|
||||
&& let Err(e) = fc_common::repo::channels::auto_promote_if_complete(
|
||||
&state.pool,
|
||||
jobset_id,
|
||||
eval.id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if eval.status == fc_common::models::EvaluationStatus::Completed {
|
||||
if let Err(e) = fc_common::repo::channels::auto_promote_if_complete(
|
||||
&state.pool,
|
||||
jobset_id,
|
||||
eval.id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(jobset_id = %jobset_id, "Failed to auto-promote channel: {e}");
|
||||
}
|
||||
}
|
||||
tracing::warn!(jobset_id = %jobset_id, "Failed to auto-promote channel: {e}");
|
||||
}
|
||||
|
||||
// Re-fetch to include any promotion
|
||||
|
|
@ -159,13 +156,12 @@ async fn nixexprs_tarball(
|
|||
let _ = writeln!(nix_src, "in {{");
|
||||
|
||||
for build in &succeeded {
|
||||
let output_path = match &build.build_output_path {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
let Some(output_path) = &build.build_output_path else {
|
||||
continue;
|
||||
};
|
||||
let system = build.system.as_deref().unwrap_or("x86_64-linux");
|
||||
// Sanitize job_name for use as a Nix attribute (replace dots/slashes)
|
||||
let attr_name = build.job_name.replace('.', "-").replace('/', "-");
|
||||
let attr_name = build.job_name.replace(['.', '/'], "-");
|
||||
let _ = writeln!(
|
||||
nix_src,
|
||||
" \"{attr_name}\" = mkFakeDerivation {{ name = \"{}\"; system = \
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ struct BuildView {
|
|||
log_url: String,
|
||||
}
|
||||
|
||||
/// Enhanced build view for queue page with elapsed time and builder info
|
||||
/// Queue page build info with elapsed time and builder details
|
||||
struct QueueBuildView {
|
||||
id: Uuid,
|
||||
job_name: String,
|
||||
|
|
@ -379,7 +379,7 @@ struct ChannelsTemplate {
|
|||
channels: Vec<Channel>,
|
||||
}
|
||||
|
||||
/// Enhanced builder view with load and activity info
|
||||
/// Builder info with load and activity metrics
|
||||
struct BuilderView {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
|
|
@ -455,7 +455,7 @@ async fn home(
|
|||
State(state): State<AppState>,
|
||||
extensions: Extensions,
|
||||
) -> Html<String> {
|
||||
let stats = fc_common::repo::builds::get_stats(&state.pool)
|
||||
let build_stats = fc_common::repo::builds::get_stats(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let builds = fc_common::repo::builds::list_recent(&state.pool, 10)
|
||||
|
|
@ -499,13 +499,13 @@ async fn home(
|
|||
last_eval = Some(e);
|
||||
}
|
||||
}
|
||||
let (status, class, time) = match &last_eval {
|
||||
Some(e) => {
|
||||
let (status, class, time) = last_eval.as_ref().map_or_else(
|
||||
|| ("-".into(), "pending".into(), "-".into()),
|
||||
|e| {
|
||||
let (t, c) = eval_badge(&e.status);
|
||||
(t, c, e.evaluation_time.format("%Y-%m-%d %H:%M").to_string())
|
||||
},
|
||||
None => ("-".into(), "pending".into(), "-".into()),
|
||||
};
|
||||
);
|
||||
project_summaries.push(ProjectSummaryView {
|
||||
id: p.id,
|
||||
name: p.name.clone(),
|
||||
|
|
@ -517,11 +517,11 @@ async fn home(
|
|||
}
|
||||
|
||||
let tmpl = HomeTemplate {
|
||||
total_builds: stats.total_builds.unwrap_or(0),
|
||||
completed_builds: stats.completed_builds.unwrap_or(0),
|
||||
failed_builds: stats.failed_builds.unwrap_or(0),
|
||||
running_builds: stats.running_builds.unwrap_or(0),
|
||||
pending_builds: stats.pending_builds.unwrap_or(0),
|
||||
total_builds: build_stats.total_builds.unwrap_or(0),
|
||||
completed_builds: build_stats.completed_builds.unwrap_or(0),
|
||||
failed_builds: build_stats.failed_builds.unwrap_or(0),
|
||||
running_builds: build_stats.running_builds.unwrap_or(0),
|
||||
pending_builds: build_stats.pending_builds.unwrap_or(0),
|
||||
recent_builds: builds.iter().map(build_view).collect(),
|
||||
recent_evals: evals.iter().map(eval_view).collect(),
|
||||
projects: project_summaries,
|
||||
|
|
@ -581,9 +581,9 @@ async fn project_page(
|
|||
Path(id): Path<Uuid>,
|
||||
extensions: Extensions,
|
||||
) -> Html<String> {
|
||||
let project = match fc_common::repo::projects::get(&state.pool, id).await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Html("Project not found".to_string()),
|
||||
let Ok(project) = fc_common::repo::projects::get(&state.pool, id).await
|
||||
else {
|
||||
return Html("Project not found".to_string());
|
||||
};
|
||||
let jobsets =
|
||||
fc_common::repo::jobsets::list_for_project(&state.pool, id, 100, 0)
|
||||
|
|
@ -604,7 +604,7 @@ async fn project_page(
|
|||
.unwrap_or_default();
|
||||
evals.append(&mut js_evals);
|
||||
}
|
||||
evals.sort_by(|a, b| b.evaluation_time.cmp(&a.evaluation_time));
|
||||
evals.sort_by_key(|e| std::cmp::Reverse(e.evaluation_time));
|
||||
evals.truncate(10);
|
||||
|
||||
let tmpl = ProjectTemplate {
|
||||
|
|
@ -625,18 +625,13 @@ async fn jobset_page(
|
|||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Html<String> {
|
||||
let jobset = match fc_common::repo::jobsets::get(&state.pool, id).await {
|
||||
Ok(j) => j,
|
||||
Err(_) => return Html("Jobset not found".to_string()),
|
||||
let Ok(jobset) = fc_common::repo::jobsets::get(&state.pool, id).await else {
|
||||
return Html("Jobset not found".to_string());
|
||||
};
|
||||
let project = match fc_common::repo::projects::get(
|
||||
&state.pool,
|
||||
jobset.project_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(_) => return Html("Project not found".to_string()),
|
||||
let Ok(project) =
|
||||
fc_common::repo::projects::get(&state.pool, jobset.project_id).await
|
||||
else {
|
||||
return Html("Project not found".to_string());
|
||||
};
|
||||
|
||||
let evals = fc_common::repo::evaluations::list_filtered(
|
||||
|
|
@ -769,24 +764,20 @@ async fn evaluation_page(
|
|||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Html<String> {
|
||||
let eval = match fc_common::repo::evaluations::get(&state.pool, id).await {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Html("Evaluation not found".to_string()),
|
||||
let Ok(eval) = fc_common::repo::evaluations::get(&state.pool, id).await
|
||||
else {
|
||||
return Html("Evaluation not found".to_string());
|
||||
};
|
||||
|
||||
let jobset =
|
||||
match fc_common::repo::jobsets::get(&state.pool, eval.jobset_id).await {
|
||||
Ok(j) => j,
|
||||
Err(_) => return Html("Jobset not found".to_string()),
|
||||
};
|
||||
let project = match fc_common::repo::projects::get(
|
||||
&state.pool,
|
||||
jobset.project_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(_) => return Html("Project not found".to_string()),
|
||||
let Ok(jobset) =
|
||||
fc_common::repo::jobsets::get(&state.pool, eval.jobset_id).await
|
||||
else {
|
||||
return Html("Jobset not found".to_string());
|
||||
};
|
||||
let Ok(project) =
|
||||
fc_common::repo::projects::get(&state.pool, jobset.project_id).await
|
||||
else {
|
||||
return Html("Project not found".to_string());
|
||||
};
|
||||
|
||||
let builds = fc_common::repo::builds::list_filtered(
|
||||
|
|
@ -919,31 +910,24 @@ async fn build_page(
|
|||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Html<String> {
|
||||
let build = match fc_common::repo::builds::get(&state.pool, id).await {
|
||||
Ok(b) => b,
|
||||
Err(_) => return Html("Build not found".to_string()),
|
||||
let Ok(build) = fc_common::repo::builds::get(&state.pool, id).await else {
|
||||
return Html("Build not found".to_string());
|
||||
};
|
||||
|
||||
let eval =
|
||||
match fc_common::repo::evaluations::get(&state.pool, build.evaluation_id)
|
||||
.await
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(_) => return Html("Evaluation not found".to_string()),
|
||||
};
|
||||
let jobset =
|
||||
match fc_common::repo::jobsets::get(&state.pool, eval.jobset_id).await {
|
||||
Ok(j) => j,
|
||||
Err(_) => return Html("Jobset not found".to_string()),
|
||||
};
|
||||
let project = match fc_common::repo::projects::get(
|
||||
&state.pool,
|
||||
jobset.project_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(_) => return Html("Project not found".to_string()),
|
||||
let Ok(eval) =
|
||||
fc_common::repo::evaluations::get(&state.pool, build.evaluation_id).await
|
||||
else {
|
||||
return Html("Evaluation not found".to_string());
|
||||
};
|
||||
let Ok(jobset) =
|
||||
fc_common::repo::jobsets::get(&state.pool, eval.jobset_id).await
|
||||
else {
|
||||
return Html("Jobset not found".to_string());
|
||||
};
|
||||
let Ok(project) =
|
||||
fc_common::repo::projects::get(&state.pool, jobset.project_id).await
|
||||
else {
|
||||
return Html("Project not found".to_string());
|
||||
};
|
||||
|
||||
let eval_commit_short = if eval.commit_hash.len() > 12 {
|
||||
|
|
@ -1016,12 +1000,10 @@ async fn queue_page(State(state): State<AppState>) -> Html<String> {
|
|||
let running_builds: Vec<QueueBuildView> = running
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let elapsed = if let Some(started) = b.started_at {
|
||||
let elapsed = b.started_at.map_or_else(String::new, |started| {
|
||||
let dur = chrono::Utc::now() - started;
|
||||
format_elapsed(dur.num_seconds())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
});
|
||||
let builder_name =
|
||||
b.builder_id.and_then(|id| builder_map.get(&id).cloned());
|
||||
QueueBuildView {
|
||||
|
|
@ -1114,7 +1096,7 @@ async fn admin_page(
|
|||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or((0,));
|
||||
let stats = fc_common::repo::builds::get_stats(pool)
|
||||
let build_stats = fc_common::repo::builds::get_stats(pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let builders_count = fc_common::repo::remote_builders::count(pool)
|
||||
|
|
@ -1129,10 +1111,10 @@ async fn admin_page(
|
|||
projects_count: projects.0,
|
||||
jobsets_count: jobsets.0,
|
||||
evaluations_count: evaluations.0,
|
||||
builds_pending: stats.pending_builds.unwrap_or(0),
|
||||
builds_running: stats.running_builds.unwrap_or(0),
|
||||
builds_completed: stats.completed_builds.unwrap_or(0),
|
||||
builds_failed: stats.failed_builds.unwrap_or(0),
|
||||
builds_pending: build_stats.pending_builds.unwrap_or(0),
|
||||
builds_running: build_stats.running_builds.unwrap_or(0),
|
||||
builds_completed: build_stats.completed_builds.unwrap_or(0),
|
||||
builds_failed: build_stats.failed_builds.unwrap_or(0),
|
||||
remote_builders: builders_count,
|
||||
channels_count: channels.0,
|
||||
};
|
||||
|
|
@ -1381,36 +1363,28 @@ async fn logout_action(
|
|||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
// Check for user session
|
||||
if let Some(session_id) = cookie_header
|
||||
.split(';')
|
||||
.filter_map(|pair| {
|
||||
let pair = pair.trim();
|
||||
let (k, v) = pair.split_once('=')?;
|
||||
if k.trim() == "fc_user_session" {
|
||||
Some(v.trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.next()
|
||||
{
|
||||
if let Some(session_id) = cookie_header.split(';').find_map(|pair| {
|
||||
let pair = pair.trim();
|
||||
let (k, v) = pair.split_once('=')?;
|
||||
if k.trim() == "fc_user_session" {
|
||||
Some(v.trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
state.sessions.remove(&session_id);
|
||||
}
|
||||
|
||||
// Check for legacy API key session
|
||||
if let Some(session_id) = cookie_header
|
||||
.split(';')
|
||||
.filter_map(|pair| {
|
||||
let pair = pair.trim();
|
||||
let (k, v) = pair.split_once('=')?;
|
||||
if k.trim() == "fc_session" {
|
||||
Some(v.trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.next()
|
||||
{
|
||||
if let Some(session_id) = cookie_header.split(';').find_map(|pair| {
|
||||
let pair = pair.trim();
|
||||
let (k, v) = pair.split_once('=')?;
|
||||
if k.trim() == "fc_session" {
|
||||
Some(v.trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
state.sessions.remove(&session_id);
|
||||
}
|
||||
}
|
||||
|
|
@ -1556,12 +1530,13 @@ async fn starred_page(
|
|||
Vec::new()
|
||||
};
|
||||
|
||||
if let Some(build) = builds.first() {
|
||||
let (text, class) = status_badge(&build.status);
|
||||
(text, class, Some(build.id))
|
||||
} else {
|
||||
("No builds".to_string(), "pending".to_string(), None)
|
||||
}
|
||||
builds.first().map_or_else(
|
||||
|| ("No builds".to_string(), "pending".to_string(), None),
|
||||
|build| {
|
||||
let (text, class) = status_badge(&build.status);
|
||||
(text, class, Some(build.id))
|
||||
},
|
||||
)
|
||||
} else {
|
||||
("No builds".to_string(), "pending".to_string(), None)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -93,9 +93,9 @@ async fn stream_build_log(
|
|||
if active_path.exists() { active_path.clone() } else { final_path.clone() }
|
||||
};
|
||||
|
||||
let file = if let Ok(f) = tokio::fs::File::open(&path).await { f } else {
|
||||
yield Ok(Event::default().data("Failed to open log file"));
|
||||
return;
|
||||
let Ok(file) = tokio::fs::File::open(&path).await else {
|
||||
yield Ok(Event::default().data("Failed to open log file"));
|
||||
return;
|
||||
};
|
||||
|
||||
let mut reader = BufReader::new(file);
|
||||
|
|
@ -106,7 +106,7 @@ async fn stream_build_log(
|
|||
line.clear();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) => {
|
||||
// EOF — check if build is still running
|
||||
// EOF - check if build is still running
|
||||
consecutive_empty += 1;
|
||||
if consecutive_empty > 5 {
|
||||
// Check build status
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ struct TimeseriesQuery {
|
|||
bucket: i32,
|
||||
}
|
||||
|
||||
fn default_hours() -> i32 {
|
||||
const fn default_hours() -> i32 {
|
||||
24
|
||||
}
|
||||
|
||||
fn default_bucket() -> i32 {
|
||||
const fn default_bucket() -> i32 {
|
||||
60
|
||||
}
|
||||
|
||||
|
|
@ -64,21 +64,19 @@ fn escape_prometheus_label(s: &str) -> String {
|
|||
}
|
||||
|
||||
async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
||||
let stats = match fc_common::repo::builds::get_stats(&state.pool).await {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
},
|
||||
use std::fmt::Write;
|
||||
|
||||
let Ok(build_stats) = fc_common::repo::builds::get_stats(&state.pool).await
|
||||
else {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
};
|
||||
|
||||
let eval_count: i64 =
|
||||
match sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM evaluations")
|
||||
sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM evaluations")
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
{
|
||||
Ok(row) => row.0,
|
||||
Err(_) => 0,
|
||||
};
|
||||
.ok()
|
||||
.map_or(0, |row| row.0);
|
||||
|
||||
let eval_by_status: Vec<(String, i64)> = sqlx::query_as(
|
||||
"SELECT status::text, COUNT(*) FROM evaluations GROUP BY status",
|
||||
|
|
@ -124,8 +122,6 @@ async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
|||
.await
|
||||
.unwrap_or((None, None, None));
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
let mut output = String::with_capacity(2048);
|
||||
|
||||
// Build counts by status
|
||||
|
|
@ -134,27 +130,27 @@ async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
|||
let _ = writeln!(
|
||||
output,
|
||||
"fc_builds_total{{status=\"succeeded\"}} {}",
|
||||
stats.completed_builds.unwrap_or(0)
|
||||
build_stats.completed_builds.unwrap_or(0)
|
||||
);
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_builds_total{{status=\"failed\"}} {}",
|
||||
stats.failed_builds.unwrap_or(0)
|
||||
build_stats.failed_builds.unwrap_or(0)
|
||||
);
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_builds_total{{status=\"running\"}} {}",
|
||||
stats.running_builds.unwrap_or(0)
|
||||
build_stats.running_builds.unwrap_or(0)
|
||||
);
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_builds_total{{status=\"pending\"}} {}",
|
||||
stats.pending_builds.unwrap_or(0)
|
||||
build_stats.pending_builds.unwrap_or(0)
|
||||
);
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"fc_builds_total{{status=\"all\"}} {}",
|
||||
stats.total_builds.unwrap_or(0)
|
||||
build_stats.total_builds.unwrap_or(0)
|
||||
);
|
||||
|
||||
// Build duration stats
|
||||
|
|
@ -166,7 +162,7 @@ async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
|||
let _ = writeln!(
|
||||
output,
|
||||
"fc_builds_avg_duration_seconds {:.2}",
|
||||
stats.avg_duration_seconds.unwrap_or(0.0)
|
||||
build_stats.avg_duration_seconds.unwrap_or(0.0)
|
||||
);
|
||||
|
||||
output.push_str(
|
||||
|
|
@ -214,7 +210,7 @@ async fn prometheus_metrics(State(state): State<AppState>) -> Response {
|
|||
let _ = writeln!(
|
||||
output,
|
||||
"fc_queue_depth {}",
|
||||
stats.pending_builds.unwrap_or(0)
|
||||
build_stats.pending_builds.unwrap_or(0)
|
||||
);
|
||||
|
||||
// Infrastructure
|
||||
|
|
|
|||
|
|
@ -44,13 +44,15 @@ use crate::{
|
|||
static STYLE_CSS: &str = include_str!("../../static/style.css");
|
||||
|
||||
/// Helper to generate secure cookie flags based on server configuration.
|
||||
/// Returns a string containing cookie security attributes: HttpOnly, SameSite,
|
||||
/// and optionally Secure.
|
||||
/// Returns a string containing cookie security attributes: `HttpOnly`,
|
||||
/// `SameSite`, and optionally Secure.
|
||||
///
|
||||
/// The Secure flag is set when:
|
||||
///
|
||||
/// 1. `force_secure_cookies` is enabled in config (for HTTPS reverse proxies),
|
||||
/// OR 2. The server is not bound to localhost/127.0.0.1 AND not in permissive
|
||||
/// mode
|
||||
/// 2. OR the server is not bound to localhost/127.0.0.1 AND not in permissive
|
||||
/// mode
|
||||
#[must_use]
|
||||
pub fn cookie_security_flags(
|
||||
config: &fc_common::config::ServerConfig,
|
||||
) -> String {
|
||||
|
|
|
|||
|
|
@ -89,12 +89,9 @@ fn build_github_client(config: &GitHubOAuthConfig) -> GitHubOAuthClient {
|
|||
}
|
||||
|
||||
async fn github_login(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let config = match &state.config.oauth.github {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return (StatusCode::NOT_FOUND, "GitHub OAuth not configured")
|
||||
.into_response();
|
||||
},
|
||||
let Some(config) = &state.config.oauth.github else {
|
||||
return (StatusCode::NOT_FOUND, "GitHub OAuth not configured")
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let client = build_github_client(config);
|
||||
|
|
@ -141,13 +138,10 @@ async fn github_callback(
|
|||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<OAuthCallbackParams>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let config = match &state.config.oauth.github {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return Err(ApiError(fc_common::CiError::NotFound(
|
||||
"GitHub OAuth not configured".to_string(),
|
||||
)));
|
||||
},
|
||||
let Some(config) = &state.config.oauth.github else {
|
||||
return Err(ApiError(fc_common::CiError::NotFound(
|
||||
"GitHub OAuth not configured".to_string(),
|
||||
)));
|
||||
};
|
||||
|
||||
// Verify CSRF token from cookie
|
||||
|
|
@ -290,7 +284,7 @@ async fn github_callback(
|
|||
};
|
||||
|
||||
let clear_state =
|
||||
format!("fc_oauth_state=; {}; Path=/; Max-Age=0", security_flags);
|
||||
format!("fc_oauth_state=; {security_flags}; Path=/; Max-Age=0");
|
||||
let session_cookie = format!(
|
||||
"fc_user_session={}; {}; Path=/; Max-Age={}",
|
||||
session.0,
|
||||
|
|
@ -371,21 +365,21 @@ mod tests {
|
|||
fn test_secure_flag_detection() {
|
||||
// HTTP should not have Secure flag
|
||||
let http_uri = "http://localhost:3000/callback";
|
||||
let secure_flag = if http_uri.starts_with("https://") {
|
||||
let http_secure_flag = if http_uri.starts_with("https://") {
|
||||
"; Secure"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
assert_eq!(secure_flag, "");
|
||||
assert_eq!(http_secure_flag, "");
|
||||
|
||||
// HTTPS should have Secure flag
|
||||
let https_uri = "https://example.com/callback";
|
||||
let secure_flag = if https_uri.starts_with("https://") {
|
||||
let https_secure_flag = if https_uri.starts_with("https://") {
|
||||
"; Secure"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
assert_eq!(secure_flag, "; Secure");
|
||||
assert_eq!(https_secure_flag, "; Secure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -437,7 +431,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_github_emails_find_primary_verified() {
|
||||
let emails = vec![
|
||||
let emails = [
|
||||
GitHubEmailResponse {
|
||||
email: "secondary@example.com".to_string(),
|
||||
primary: false,
|
||||
|
|
@ -467,7 +461,7 @@ mod tests {
|
|||
#[test]
|
||||
fn test_github_emails_fallback_to_verified() {
|
||||
// No primary email, should fall back to first verified
|
||||
let emails = vec![
|
||||
let emails = [
|
||||
GitHubEmailResponse {
|
||||
email: "unverified@example.com".to_string(),
|
||||
primary: false,
|
||||
|
|
@ -492,7 +486,7 @@ mod tests {
|
|||
#[test]
|
||||
fn test_github_emails_no_verified() {
|
||||
// No verified emails
|
||||
let emails = vec![GitHubEmailResponse {
|
||||
let emails = [GitHubEmailResponse {
|
||||
email: "unverified@example.com".to_string(),
|
||||
primary: true,
|
||||
verified: false,
|
||||
|
|
@ -540,8 +534,8 @@ mod tests {
|
|||
let max_age = 7 * 24 * 60 * 60;
|
||||
|
||||
let cookie = format!(
|
||||
"fc_user_session={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}",
|
||||
session_token, max_age, secure_flag
|
||||
"fc_user_session={session_token}; HttpOnly; SameSite=Lax; Path=/; \
|
||||
Max-Age={max_age}{secure_flag}"
|
||||
);
|
||||
|
||||
assert!(cookie.contains("fc_user_session=test-session-token"));
|
||||
|
|
|
|||
|
|
@ -159,17 +159,14 @@ async fn handle_github_webhook(
|
|||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let webhook_config = match webhook_config {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(WebhookResponse {
|
||||
accepted: false,
|
||||
message: "No GitHub webhook configured for this project".to_string(),
|
||||
}),
|
||||
));
|
||||
},
|
||||
let Some(webhook_config) = webhook_config else {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(WebhookResponse {
|
||||
accepted: false,
|
||||
message: "No GitHub webhook configured for this project".to_string(),
|
||||
}),
|
||||
));
|
||||
};
|
||||
|
||||
// Verify signature if secret is configured
|
||||
|
|
@ -299,17 +296,14 @@ async fn handle_github_pull_request(
|
|||
));
|
||||
}
|
||||
|
||||
let pr = match payload.pull_request {
|
||||
Some(pr) => pr,
|
||||
None => {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(WebhookResponse {
|
||||
accepted: true,
|
||||
message: "No pull request data, skipping".to_string(),
|
||||
}),
|
||||
));
|
||||
},
|
||||
let Some(pr) = payload.pull_request else {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(WebhookResponse {
|
||||
accepted: true,
|
||||
message: "No pull request data, skipping".to_string(),
|
||||
}),
|
||||
));
|
||||
};
|
||||
|
||||
// Skip draft PRs
|
||||
|
|
@ -513,6 +507,8 @@ async fn handle_gitlab_webhook(
|
|||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<(StatusCode, Json<WebhookResponse>), ApiError> {
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
// Check webhook config exists
|
||||
let webhook_config = repo::webhook_configs::get_by_project_and_forge(
|
||||
&state.pool,
|
||||
|
|
@ -522,17 +518,14 @@ async fn handle_gitlab_webhook(
|
|||
.await
|
||||
.map_err(ApiError)?;
|
||||
|
||||
let webhook_config = match webhook_config {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(WebhookResponse {
|
||||
accepted: false,
|
||||
message: "No GitLab webhook configured for this project".to_string(),
|
||||
}),
|
||||
));
|
||||
},
|
||||
let Some(webhook_config) = webhook_config else {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(WebhookResponse {
|
||||
accepted: false,
|
||||
message: "No GitLab webhook configured for this project".to_string(),
|
||||
}),
|
||||
));
|
||||
};
|
||||
|
||||
// Verify token if secret is configured
|
||||
|
|
@ -544,7 +537,6 @@ async fn handle_gitlab_webhook(
|
|||
.unwrap_or("");
|
||||
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
use subtle::ConstantTimeEq;
|
||||
let token_matches = token.len() == secret.len()
|
||||
&& token.as_bytes().ct_eq(secret.as_bytes()).into();
|
||||
|
||||
|
|
@ -656,17 +648,14 @@ async fn handle_gitlab_merge_request(
|
|||
)))
|
||||
})?;
|
||||
|
||||
let attrs = match payload.object_attributes {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(WebhookResponse {
|
||||
accepted: true,
|
||||
message: "No merge request attributes, skipping".to_string(),
|
||||
}),
|
||||
));
|
||||
},
|
||||
let Some(attrs) = payload.object_attributes else {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(WebhookResponse {
|
||||
accepted: true,
|
||||
message: "No merge request attributes, skipping".to_string(),
|
||||
}),
|
||||
));
|
||||
};
|
||||
|
||||
// Skip draft/WIP merge requests
|
||||
|
|
@ -774,12 +763,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_verify_signature_valid() {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
let secret = "test-secret";
|
||||
let body = b"test-body";
|
||||
|
||||
// Compute expected signature
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let expected = hex::encode(mac.finalize().into_bytes());
|
||||
|
|
@ -787,7 +777,7 @@ mod tests {
|
|||
assert!(verify_signature(
|
||||
secret,
|
||||
body,
|
||||
&format!("sha256={}", expected)
|
||||
&format!("sha256={expected}")
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -800,20 +790,16 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_verify_signature_wrong_secret() {
|
||||
let body = b"test-body";
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
let body = b"test-body";
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(b"secret1").unwrap();
|
||||
mac.update(body);
|
||||
let sig = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
// Verify with different secret should fail
|
||||
assert!(!verify_signature(
|
||||
"secret2",
|
||||
body,
|
||||
&format!("sha256={}", sig)
|
||||
));
|
||||
assert!(!verify_signature("secret2", body, &format!("sha256={sig}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ use sqlx::PgPool;
|
|||
|
||||
/// Maximum session lifetime before automatic eviction (24 hours).
|
||||
const SESSION_MAX_AGE: std::time::Duration =
|
||||
std::time::Duration::from_secs(24 * 60 * 60);
|
||||
std::time::Duration::from_hours(24);
|
||||
|
||||
/// How often the background cleanup task runs (every 5 minutes).
|
||||
const SESSION_CLEANUP_INTERVAL: std::time::Duration =
|
||||
std::time::Duration::from_secs(5 * 60);
|
||||
std::time::Duration::from_mins(5);
|
||||
|
||||
/// Session data supporting both API key and user authentication
|
||||
#[derive(Clone)]
|
||||
|
|
@ -27,13 +27,10 @@ impl SessionData {
|
|||
/// Check if the session has admin role
|
||||
#[must_use]
|
||||
pub fn is_admin(&self) -> bool {
|
||||
if let Some(ref user) = self.user {
|
||||
user.role == "admin"
|
||||
} else if let Some(ref key) = self.api_key {
|
||||
key.role == "admin"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
self.user.as_ref().map_or_else(
|
||||
|| self.api_key.as_ref().is_some_and(|key| key.role == "admin"),
|
||||
|user| user.role == "admin",
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if the session has a specific role
|
||||
|
|
@ -42,25 +39,24 @@ impl SessionData {
|
|||
if self.is_admin() {
|
||||
return true;
|
||||
}
|
||||
if let Some(ref user) = self.user {
|
||||
user.role == role
|
||||
} else if let Some(ref key) = self.api_key {
|
||||
key.role == role
|
||||
} else {
|
||||
false
|
||||
}
|
||||
self.user.as_ref().map_or_else(
|
||||
|| self.api_key.as_ref().is_some_and(|key| key.role == role),
|
||||
|user| user.role == role,
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the display name for the session (username or api key name)
|
||||
#[must_use]
|
||||
pub fn display_name(&self) -> String {
|
||||
if let Some(ref user) = self.user {
|
||||
user.username.clone()
|
||||
} else if let Some(ref key) = self.api_key {
|
||||
key.name.clone()
|
||||
} else {
|
||||
"Anonymous".to_string()
|
||||
}
|
||||
self.user.as_ref().map_or_else(
|
||||
|| {
|
||||
self
|
||||
.api_key
|
||||
.as_ref()
|
||||
.map_or_else(|| "Anonymous".to_string(), |key| key.name.clone())
|
||||
},
|
||||
|user| user.username.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if this is a user session (not just API key)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Integration tests for API endpoints.
|
||||
//! Requires TEST_DATABASE_URL to be set.
|
||||
//! Requires `TEST_DATABASE_URL` to be set.
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
|
|
@ -8,12 +8,9 @@ use axum::{
|
|||
use tower::ServiceExt;
|
||||
|
||||
async fn get_pool() -> Option<sqlx::PgPool> {
|
||||
let url = match std::env::var("TEST_DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
println!("Skipping API test: TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
},
|
||||
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
|
||||
println!("Skipping API test: TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -44,9 +41,8 @@ fn build_app(pool: sqlx::PgPool) -> axum::Router {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_router_no_duplicate_routes() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let config = fc_common::config::Config::default();
|
||||
|
|
@ -79,9 +75,8 @@ fn build_app_with_config(
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_health_endpoint() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -108,9 +103,8 @@ async fn test_health_endpoint() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_project_endpoints() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -204,9 +198,8 @@ async fn test_project_endpoints() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_builds_endpoints() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -244,9 +237,8 @@ async fn test_builds_endpoints() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_error_response_includes_error_code() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -275,9 +267,8 @@ async fn test_error_response_includes_error_code() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_cache_invalid_hash_returns_404() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut config = fc_common::config::Config::default();
|
||||
|
|
@ -352,9 +343,8 @@ async fn test_cache_invalid_hash_returns_404() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_cache_nar_invalid_hash_returns_404() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut config = fc_common::config::Config::default();
|
||||
|
|
@ -390,9 +380,8 @@ async fn test_cache_nar_invalid_hash_returns_404() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_cache_disabled_returns_404() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut config = fc_common::config::Config::default();
|
||||
|
|
@ -426,9 +415,8 @@ async fn test_cache_disabled_returns_404() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_search_rejects_long_query() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -457,9 +445,8 @@ async fn test_search_rejects_long_query() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_search_rejects_empty_query() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -486,9 +473,8 @@ async fn test_search_rejects_empty_query() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_search_whitespace_only_query() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -514,9 +500,8 @@ async fn test_search_whitespace_only_query() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_builds_list_with_system_filter() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -544,9 +529,8 @@ async fn test_builds_list_with_system_filter() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_builds_list_with_job_name_filter() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -572,9 +556,8 @@ async fn test_builds_list_with_job_name_filter() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_builds_list_combined_filters() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -595,9 +578,8 @@ async fn test_builds_list_combined_filters() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_cache_info_returns_correct_headers() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut config = fc_common::config::Config::default();
|
||||
|
|
@ -631,9 +613,8 @@ async fn test_cache_info_returns_correct_headers() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_metrics_endpoint() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -718,9 +699,8 @@ async fn test_metrics_endpoint() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_get_nonexistent_build_returns_error_code() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -750,9 +730,8 @@ async fn test_get_nonexistent_build_returns_error_code() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_create_project_validation_rejects_invalid_name() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -786,9 +765,8 @@ async fn test_create_project_validation_rejects_invalid_name() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_create_project_validation_rejects_bad_url() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -821,9 +799,8 @@ async fn test_create_project_validation_rejects_bad_url() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_create_project_validation_accepts_valid() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -854,14 +831,14 @@ async fn test_create_project_validation_accepts_valid() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_project_create_with_auth() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
use sha2::Digest;
|
||||
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Create an admin API key
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
use sha2::Digest;
|
||||
hasher.update(b"fc_test_project_auth");
|
||||
let key_hash = hex::encode(hasher.finalize());
|
||||
let _ =
|
||||
|
|
@ -900,9 +877,8 @@ async fn test_project_create_with_auth() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_project_create_without_auth_rejected() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -929,14 +905,14 @@ async fn test_project_create_without_auth_rejected() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_setup_endpoint_creates_project_and_jobsets() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
use sha2::Digest;
|
||||
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Create an admin API key
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
use sha2::Digest;
|
||||
hasher.update(b"fc_test_setup_key");
|
||||
let key_hash = hex::encode(hasher.finalize());
|
||||
let _ =
|
||||
|
|
@ -991,9 +967,8 @@ async fn test_setup_endpoint_creates_project_and_jobsets() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_security_headers_present() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
@ -1033,9 +1008,8 @@ async fn test_security_headers_present() {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_static_css_served() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let app = build_app(pool);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! End-to-end integration test.
|
||||
//! Requires TEST_DATABASE_URL to be set.
|
||||
//! Requires `TEST_DATABASE_URL` to be set.
|
||||
//! Tests the full flow: create project -> jobset -> evaluation -> builds.
|
||||
//!
|
||||
//! Nix-dependent steps are skipped if nix is not available.
|
||||
|
|
@ -12,12 +12,9 @@ use fc_common::models::*;
|
|||
use tower::ServiceExt;
|
||||
|
||||
async fn get_pool() -> Option<sqlx::PgPool> {
|
||||
let url = match std::env::var("TEST_DATABASE_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
println!("Skipping E2E test: TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
},
|
||||
let Ok(url) = std::env::var("TEST_DATABASE_URL") else {
|
||||
println!("Skipping E2E test: TEST_DATABASE_URL not set");
|
||||
return None;
|
||||
};
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
|
|
@ -36,9 +33,8 @@ async fn get_pool() -> Option<sqlx::PgPool> {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_e2e_project_eval_build_flow() {
|
||||
let pool = match get_pool().await {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
let Some(pool) = get_pool().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 1. Create a project
|
||||
|
|
@ -254,10 +250,10 @@ async fn test_e2e_project_eval_build_flow() {
|
|||
assert_eq!(steps[0].exit_code, Some(0));
|
||||
|
||||
// 14. Verify build stats reflect our changes
|
||||
let stats = fc_common::repo::builds::get_stats(&pool)
|
||||
let build_stats = fc_common::repo::builds::get_stats(&pool)
|
||||
.await
|
||||
.expect("get stats");
|
||||
assert!(stats.completed_builds.unwrap_or(0) >= 2);
|
||||
assert!(build_stats.completed_builds.unwrap_or(0) >= 2);
|
||||
|
||||
// 15. Create a channel and verify it works
|
||||
let channel = fc_common::repo::channels::create(&pool, CreateChannel {
|
||||
|
|
|
|||
|
|
@ -46,3 +46,10 @@ Hydra follows a tightly-coupled architecture with three main daemons:
|
|||
```plaintext
|
||||
Git Repository -> Evaluator -> Database -> Queue Runner -> Build Hosts -> Results -> Database - Web UI
|
||||
```
|
||||
|
||||
1. **hydra-server** (Perl/Catalyst): Web interface and REST API
|
||||
2. **hydra-evaluator**: Polls Git repos, evaluates Nix expressions, creates
|
||||
`.drv` files
|
||||
3. **hydra-queue-runner**: Dispatches builds to available builders via SSH/Nix
|
||||
remote
|
||||
4. **Database (PostgreSQL)**: Central state management for all components
|
||||
|
|
|
|||
140
docs/README.md
140
docs/README.md
|
|
@ -185,48 +185,49 @@ development.
|
|||
|
||||
<!--markdownlint-disable MD013 -->
|
||||
|
||||
| Section | Key | Default | Description |
|
||||
| --------------- | ---------------------- | --------------------------------------------- | ----------------------------------------- |
|
||||
| `database` | `url` | `postgresql://fc_ci:password@localhost/fc_ci` | PostgreSQL connection URL |
|
||||
| `database` | `max_connections` | `20` | Maximum connection pool size |
|
||||
| `database` | `min_connections` | `5` | Minimum idle connections |
|
||||
| `database` | `connect_timeout` | `30` | Connection timeout (seconds) |
|
||||
| `database` | `idle_timeout` | `600` | Idle connection timeout (seconds) |
|
||||
| `database` | `max_lifetime` | `1800` | Maximum connection lifetime (seconds) |
|
||||
| `server` | `host` | `127.0.0.1` | HTTP listen address |
|
||||
| `server` | `port` | `3000` | HTTP listen port |
|
||||
| `server` | `request_timeout` | `30` | Per-request timeout (seconds) |
|
||||
| `server` | `max_body_size` | `10485760` | Maximum request body size (10 MB) |
|
||||
| `server` | `api_key` | none | Optional legacy API key (prefer DB keys) |
|
||||
| `server` | `cors_permissive` | `false` | Allow all CORS origins |
|
||||
| `server` | `allowed_origins` | `[]` | Allowed CORS origins list |
|
||||
| `server` | `rate_limit_rps` | none | Requests per second limit |
|
||||
| `server` | `rate_limit_burst` | none | Burst size for rate limiting |
|
||||
| `evaluator` | `poll_interval` | `60` | Seconds between git poll cycles |
|
||||
| `evaluator` | `git_timeout` | `600` | Git operation timeout (seconds) |
|
||||
| `evaluator` | `nix_timeout` | `1800` | Nix evaluation timeout (seconds) |
|
||||
| `evaluator` | `max_concurrent_evals` | `4` | Maximum concurrent evaluations |
|
||||
| `evaluator` | `work_dir` | `/tmp/fc-evaluator` | Working directory for clones |
|
||||
| `evaluator` | `restrict_eval` | `true` | Pass `--option restrict-eval true` to Nix |
|
||||
| `evaluator` | `allow_ifd` | `false` | Allow import-from-derivation |
|
||||
| `queue_runner` | `workers` | `4` | Concurrent build slots |
|
||||
| `queue_runner` | `poll_interval` | `5` | Seconds between build queue polls |
|
||||
| `queue_runner` | `build_timeout` | `3600` | Per-build timeout (seconds) |
|
||||
| `queue_runner` | `work_dir` | `/tmp/fc-queue-runner` | Working directory for builds |
|
||||
| `gc` | `enabled` | `true` | Manage GC roots for build outputs |
|
||||
| `gc` | `gc_roots_dir` | `/nix/var/nix/gcroots/per-user/fc/fc-roots` | GC roots directory |
|
||||
| `gc` | `max_age_days` | `30` | Remove GC roots older than N days |
|
||||
| `gc` | `cleanup_interval` | `3600` | GC cleanup interval (seconds) |
|
||||
| `logs` | `log_dir` | `/var/lib/fc/logs` | Build log storage directory |
|
||||
| `logs` | `compress` | `false` | Compress stored logs |
|
||||
| `cache` | `enabled` | `true` | Serve a Nix binary cache at `/nix-cache/` |
|
||||
| `cache` | `secret_key_file` | none | Signing key for binary cache |
|
||||
| `signing` | `enabled` | `false` | Sign build outputs |
|
||||
| `signing` | `key_file` | none | Signing key file path |
|
||||
| `notifications` | `webhook_url` | none | HTTP endpoint to POST build status JSON |
|
||||
| `notifications` | `github_token` | none | GitHub token for commit status updates |
|
||||
| `notifications` | `gitea_url` | none | Gitea/Forgejo instance URL |
|
||||
| `notifications` | `gitea_token` | none | Gitea/Forgejo API token |
|
||||
| Section | Key | Default | Description |
|
||||
| --------------- | ---------------------- | --------------------------------------------- | ----------------------------------------------------- |
|
||||
| `database` | `url` | `postgresql://fc_ci:password@localhost/fc_ci` | PostgreSQL connection URL |
|
||||
| `database` | `max_connections` | `20` | Maximum connection pool size |
|
||||
| `database` | `min_connections` | `5` | Minimum idle connections |
|
||||
| `database` | `connect_timeout` | `30` | Connection timeout (seconds) |
|
||||
| `database` | `idle_timeout` | `600` | Idle connection timeout (seconds) |
|
||||
| `database` | `max_lifetime` | `1800` | Maximum connection lifetime (seconds) |
|
||||
| `server` | `host` | `127.0.0.1` | HTTP listen address |
|
||||
| `server` | `port` | `3000` | HTTP listen port |
|
||||
| `server` | `request_timeout` | `30` | Per-request timeout (seconds) |
|
||||
| `server` | `max_body_size` | `10485760` | Maximum request body size (10 MB) |
|
||||
| `server` | `api_key` | none | Optional legacy API key (prefer DB keys) |
|
||||
| `server` | `cors_permissive` | `false` | Allow all CORS origins |
|
||||
| `server` | `allowed_origins` | `[]` | Allowed CORS origins list |
|
||||
| `server` | `force_secure_cookies` | `false` | Force Secure flag on cookies (enable for HTTPS proxy) |
|
||||
| `server` | `rate_limit_rps` | none | Requests per second limit per IP (DoS protection) |
|
||||
| `server` | `rate_limit_burst` | none | Burst size for rate limiting (e.g., 20) |
|
||||
| `evaluator` | `poll_interval` | `60` | Seconds between git poll cycles |
|
||||
| `evaluator` | `git_timeout` | `600` | Git operation timeout (seconds) |
|
||||
| `evaluator` | `nix_timeout` | `1800` | Nix evaluation timeout (seconds) |
|
||||
| `evaluator` | `max_concurrent_evals` | `4` | Maximum concurrent evaluations |
|
||||
| `evaluator` | `work_dir` | `/tmp/fc-evaluator` | Working directory for clones |
|
||||
| `evaluator` | `restrict_eval` | `true` | Pass `--option restrict-eval true` to Nix |
|
||||
| `evaluator` | `allow_ifd` | `false` | Allow import-from-derivation |
|
||||
| `queue_runner` | `workers` | `4` | Concurrent build slots |
|
||||
| `queue_runner` | `poll_interval` | `5` | Seconds between build queue polls |
|
||||
| `queue_runner` | `build_timeout` | `3600` | Per-build timeout (seconds) |
|
||||
| `queue_runner` | `work_dir` | `/tmp/fc-queue-runner` | Working directory for builds |
|
||||
| `gc` | `enabled` | `true` | Manage GC roots for build outputs |
|
||||
| `gc` | `gc_roots_dir` | `/nix/var/nix/gcroots/per-user/fc/fc-roots` | GC roots directory |
|
||||
| `gc` | `max_age_days` | `30` | Remove GC roots older than N days |
|
||||
| `gc` | `cleanup_interval` | `3600` | GC cleanup interval (seconds) |
|
||||
| `logs` | `log_dir` | `/var/lib/fc/logs` | Build log storage directory |
|
||||
| `logs` | `compress` | `false` | Compress stored logs |
|
||||
| `cache` | `enabled` | `true` | Serve a Nix binary cache at `/nix-cache/` |
|
||||
| `cache` | `secret_key_file` | none | Signing key for binary cache |
|
||||
| `signing` | `enabled` | `false` | Sign build outputs |
|
||||
| `signing` | `key_file` | none | Signing key file path |
|
||||
| `notifications` | `webhook_url` | none | HTTP endpoint to POST build status JSON |
|
||||
| `notifications` | `github_token` | none | GitHub token for commit status updates |
|
||||
| `notifications` | `gitea_url` | none | Gitea/Forgejo instance URL |
|
||||
| `notifications` | `gitea_token` | none | Gitea/Forgejo API token |
|
||||
|
||||
<!--markdownlint-enable MD013 -->
|
||||
|
||||
|
|
@ -300,6 +301,11 @@ proxy:
|
|||
server.host = "127.0.0.1";
|
||||
server.port = 3000;
|
||||
|
||||
# Security: enable when behind HTTPS reverse proxy
|
||||
server.force_secure_cookies = true;
|
||||
server.rate_limit_rps = 100;
|
||||
server.rate_limit_burst = 20;
|
||||
|
||||
evaluator.poll_interval = 300;
|
||||
evaluator.restrict_eval = true;
|
||||
queue_runner.workers = 8;
|
||||
|
|
@ -369,6 +375,56 @@ Ensure the PostgreSQL server on the head node allows connections from builder
|
|||
machines via `pg_hba.conf` (the NixOS `services.postgresql` module handles this
|
||||
with `authentication` settings).
|
||||
|
||||
#### Remote Builders via SSH
|
||||
|
||||
FC supports an alternative deployment model where a single queue-runner
|
||||
dispatches builds to remote builder machines via SSH. In this setup:
|
||||
|
||||
- **Head node**: runs `fc-server`, `fc-evaluator`, and **one** `fc-queue-runner`
|
||||
- **Builder machines**: standard NixOS machines with SSH access and Nix
|
||||
installed (no FC software required)
|
||||
|
||||
The queue-runner automatically attempts remote builds using:
|
||||
|
||||
```bash
|
||||
nix build --store ssh://<builder>
|
||||
```
|
||||
|
||||
when a build's `system` matches a configured remote builder. If no remote
|
||||
builder is available or all fail, it falls back to local execution.
|
||||
|
||||
You can configure remote builders via the REST API:
|
||||
|
||||
```bash
|
||||
# Create a remote builder
|
||||
curl -X POST http://localhost:3000/api/v1/admin/builders \
|
||||
-H 'Authorization: Bearer <admin-key>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "builder-1",
|
||||
"ssh_uri": "builder-1.example.org",
|
||||
"systems": ["x86_64-linux", "aarch64-linux"],
|
||||
"max_jobs": 4,
|
||||
"speed_factor": 1,
|
||||
"enabled": true
|
||||
}'
|
||||
```
|
||||
|
||||
Do note that this requires some SSH key setup. Namely.
|
||||
|
||||
- The queue-runner machine needs SSH access to each builder (public key in
|
||||
`~/.ssh/authorized_keys` on builders)
|
||||
- Use `ssh_key_file` in the builder config if using a non-default key
|
||||
- Add known host keys via `public_host_key` to prevent MITM warnings
|
||||
|
||||
The queue-runner tracks builder health automatically: consecutive failures
|
||||
disable the builder with exponential backoff until it recovers.
|
||||
|
||||
## Security
|
||||
|
||||
FC implements multiple security layers to protect your CI infrastructure. See
|
||||
[the security document](./SECURITY.md) for more details.
|
||||
|
||||
## Authentication
|
||||
|
||||
FC supports two authentication methods:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue