diff --git a/Cargo.lock b/Cargo.lock index 53deb5e..e00df57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 766702d..04f5c88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,76 @@ urlencoding = "2.1.3" uuid = { version = "1.18.1", features = [ "v4", "serde" ] } xz2 = "0.1.7" +# See: +# +[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" diff --git a/crates/common/src/alerts.rs b/crates/common/src/alerts.rs index 6d55f6c..95ebadc 100644 --- a/crates/common/src/alerts.rs +++ b/crates/common/src/alerts.rs @@ -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 diff --git a/crates/common/src/bootstrap.rs b/crates/common/src/bootstrap.rs index 3bf369f..9d0fafb 100644 --- a/crates/common/src/bootstrap.rs +++ b/crates/common/src/bootstrap.rs @@ -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 { - 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 { /// 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 = diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index 6f0296b..c7bedbf 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -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, - /// S3-specific configuration (used when store_uri starts with s3://) + /// S3-specific configuration (used when `store_uri` starts with s3://) pub s3: Option, } /// 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, @@ -223,36 +225,12 @@ pub struct S3CacheConfig { pub secret_access_key: Option, /// Session token for temporary credentials (optional) pub session_token: Option, - /// Endpoint URL for S3-compatible services (e.g., MinIO) + /// Endpoint URL for S3-compatible services (e.g., `MinIO`) pub endpoint_url: Option, - /// 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 { 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() { diff --git a/crates/common/src/database.rs b/crates/common/src/database.rs index d43523d..e28a46a 100644 --- a/crates/common/src/database.rs +++ b/crates/common/src/database.rs @@ -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 { 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 { 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 { diff --git a/crates/common/src/error.rs b/crates/common/src/error.rs index 2c15db0..78193fb 100644 --- a/crates/common/src/error.rs +++ b/crates/common/src/error.rs @@ -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 = std::result::Result; /// 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 { 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 { 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), diff --git a/crates/common/src/gc_roots.rs b/crates/common/src/gc_roots.rs index f5f318e..bb6152b 100644 --- a/crates/common/src/gc_roots.rs +++ b/crates/common/src/gc_roots.rs @@ -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::() { - 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::() + && 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 { 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, diff --git a/crates/common/src/log_storage.rs b/crates/common/src/log_storage.rs index 5e8ca79..5781a58 100644 --- a/crates/common/src/log_storage.rs +++ b/crates/common/src/log_storage.rs @@ -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 { 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> { 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() { diff --git a/crates/common/src/migrate.rs b/crates/common/src/migrate.rs index 2e56414..1b5b542 100644 --- a/crates/common/src/migrate.rs +++ b/crates/common/src/migrate.rs @@ -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 { } /// 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"); diff --git a/crates/common/src/migrate_cli.rs b/crates/common/src/migrate_cli.rs index 4eae397..a7bbf55 100644 --- a/crates/common/src/migrate_cli.rs +++ b/crates/common/src/migrate_cli.rs @@ -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(); diff --git a/crates/common/src/models.rs b/crates/common/src/models.rs index fbda198..a228fa9 100644 --- a/crates/common/src/models.rs +++ b/crates/common/src/models.rs @@ -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 { + /// 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 { 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, } -/// 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>, } +/// 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 { diff --git a/crates/common/src/nix_probe.rs b/crates/common/src/nix_probe.rs index 5385605..478c39d 100644 --- a/crates/common/src/nix_probe.rs +++ b/crates/common/src/nix_probe.rs @@ -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"); diff --git a/crates/common/src/notifications.rs b/crates/common/src/notifications.rs index 8ae09bd..d6eda62 100644 --- a/crates/common/src/notifications.rs +++ b/crates/common/src/notifications.rs @@ -267,9 +267,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 +328,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 +386,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 +600,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 +616,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 +665,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 +717,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 +768,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 +806,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 +841,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 +848,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", diff --git a/crates/common/src/repo/api_keys.rs b/crates/common/src/repo/api_keys.rs index 805f879..1b9d1e3 100644 --- a/crates/common/src/repo/api_keys.rs +++ b/crates/common/src/repo/api_keys.rs @@ -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> { 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> { .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) diff --git a/crates/common/src/repo/build_dependencies.rs b/crates/common/src/repo/build_dependencies.rs index 15550a7..7cdb5e6 100644 --- a/crates/common/src/repo/build_dependencies.rs +++ b/crates/common/src/repo/build_dependencies.rs @@ -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 { let row: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM build_dependencies bd JOIN builds b ON \ diff --git a/crates/common/src/repo/build_metrics.rs b/crates/common/src/repo/build_metrics.rs index e0df23e..c984cbc 100644 --- a/crates/common/src/repo/build_metrics.rs +++ b/crates/common/src/repo/build_metrics.rs @@ -7,6 +7,8 @@ use crate::{ models::BuildMetric, }; +type PercentileRow = (DateTime, Option, Option, Option); + /// Time-series data point for metrics visualization. #[derive(Debug, Clone)] pub struct TimeseriesPoint { @@ -32,6 +34,11 @@ pub struct DurationPercentiles { pub p99: Option, } +/// 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, @@ -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, @@ -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, @@ -143,18 +163,17 @@ pub async fn get_duration_percentiles_timeseries( hours: i32, bucket_minutes: i32, ) -> Result> { - let rows: Vec<(DateTime, Option, Option, Option)> = - sqlx::query_as( - "SELECT + let rows: Vec = 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, diff --git a/crates/common/src/repo/build_products.rs b/crates/common/src/repo/build_products.rs index 280ab1c..43b6fa1 100644 --- a/crates/common/src/repo/build_products.rs +++ b/crates/common/src/repo/build_products.rs @@ -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 { sqlx::query_as::<_, BuildProduct>( "SELECT * FROM build_products WHERE id = $1", @@ -37,6 +47,11 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result { .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, diff --git a/crates/common/src/repo/build_steps.rs b/crates/common/src/repo/build_steps.rs index d643c12..0291c33 100644 --- a/crates/common/src/repo/build_steps.rs +++ b/crates/common/src/repo/build_steps.rs @@ -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, diff --git a/crates/common/src/repo/builds.rs b/crates/common/src/repo/builds.rs index 6ac0a67..d3619cf 100644 --- a/crates/common/src/repo/builds.rs +++ b/crates/common/src/repo/builds.rs @@ -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 { 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 { }) } +/// 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 { 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 { .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> { 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> { .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> { 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> { .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 { match sqlx::query_as::<_, BuildStats>("SELECT * FROM build_stats") .fetch_optional(pool) @@ -178,6 +228,10 @@ pub async fn get_stats(pool: &PgPool) -> Result { /// 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, @@ -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, @@ -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 { 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 { } /// 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> { let mut cancelled = Vec::new(); @@ -312,7 +388,11 @@ pub async fn cancel_cascade(pool: &PgPool, id: Uuid) -> Result> { } /// 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 { 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 { } /// 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> { @@ -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 { 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 { } /// Set the `builder_id` for a build. +/// +/// # Errors +/// +/// Returns error if database update fails. pub async fn set_builder( pool: &PgPool, id: Uuid, diff --git a/crates/common/src/repo/channels.rs b/crates/common/src/repo/channels.rs index 844034c..89ec79d 100644 --- a/crates/common/src/repo/channels.rs +++ b/crates/common/src/repo/channels.rs @@ -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 { 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 { }) } +/// 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 { 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 { .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> { 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> { } /// 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) diff --git a/crates/common/src/repo/evaluations.rs b/crates/common/src/repo/evaluations.rs index a090696..e38929c 100644 --- a/crates/common/src/repo/evaluations.rs +++ b/crates/common/src/repo/evaluations.rs @@ -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 { 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 { .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, @@ -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, @@ -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 { let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM evaluations") .fetch_one(pool) @@ -171,7 +218,11 @@ pub async fn count(pool: &PgPool) -> Result { 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, diff --git a/crates/common/src/repo/failed_paths_cache.rs b/crates/common/src/repo/failed_paths_cache.rs index a3cc2ef..9a18b3c 100644 --- a/crates/common/src/repo/failed_paths_cache.rs +++ b/crates/common/src/repo/failed_paths_cache.rs @@ -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 { 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 { 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 { let result = sqlx::query( "DELETE FROM failed_paths_cache WHERE failed_at < NOW() - \ diff --git a/crates/common/src/repo/jobset_inputs.rs b/crates/common/src/repo/jobset_inputs.rs index eca2485..88dc8ad 100644 --- a/crates/common/src/repo/jobset_inputs.rs +++ b/crates/common/src/repo/jobset_inputs.rs @@ -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, diff --git a/crates/common/src/repo/jobsets.rs b/crates/common/src/repo/jobsets.rs index 0f2d323..62b5d4a 100644 --- a/crates/common/src/repo/jobsets.rs +++ b/crates/common/src/repo/jobsets.rs @@ -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 { 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 { }) } +/// 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 { 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 { .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 { 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 { 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 { 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 { .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> { sqlx::query_as::<_, ActiveJobset>("SELECT * FROM active_jobsets") .fetch_all(pool) @@ -199,6 +239,10 @@ pub async fn list_active(pool: &PgPool) -> Result> { } /// 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, diff --git a/crates/common/src/repo/notification_configs.rs b/crates/common/src/repo/notification_configs.rs index 164d00e..565532b 100644 --- a/crates/common/src/repo/notification_configs.rs +++ b/crates/common/src/repo/notification_configs.rs @@ -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, diff --git a/crates/common/src/repo/notification_tasks.rs b/crates/common/src/repo/notification_tasks.rs index c2effdb..f8aa218 100644 --- a/crates/common/src/repo/notification_tasks.rs +++ b/crates/common/src/repo/notification_tasks.rs @@ -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 { 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> { 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 { 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 { } /// 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 { 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 { 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 { } /// Count failed tasks (for monitoring) +/// +/// # Errors +/// +/// Returns error if database query fails. pub async fn count_failed(pool: &PgPool) -> Result { let count: (i64,) = sqlx::query_as( - r#" + r" SELECT COUNT(*) FROM notification_tasks WHERE status = 'failed' - "#, + ", ) .fetch_one(pool) .await?; diff --git a/crates/common/src/repo/project_members.rs b/crates/common/src/repo/project_members.rs index 1571353..54c05d0 100644 --- a/crates/common/src/repo/project_members.rs +++ b/crates/common/src/repo/project_members.rs @@ -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 { sqlx::query_as::<_, ProjectMember>( "SELECT * FROM project_members WHERE id = $1", @@ -61,6 +69,10 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result { } /// 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, diff --git a/crates/common/src/repo/projects.rs b/crates/common/src/repo/projects.rs index 5b0f953..a2209dc 100644 --- a/crates/common/src/repo/projects.rs +++ b/crates/common/src/repo/projects.rs @@ -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 { 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 { }) } +/// 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 { 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 { .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 { 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 { .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 { let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM projects") .fetch_one(pool) @@ -65,12 +90,17 @@ pub async fn count(pool: &PgPool) -> Result { 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 { - // 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 { 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 { .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) diff --git a/crates/common/src/repo/remote_builders.rs b/crates/common/src/repo/remote_builders.rs index 6ceb147..3887787 100644 --- a/crates/common/src/repo/remote_builders.rs +++ b/crates/common/src/repo/remote_builders.rs @@ -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 { sqlx::query_as::<_, RemoteBuilder>( "SELECT * FROM remote_builders WHERE id = $1", @@ -50,6 +60,11 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result { .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> { 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> { .map_err(CiError::Database) } +/// List all enabled remote builders. +/// +/// # Errors +/// +/// Returns error if database query fails. pub async fn list_enabled(pool: &PgPool) -> Result> { 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> { /// 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 { sqlx::query_as::<_, RemoteBuilder>( "UPDATE remote_builders SET consecutive_failures = \ @@ -105,7 +134,11 @@ pub async fn record_failure(pool: &PgPool, id: Uuid) -> Result { } /// 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 { 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 { .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 { - // 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 { 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 { } /// 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 { 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(()) diff --git a/crates/common/src/repo/search.rs b/crates/common/src/repo/search.rs index ee6993b..6756de3 100644 --- a/crates/common/src/repo/search.rs +++ b/crates/common/src/repo/search.rs @@ -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, diff --git a/crates/common/src/repo/starred_jobs.rs b/crates/common/src/repo/starred_jobs.rs index a720eef..7a640e0 100644 --- a/crates/common/src/repo/starred_jobs.rs +++ b/crates/common/src/repo/starred_jobs.rs @@ -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 { 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 { } /// 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 { 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 { } /// 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) diff --git a/crates/common/src/repo/users.rs b/crates/common/src/repo/users.rs index 10b5c9b..7b70eaa 100644 --- a/crates/common/src/repo/users.rs +++ b/crates/common/src/repo/users.rs @@ -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 { use argon2::{ Argon2, @@ -33,6 +37,10 @@ pub fn hash_password(password: &str) -> Result { } /// Verify a password against a hash +/// +/// # Errors +/// +/// Returns error if password hash parsing fails. pub fn verify_password(password: &str, hash: &str) -> Result { use argon2::{Argon2, PasswordHash, PasswordVerifier}; @@ -47,6 +55,10 @@ pub fn verify_password(password: &str, hash: &str) -> Result { } /// 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 { // Validate username validate_username(&data.username) @@ -94,6 +106,10 @@ pub async fn create(pool: &PgPool, data: &CreateUser) -> Result { } /// 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 { 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 { } /// 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> { 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> { } /// List all users with pagination +/// +/// # Errors +/// +/// Returns error if database query fails. pub async fn list(pool: &PgPool, limit: i64, offset: i64) -> Result> { 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> { } /// Count total users +/// +/// # Errors +/// +/// Returns error if database query fails. pub async fn count(pool: &PgPool) -> Result { let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") .fetch_one(pool) @@ -186,6 +222,10 @@ pub async fn count(pool: &PgPool) -> Result { } /// 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) diff --git a/crates/common/src/repo/webhook_configs.rs b/crates/common/src/repo/webhook_configs.rs index 5d5ef01..22fb1ad 100644 --- a/crates/common/src/repo/webhook_configs.rs +++ b/crates/common/src/repo/webhook_configs.rs @@ -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 { sqlx::query_as::<_, WebhookConfig>( "SELECT * FROM webhook_configs WHERE id = $1", @@ -44,6 +54,11 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result { .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, diff --git a/crates/common/src/validate.rs b/crates/common/src/validate.rs index 8fe3010..7523a2b 100644 --- a/crates/common/src/validate.rs +++ b/crates/common/src/validate.rs @@ -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::() { - 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::() + && (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(), }; diff --git a/crates/common/src/validation.rs b/crates/common/src/validation.rs index 81246f1..be6b2f4 100644 --- a/crates/common/src/validation.rs +++ b/crates/common/src/validation.rs @@ -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 { diff --git a/crates/common/tests/database_tests.rs b/crates/common/tests/database_tests.rs index ebe320f..6f755e0 100644 --- a/crates/common/tests/database_tests.rs +++ b/crates/common/tests/database_tests.rs @@ -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 diff --git a/crates/common/tests/mod.rs b/crates/common/tests/mod.rs index 80c50d9..9f396e4 100644 --- a/crates/common/tests/mod.rs +++ b/crates/common/tests/mod.rs @@ -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 diff --git a/crates/common/tests/repo_tests.rs b/crates/common/tests/repo_tests.rs index 5244da2..959f897 100644 --- a/crates/common/tests/repo_tests.rs +++ b/crates/common/tests/repo_tests.rs @@ -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 { - 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; diff --git a/crates/common/tests/search_tests.rs b/crates/common/tests/search_tests.rs index a97ae29..f66d464 100644 --- a/crates/common/tests/search_tests.rs +++ b/crates/common/tests/search_tests.rs @@ -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 { - 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 { #[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 diff --git a/crates/common/tests/user_management_tests.rs b/crates/common/tests/user_management_tests.rs index 680ae82..170757e 100644 --- a/crates/common/tests/user_management_tests.rs +++ b/crates/common/tests/user_management_tests.rs @@ -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 { - 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 { #[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(); diff --git a/crates/evaluator/src/eval_loop.rs b/crates/evaluator/src/eval_loop.rs index 38b74e5..3847520 100644 --- a/crates/evaluator/src/eval_loop.rs +++ b/crates/evaluator/src/eval_loop.rs @@ -20,6 +20,11 @@ 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, @@ -57,13 +62,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(); @@ -91,11 +93,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" ); } } @@ -129,13 +130,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 +278,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 }, @@ -420,12 +420,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 +433,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 +446,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 +476,20 @@ async fn check_declarative_config( repo_path: &std::path::Path, project_id: Uuid, ) { + #[derive(serde::Deserialize)] + struct DeclarativeConfig { + jobsets: Option>, + } + + #[derive(serde::Deserialize)] + struct DeclarativeJobset { + name: String, + nix_expression: String, + flake_mode: Option, + check_interval: Option, + enabled: Option, + } + let config_path = repo_path.join(".fc.toml"); let alt_config_path = repo_path.join(".fc/config.toml"); @@ -502,20 +512,6 @@ async fn check_declarative_config( }, }; - #[derive(serde::Deserialize)] - struct DeclarativeConfig { - jobsets: Option>, - } - - #[derive(serde::Deserialize)] - struct DeclarativeJobset { - name: String, - nix_expression: String, - flake_mode: Option, - check_interval: Option, - enabled: Option, - } - let config: DeclarativeConfig = match toml::from_str(&content) { Ok(c) => c, Err(e) => { diff --git a/crates/evaluator/src/git.rs b/crates/evaluator/src/git.rs index 6521c44..c666aca 100644 --- a/crates/evaluator/src/git.rs +++ b/crates/evaluator/src/git.rs @@ -7,6 +7,10 @@ use git2::Repository; /// /// If `branch` is `Some`, resolve `refs/remotes/origin/` 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}"); diff --git a/crates/evaluator/src/nix.rs b/crates/evaluator/src/nix.rs index 8451944..890d632 100644 --- a/crates/evaluator/src/nix.rs +++ b/crates/evaluator/src/nix.rs @@ -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, diff --git a/crates/evaluator/tests/git_tests.rs b/crates/evaluator/tests/git_tests.rs index 7047012..565709e 100644 --- a/crates/evaluator/tests/git_tests.rs +++ b/crates/evaluator/tests/git_tests.rs @@ -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; diff --git a/crates/queue-runner/src/builder.rs b/crates/queue-runner/src/builder.rs index 9540349..46b4b46 100644 --- a/crates/queue-runner/src/builder.rs +++ b/crates/queue-runner/src/builder.rs @@ -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:?}" + ))) + }) } diff --git a/crates/queue-runner/src/main.rs b/crates/queue-runner/src/main.rs index 70a8b58..8fe4a73 100644 --- a/crates/queue-runner/src/main.rs +++ b/crates/queue-runner/src/main.rs @@ -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( diff --git a/crates/queue-runner/src/runner_loop.rs b/crates/queue-runner/src/runner_loop.rs index 00129e0..8494b0b 100644 --- a/crates/queue-runner/src/runner_loop.rs +++ b/crates/queue-runner/src/runner_loop.rs @@ -9,6 +9,12 @@ use tokio::sync::Notify; use crate::worker::WorkerPool; +/// 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, @@ -42,7 +48,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, @@ -115,34 +121,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 diff --git a/crates/queue-runner/src/worker.rs b/crates/queue-runner/src/worker.rs index 094c58e..b9a79b7 100644 --- a/crates/queue-runner/src/worker.rs +++ b/crates/queue-runner/src/worker.rs @@ -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 +/// 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; } } @@ -561,7 +499,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 +643,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 +678,9 @@ async fn run_build( collect_metrics_and_alert( pool, - &build, + build, &build_result.output_paths, - &alert_manager, + alert_manager, ) .await; @@ -775,8 +713,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 +742,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 +783,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); + } +} diff --git a/crates/queue-runner/tests/runner_tests.rs b/crates/queue-runner/tests/runner_tests.rs index ff4f4a9..bbd4686 100644 --- a/crates/queue-runner/tests/runner_tests.rs +++ b/crates/queue-runner/tests/runner_tests.rs @@ -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() diff --git a/crates/server/src/auth_middleware.rs b/crates/server/src/auth_middleware.rs index bf5e383..3a3618d 100644 --- a/crates/server/src/auth_middleware.rs +++ b/crates/server/src/auth_middleware.rs @@ -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, mut request: Request, @@ -164,6 +169,12 @@ impl FromRequestParts 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 { - 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 + } + }) } diff --git a/crates/server/src/routes/admin.rs b/crates/server/src/routes/admin.rs index 86d1b4c..047e5dc 100644 --- a/crates/server/src/routes/admin.rs +++ b/crates/server/src/routes/admin.rs @@ -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, })) diff --git a/crates/server/src/routes/badges.rs b/crates/server/src/routes/badges.rs index bbcb64c..192ff71 100644 --- a/crates/server/src/routes/badges.rs +++ b/crates/server/src/routes/badges.rs @@ -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 { diff --git a/crates/server/src/routes/builds.rs b/crates/server/src/routes/builds.rs index ae5839a..f500015 100644 --- a/crates/server/src/routes/builds.rs +++ b/crates/server/src/routes/builds.rs @@ -133,10 +133,10 @@ async fn list_build_products( async fn build_stats( State(state): State, ) -> Result, 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); diff --git a/crates/server/src/routes/cache.rs b/crates/server/src/routes/cache.rs index fea7428..4962d59 100644 --- a/crates/server/src/routes/cache.rs +++ b/crates/server/src/routes/cache.rs @@ -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, Path(hash): Path, ) -> Result { + 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, diff --git a/crates/server/src/routes/channels.rs b/crates/server/src/routes/channels.rs index f89aa02..f1cb726 100644 --- a/crates/server/src/routes/channels.rs +++ b/crates/server/src/routes/channels.rs @@ -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 = \ diff --git a/crates/server/src/routes/dashboard.rs b/crates/server/src/routes/dashboard.rs index 7971fef..95d2b63 100644 --- a/crates/server/src/routes/dashboard.rs +++ b/crates/server/src/routes/dashboard.rs @@ -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, } -/// 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, extensions: Extensions, ) -> Html { - 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, extensions: Extensions, ) -> Html { - 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, Path(id): Path, ) -> Html { - 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, Path(id): Path, ) -> Html { - 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, Path(id): Path, ) -> Html { - 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) -> Html { let running_builds: Vec = 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) }; diff --git a/crates/server/src/routes/logs.rs b/crates/server/src/routes/logs.rs index 7105490..a6a2b6f 100644 --- a/crates/server/src/routes/logs.rs +++ b/crates/server/src/routes/logs.rs @@ -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 diff --git a/crates/server/src/routes/metrics.rs b/crates/server/src/routes/metrics.rs index df1fd44..27b9f6e 100644 --- a/crates/server/src/routes/metrics.rs +++ b/crates/server/src/routes/metrics.rs @@ -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) -> 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) -> 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) -> 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) -> 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) -> Response { let _ = writeln!( output, "fc_queue_depth {}", - stats.pending_builds.unwrap_or(0) + build_stats.pending_builds.unwrap_or(0) ); // Infrastructure diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs index 0f70437..0e893e8 100644 --- a/crates/server/src/routes/mod.rs +++ b/crates/server/src/routes/mod.rs @@ -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 { diff --git a/crates/server/src/routes/oauth.rs b/crates/server/src/routes/oauth.rs index 9514aa3..43996d9 100644 --- a/crates/server/src/routes/oauth.rs +++ b/crates/server/src/routes/oauth.rs @@ -89,12 +89,9 @@ fn build_github_client(config: &GitHubOAuthConfig) -> GitHubOAuthClient { } async fn github_login(State(state): State) -> 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, ) -> Result { - 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")); diff --git a/crates/server/src/routes/webhooks.rs b/crates/server/src/routes/webhooks.rs index d117e61..f843366 100644 --- a/crates/server/src/routes/webhooks.rs +++ b/crates/server/src/routes/webhooks.rs @@ -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), 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::::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::::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] diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index 4b783e3..45d8fd2 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -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) diff --git a/crates/server/tests/api_tests.rs b/crates/server/tests/api_tests.rs index 1c094b6..fb60174 100644 --- a/crates/server/tests/api_tests.rs +++ b/crates/server/tests/api_tests.rs @@ -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 { - 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); diff --git a/crates/server/tests/e2e_test.rs b/crates/server/tests/e2e_test.rs index 32c62a7..4736d59 100644 --- a/crates/server/tests/e2e_test.rs +++ b/crates/server/tests/e2e_test.rs @@ -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 { - 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 { #[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 { diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 517bf82..fd94589 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 diff --git a/docs/README.md b/docs/README.md index 526674f..91a77d8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -185,48 +185,49 @@ development. -| 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 | @@ -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:// +``` + +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 ' \ + -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: